Merge branch 'main' into andrew/placeholder-updates-to-workspace-settings

This commit is contained in:
andrewwallacespeckle
2025-05-23 09:44:30 +02:00
19 changed files with 332 additions and 129 deletions
+1 -2
View File
@@ -2,7 +2,7 @@
# shellcheck disable=SC1091
set -e
if [ -n "$CI" ]
if [ -n "$CI" ]
then
echo "running eslint"
yarn eslint:projectwide
@@ -16,7 +16,6 @@ else
yarn lint-staged
fi
echo "🔍 looking for additional linter dependencies"
check_dependencies_available() {
+7 -7
View File
@@ -9,9 +9,9 @@
"node": "^22.6.0"
},
"scripts": {
"build": "yarn workspaces foreach -ptvW run build",
"build:public": "yarn workspaces foreach -ptvW --no-private run build",
"build:tailwind-deps": "yarn workspaces foreach -ivW -j unlimited --include '{@speckle/shared,@speckle/tailwind-theme,@speckle/ui-components}' run build",
"build": "yarn workspaces foreach --parallel --topological --verbose --worktree run build",
"build:public": "yarn ensure:tailwind-deps && yarn workspace @speckle/frontend-2 build:postinstall && yarn workspaces foreach --parallel --topological --verbose --worktree --no-private run build",
"build:tailwind-deps": "yarn workspaces foreach --interlaced --verbose --worktree --jobs unlimited --include '{@speckle/shared,@speckle/tailwind-theme,@speckle/ui-components}' run build",
"ensure:tailwind-deps": "node ./utils/ensure-tailwind-deps.mjs",
"helm:readme:generate": "./utils/helm/update-schema-json.sh",
"prettier:check": "prettier --check .",
@@ -28,10 +28,10 @@
"dev:kind:helm:up": "yarn dev:kind:up && tilt up --file ./.circleci/deployment/Tiltfile.helm --context kind-speckle-server",
"dev:kind:helm:down": "tilt down --file ./.circleci/deployment/Tiltfile.helm --context kind-speckle-server",
"dev:kind:helm:ci": "tilt ci --file ./.circleci/deployment/Tiltfile.helm --context kind-speckle-server --timeout 10m",
"dev": "yarn workspaces foreach -pivW -j unlimited run dev",
"dev:no-server": "yarn workspaces foreach --exclude @speckle/server -pivW -j unlimited run dev",
"dev:minimal": "yarn workspaces foreach -pivW -j unlimited --include '{@speckle/server,@speckle/frontend-2}' run dev",
"gqlgen": "yarn workspaces foreach -pivW -j unlimited --include '{@speckle/server,@speckle/frontend,@speckle/frontend-2}' run gqlgen",
"dev": "yarn workspaces foreach --parallel --interlaced --verbose --worktree --jobs unlimited run dev",
"dev:no-server": "yarn workspaces foreach --exclude @speckle/server --parallel --interlaced --verbose --worktree --jobs unlimited run dev",
"dev:minimal": "yarn workspaces foreach --parallel --interlaced --verbose --worktree --jobs unlimited --include '{@speckle/server,@speckle/frontend-2}' run dev",
"gqlgen": "yarn workspaces foreach --parallel --interlaced --verbose --worktree --jobs unlimited --include '{@speckle/server,@speckle/frontend-2}' run gqlgen",
"dev:server": "yarn workspace @speckle/server dev",
"dev:frontend-2": "yarn workspace @speckle/frontend-2 dev",
"dev:shared": "yarn workspace @speckle/shared dev",
@@ -65,6 +65,59 @@
<span>{{ option.label }}</span>
</label>
</div>
<div v-if="isWorkspacesEnabled">
<label
:for="`option-hide-logo`"
class="flex items-center gap-1 cursor-pointer max-w-max"
>
<FormCheckbox
id="option-hide-logo"
v-model="hideSpeckleBranding"
name="Hide Speckle logo"
hide-label
class="cursor-pointer"
:disabled="
workspaceHideSpeckleBrandingEnabled ||
!canEditEmbedOptions?.authorized
"
/>
<div class="flex flex-col gap-0.5">
<span
:key="`hide-branding-tooltip-${workspaceHideSpeckleBrandingEnabled}`"
v-tippy="hideSpeckleBrandingTooltip"
>
Hide Speckle logo
</span>
<span
v-if="
!canEditEmbedOptions?.authorized &&
canEditEmbedOptions?.code === 'WorkspaceNoFeatureAccess'
"
class="text-body-2xs text-foreground-2"
>
This feature is only available on the business plan
<NuxtLink
:to="settingsWorkspaceRoutes.billing.route(workspaceSlug)"
class="underline"
>
upgrade now
</NuxtLink>
</span>
<span
v-if="hideSpeckleBranding && !workspaceHideSpeckleBrandingEnabled"
class="text-body-2xs text-foreground-2"
>
Tip: You can also hide the logo for all embeds in
<NuxtLink
:to="settingsWorkspaceRoutes.billing.route(workspaceSlug)"
class="underline"
>
workspace settings.
</NuxtLink>
</span>
</div>
</label>
</div>
</div>
</LayoutDialogSection>
<LayoutDialogSection
@@ -100,11 +153,24 @@ import {
castToSupportedVisibility,
SupportedProjectVisibility
} from '~/lib/projects/helpers/visibility'
import { settingsWorkspaceRoutes } from '~/lib/common/helpers/route'
graphql(`
fragment ProjectsModelPageEmbed_Project on Project {
id
...ProjectsPageTeamDialogManagePermissions_Project
workspace {
id
slug
embedOptions {
hideSpeckleBranding
}
permissions {
canEditEmbedOptions {
...FullPermissionCheckResult
}
}
}
}
`)
@@ -123,6 +189,7 @@ const {
public: { baseUrl }
} = useRuntimeConfig()
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const { isSmallerOrEqualSm } = useIsSmallerOrEqualThanBreakpoint()
const updateProject = useUpdateProject()
const mp = useMixpanel()
@@ -134,6 +201,7 @@ const disableModelLink = ref(false)
const preventScrolling = ref(false)
const manuallyLoadModel = ref(false)
const projectVisibility = ref(props.project.visibility)
const hideSpeckleBranding = ref(false)
const routeModelId = computed(() => route.params.modelId as string)
@@ -176,6 +244,14 @@ const updatedUrl = computed(() => {
}
})
if (
hideSpeckleBranding.value &&
isWorkspacesEnabled.value &&
!workspaceHideSpeckleBrandingEnabled.value
) {
embedOptions['hideSpeckleBranding'] = true
}
// Serialize the embedOptions into a hash fragment
const hashFragment = encodeURIComponent(JSON.stringify(embedOptions))
url.hash = `embed=${hashFragment}`
@@ -228,6 +304,25 @@ const nonDiscoverableButtons = computed((): LayoutDialogButton[] => [
}
])
const workspaceSlug = computed(() => {
return props.project.workspace?.slug
})
const canEditEmbedOptions = computed(() => {
return props.project.workspace?.permissions?.canEditEmbedOptions
})
const workspaceHideSpeckleBrandingEnabled = computed(() => {
if (!isWorkspacesEnabled.value) return false
return props.project.workspace?.embedOptions?.hideSpeckleBranding
})
const hideSpeckleBrandingTooltip = computed(() => {
if (!isWorkspacesEnabled.value) return ''
if (workspaceHideSpeckleBrandingEnabled.value) {
return 'Speckle branding is disabled for all embeds in this workspace'
}
return ''
})
const handleEmbedCodeCopy = async (value: string) => {
await copy(value, {
successMessage: 'Embed code copied to clipboard',
@@ -292,4 +387,15 @@ const embedDialogOptions = [
value: manuallyLoadModel
}
]
watch(
() => props.project.workspace?.embedOptions?.hideSpeckleBranding,
() => {
if (isWorkspacesEnabled.value) {
hideSpeckleBranding.value =
props.project.workspace?.embedOptions?.hideSpeckleBranding ?? false
}
},
{ immediate: true }
)
</script>
@@ -102,7 +102,7 @@
:name="modelName || 'Loading...'"
:date="lastUpdate"
:url="route.path"
:hide-speckle-branding="hideSpeckleBranding"
:hide-speckle-branding="hideSpeckleLogo"
:disable-model-link="disableModelLink"
/>
<Portal to="primary-actions">
@@ -142,6 +142,7 @@ graphql(`
embedOptions {
hideSpeckleBranding
}
hasAccessToFeature(featureName: hideSpeckleBranding)
}
`)
@@ -177,7 +178,8 @@ const {
hideSelectionInfo,
isTransparent,
showControls,
disableModelLink
disableModelLink,
hideSpeckleBranding
} = useEmbed()
const mp = useMixpanel()
@@ -262,8 +264,15 @@ const lastUpdate = computed(() => {
} else return undefined
})
const hideSpeckleBranding = computed(() => {
return project.value ? project.value?.embedOptions?.hideSpeckleBranding : true
const canEditEmbedOptions = computed(() => {
return project.value?.hasAccessToFeature
})
const hideSpeckleLogo = computed(() => {
if (!project.value?.workspace) return true
if (!canEditEmbedOptions.value) return false
if (project.value?.embedOptions?.hideSpeckleBranding) return true
else return hideSpeckleBranding.value
})
useHead({ title })
@@ -48,7 +48,7 @@ defineProps<{
date?: string
name?: string
url?: string
hideSpeckleBranding?: boolean
hideSpeckleBranding?: MaybeNullOrUndefined<boolean>
disableModelLink?: MaybeNullOrUndefined<boolean>
}>()
@@ -61,7 +61,7 @@ type Documents = {
"\n fragment ProjectModelPageDialogDeleteVersion on Version {\n id\n message\n }\n": typeof types.ProjectModelPageDialogDeleteVersionFragmentDoc,
"\n fragment ProjectModelPageDialogEditMessageVersion on Version {\n id\n message\n }\n": typeof types.ProjectModelPageDialogEditMessageVersionFragmentDoc,
"\n fragment ProjectModelPageDialogMoveToVersion on Version {\n id\n message\n }\n": typeof types.ProjectModelPageDialogMoveToVersionFragmentDoc,
"\n fragment ProjectsModelPageEmbed_Project on Project {\n id\n ...ProjectsPageTeamDialogManagePermissions_Project\n }\n": typeof types.ProjectsModelPageEmbed_ProjectFragmentDoc,
"\n fragment ProjectsModelPageEmbed_Project on Project {\n id\n ...ProjectsPageTeamDialogManagePermissions_Project\n workspace {\n id\n slug\n embedOptions {\n hideSpeckleBranding\n }\n permissions {\n canEditEmbedOptions {\n ...FullPermissionCheckResult\n }\n }\n }\n }\n": typeof types.ProjectsModelPageEmbed_ProjectFragmentDoc,
"\n fragment ProjectModelPageVersionsCardVersion on Version {\n id\n message\n authorUser {\n ...LimitedUserAvatar\n }\n createdAt\n previewUrl\n referencedObject\n sourceApplication\n commentThreadCount: commentThreads(limit: 0) {\n totalCount\n }\n ...ProjectModelPageDialogDeleteVersion\n ...ProjectModelPageDialogMoveToVersion\n automationsStatus {\n ...AutomateRunsTriggerStatus_TriggeredAutomationsStatus\n }\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.ProjectModelPageVersionsCardVersionFragmentDoc,
"\n fragment ProjectPageProjectHeader on Project {\n id\n name\n description\n workspace {\n id\n slug\n name\n logo\n role\n }\n }\n": typeof types.ProjectPageProjectHeaderFragmentDoc,
"\n fragment ProjectPageAutomationDeleteDialog_Project on Project {\n id\n name\n workspaceId\n }\n": typeof types.ProjectPageAutomationDeleteDialog_ProjectFragmentDoc,
@@ -129,7 +129,7 @@ type Documents = {
"\n fragment SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain on WorkspaceDomain {\n id\n domain\n }\n": typeof types.SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomainFragmentDoc,
"\n fragment SettingsWorkspacesSecurityDomainRemoveDialog_Workspace on Workspace {\n id\n domains {\n ...SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain\n }\n }\n": typeof types.SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesSecuritySsoWrapper_Workspace on Workspace {\n id\n role\n slug\n sso {\n provider {\n id\n name\n clientId\n issuerUrl\n }\n }\n hasAccessToSSO: hasAccessToFeature(featureName: oidcSso)\n }\n": typeof types.SettingsWorkspacesSecuritySsoWrapper_WorkspaceFragmentDoc,
"\n fragment ModelPageProject on Project {\n id\n createdAt\n name\n visibility\n workspace {\n id\n slug\n name\n role\n }\n embedOptions {\n hideSpeckleBranding\n }\n }\n": typeof types.ModelPageProjectFragmentDoc,
"\n fragment ModelPageProject on Project {\n id\n createdAt\n name\n visibility\n workspace {\n id\n slug\n name\n role\n }\n embedOptions {\n hideSpeckleBranding\n }\n hasAccessToFeature(featureName: hideSpeckleBranding)\n }\n": typeof types.ModelPageProjectFragmentDoc,
"\n fragment ViewerCommentThreadData on Comment {\n id\n permissions {\n canArchive {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.ViewerCommentThreadDataFragmentDoc,
"\n fragment ThreadCommentAttachment on Comment {\n text {\n attachments {\n id\n fileName\n fileType\n fileSize\n }\n }\n }\n": typeof types.ThreadCommentAttachmentFragmentDoc,
"\n fragment ViewerCommentsListItem on Comment {\n id\n rawText\n archived\n author {\n ...LimitedUserAvatar\n }\n createdAt\n viewedAt\n replies {\n totalCount\n cursor\n items {\n ...ViewerCommentsReplyItem\n }\n }\n replyAuthors(limit: 4) {\n totalCount\n items {\n ...FormUsersSelectItem\n }\n }\n resources {\n resourceId\n resourceType\n }\n }\n": typeof types.ViewerCommentsListItemFragmentDoc,
@@ -481,7 +481,7 @@ const documents: Documents = {
"\n fragment ProjectModelPageDialogDeleteVersion on Version {\n id\n message\n }\n": types.ProjectModelPageDialogDeleteVersionFragmentDoc,
"\n fragment ProjectModelPageDialogEditMessageVersion on Version {\n id\n message\n }\n": types.ProjectModelPageDialogEditMessageVersionFragmentDoc,
"\n fragment ProjectModelPageDialogMoveToVersion on Version {\n id\n message\n }\n": types.ProjectModelPageDialogMoveToVersionFragmentDoc,
"\n fragment ProjectsModelPageEmbed_Project on Project {\n id\n ...ProjectsPageTeamDialogManagePermissions_Project\n }\n": types.ProjectsModelPageEmbed_ProjectFragmentDoc,
"\n fragment ProjectsModelPageEmbed_Project on Project {\n id\n ...ProjectsPageTeamDialogManagePermissions_Project\n workspace {\n id\n slug\n embedOptions {\n hideSpeckleBranding\n }\n permissions {\n canEditEmbedOptions {\n ...FullPermissionCheckResult\n }\n }\n }\n }\n": types.ProjectsModelPageEmbed_ProjectFragmentDoc,
"\n fragment ProjectModelPageVersionsCardVersion on Version {\n id\n message\n authorUser {\n ...LimitedUserAvatar\n }\n createdAt\n previewUrl\n referencedObject\n sourceApplication\n commentThreadCount: commentThreads(limit: 0) {\n totalCount\n }\n ...ProjectModelPageDialogDeleteVersion\n ...ProjectModelPageDialogMoveToVersion\n automationsStatus {\n ...AutomateRunsTriggerStatus_TriggeredAutomationsStatus\n }\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.ProjectModelPageVersionsCardVersionFragmentDoc,
"\n fragment ProjectPageProjectHeader on Project {\n id\n name\n description\n workspace {\n id\n slug\n name\n logo\n role\n }\n }\n": types.ProjectPageProjectHeaderFragmentDoc,
"\n fragment ProjectPageAutomationDeleteDialog_Project on Project {\n id\n name\n workspaceId\n }\n": types.ProjectPageAutomationDeleteDialog_ProjectFragmentDoc,
@@ -549,7 +549,7 @@ const documents: Documents = {
"\n fragment SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain on WorkspaceDomain {\n id\n domain\n }\n": types.SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomainFragmentDoc,
"\n fragment SettingsWorkspacesSecurityDomainRemoveDialog_Workspace on Workspace {\n id\n domains {\n ...SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain\n }\n }\n": types.SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesSecuritySsoWrapper_Workspace on Workspace {\n id\n role\n slug\n sso {\n provider {\n id\n name\n clientId\n issuerUrl\n }\n }\n hasAccessToSSO: hasAccessToFeature(featureName: oidcSso)\n }\n": types.SettingsWorkspacesSecuritySsoWrapper_WorkspaceFragmentDoc,
"\n fragment ModelPageProject on Project {\n id\n createdAt\n name\n visibility\n workspace {\n id\n slug\n name\n role\n }\n embedOptions {\n hideSpeckleBranding\n }\n }\n": types.ModelPageProjectFragmentDoc,
"\n fragment ModelPageProject on Project {\n id\n createdAt\n name\n visibility\n workspace {\n id\n slug\n name\n role\n }\n embedOptions {\n hideSpeckleBranding\n }\n hasAccessToFeature(featureName: hideSpeckleBranding)\n }\n": types.ModelPageProjectFragmentDoc,
"\n fragment ViewerCommentThreadData on Comment {\n id\n permissions {\n canArchive {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.ViewerCommentThreadDataFragmentDoc,
"\n fragment ThreadCommentAttachment on Comment {\n text {\n attachments {\n id\n fileName\n fileType\n fileSize\n }\n }\n }\n": types.ThreadCommentAttachmentFragmentDoc,
"\n fragment ViewerCommentsListItem on Comment {\n id\n rawText\n archived\n author {\n ...LimitedUserAvatar\n }\n createdAt\n viewedAt\n replies {\n totalCount\n cursor\n items {\n ...ViewerCommentsReplyItem\n }\n }\n replyAuthors(limit: 4) {\n totalCount\n items {\n ...FormUsersSelectItem\n }\n }\n resources {\n resourceId\n resourceType\n }\n }\n": types.ViewerCommentsListItemFragmentDoc,
@@ -1059,7 +1059,7 @@ export function graphql(source: "\n fragment ProjectModelPageDialogMoveToVersio
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment ProjectsModelPageEmbed_Project on Project {\n id\n ...ProjectsPageTeamDialogManagePermissions_Project\n }\n"): (typeof documents)["\n fragment ProjectsModelPageEmbed_Project on Project {\n id\n ...ProjectsPageTeamDialogManagePermissions_Project\n }\n"];
export function graphql(source: "\n fragment ProjectsModelPageEmbed_Project on Project {\n id\n ...ProjectsPageTeamDialogManagePermissions_Project\n workspace {\n id\n slug\n embedOptions {\n hideSpeckleBranding\n }\n permissions {\n canEditEmbedOptions {\n ...FullPermissionCheckResult\n }\n }\n }\n }\n"): (typeof documents)["\n fragment ProjectsModelPageEmbed_Project on Project {\n id\n ...ProjectsPageTeamDialogManagePermissions_Project\n workspace {\n id\n slug\n embedOptions {\n hideSpeckleBranding\n }\n permissions {\n canEditEmbedOptions {\n ...FullPermissionCheckResult\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1331,7 +1331,7 @@ export function graphql(source: "\n fragment SettingsWorkspacesSecuritySsoWrapp
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment ModelPageProject on Project {\n id\n createdAt\n name\n visibility\n workspace {\n id\n slug\n name\n role\n }\n embedOptions {\n hideSpeckleBranding\n }\n }\n"): (typeof documents)["\n fragment ModelPageProject on Project {\n id\n createdAt\n name\n visibility\n workspace {\n id\n slug\n name\n role\n }\n embedOptions {\n hideSpeckleBranding\n }\n }\n"];
export function graphql(source: "\n fragment ModelPageProject on Project {\n id\n createdAt\n name\n visibility\n workspace {\n id\n slug\n name\n role\n }\n embedOptions {\n hideSpeckleBranding\n }\n hasAccessToFeature(featureName: hideSpeckleBranding)\n }\n"): (typeof documents)["\n fragment ModelPageProject on Project {\n id\n createdAt\n name\n visibility\n workspace {\n id\n slug\n name\n role\n }\n embedOptions {\n hideSpeckleBranding\n }\n hasAccessToFeature(featureName: hideSpeckleBranding)\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
File diff suppressed because one or more lines are too long
@@ -12,6 +12,7 @@ export type EmbedOptions = {
disableModelLink?: boolean
noScroll?: boolean
manualLoad?: boolean
hideSpeckleBranding?: boolean
}
export function isEmbedOptions(obj: unknown): obj is EmbedOptions {
@@ -26,7 +27,8 @@ export function isEmbedOptions(obj: unknown): obj is EmbedOptions {
'hideSelectionInfo',
'disableModelLink',
'noScroll',
'manualLoad'
'manualLoad',
'hideSpeckleBranding'
].includes(key) &&
typeof possibleOptions[key as keyof EmbedOptions] === 'boolean'
)
@@ -101,6 +103,7 @@ export function useEmbed() {
const isEnabled = createComputed('isEnabled')
const isTransparent = createComputed('isTransparent')
const disableModelLink = createComputed('disableModelLink')
const hideSpeckleBranding = createComputed('hideSpeckleBranding')
const hideSelectionInfo = createComputed('hideSelectionInfo')
const noScroll = createComputed('noScroll')
const manualLoad = createComputed('manualLoad')
@@ -125,6 +128,7 @@ export function useEmbed() {
showControls: showControlsNew,
hideSelectionInfo,
disableModelLink,
hideSpeckleBranding,
noScroll,
manualLoad
}
+1 -1
View File
@@ -5,13 +5,13 @@
"private": true,
"scripts": {
"build": "NODE_OPTIONS=--max-old-space-size=8192 nuxt build",
"build:postinstall": "nuxt prepare",
"build:sourcemaps": "BUILD_SOURCEMAPS=true yarn build",
"dev:nuxt": "nuxt dev",
"dev:app": "concurrently \"nuxt dev\" \"yarn gqlgen:watch\"",
"dev": "yarn dev:app",
"preview": "nuxt preview",
"analyze": "nuxt analyze",
"postinstall": "yarn ensure:tailwind-deps && nuxt prepare",
"lint:js": "eslint .",
"lint:tsc": "vue-tsc --noEmit",
"lint:prettier": "prettier --config ../../.prettierrc --ignore-path ../../.prettierignore --check .",
@@ -6,7 +6,7 @@ export default class BatchedPool<T> {
#baseInterval: number
#processingLoop: Promise<void>
#finished = false
#disposed = false
constructor(params: {
concurrencyAndSizes: number[]
@@ -28,7 +28,7 @@ export default class BatchedPool<T> {
}
async #runWorker(batchSize: number): Promise<void> {
while (!this.#finished || this.#queue.length > 0) {
while (!this.#disposed || this.#queue.length > 0) {
if (this.#queue.length > 0) {
const batch = this.getBatch(batchSize)
await this.#processFunction(batch)
@@ -38,7 +38,7 @@ export default class BatchedPool<T> {
}
async disposeAsync(): Promise<void> {
this.#finished = true
this.#disposed = true
await this.#processingLoop
}
@@ -10,7 +10,7 @@ export default class BatchingQueue<T> {
#maxInterval: number
#processingLoop: Promise<void>
#finished = false
#disposed = false
constructor(params: {
batchSize: number
@@ -26,7 +26,7 @@ export default class BatchingQueue<T> {
}
async disposeAsync(): Promise<void> {
this.#finished = true
this.#disposed = true
await this.#processingLoop
}
@@ -42,13 +42,17 @@ export default class BatchingQueue<T> {
return this.#queue.size
}
isDisposed(): boolean {
return this.#disposed
}
#getBatch(batchSize: number): T[] {
return this.#queue.spliceValues(0, Math.min(batchSize, this.#queue.size))
}
async #loop(): Promise<void> {
let interval = this.#baseInterval
while (!this.#finished || this.#queue.size > 0) {
while (!this.#disposed || this.#queue.size > 0) {
const startTime = performance.now()
if (this.#queue.size > 0) {
const batch = this.#getBatch(this.#batchSize)
@@ -0,0 +1,49 @@
import { describe, expect, test } from 'vitest'
import { CachePump } from './cachePump.js'
import { Database } from '../operations/interfaces.js'
import AsyncGeneratorQueue from './asyncGeneratorQueue.js'
import { Item } from '../types/types.js'
import { DefermentManager } from './defermentManager.js'
const makeDatabase = (): Database =>
({
cacheSaveBatch: async (): Promise<void> => {},
getAll: async (): Promise<(Item | undefined)[]> => Promise.resolve([])
} as unknown as Database)
const makeGathered = (): AsyncGeneratorQueue<Item> =>
({
add: () => {},
async *consume() {}
} as unknown as AsyncGeneratorQueue<Item>)
const makeDeferments = (): DefermentManager =>
({
undefer: () => {}
} as unknown as DefermentManager)
describe('CachePump disposal', () => {
test('disposeAsync is idempotent and always resolves', async () => {
const pump = new CachePump(makeDatabase(), makeGathered(), makeDeferments(), {
maxCacheWriteSize: 2,
maxCacheBatchWriteWait: 100,
maxCacheBatchReadWait: 1,
maxWriteQueueSize: 2,
maxCacheReadSize: 2
})
await pump.disposeAsync()
await expect(pump.disposeAsync()).resolves.toBeUndefined()
})
test('should not throw on add after dispose if writeQueue was never created', async () => {
const pump = new CachePump(makeDatabase(), makeGathered(), makeDeferments(), {
maxCacheWriteSize: 2,
maxCacheBatchWriteWait: 100,
maxCacheBatchReadWait: 1,
maxWriteQueueSize: 2,
maxCacheReadSize: 2
})
await pump.disposeAsync()
// Should not throw, but will not add anything
expect(() =>
pump.add({ baseId: 'a', base: { id: 'b', speckle_type: 'type' } })
).not.toThrow()
})
})
@@ -5,6 +5,7 @@ import BufferQueue from './bufferQueue.js'
import AsyncGeneratorQueue from './asyncGeneratorQueue.js'
import { DefermentManager } from './defermentManager.js'
import { MemoryDatabase } from '../operations/databases/memoryDatabase.js'
import { Database } from '../operations/interfaces.js'
describe('CachePump testing', () => {
test('write two items to queue use pumpItems that are NOT found', async () => {
@@ -71,4 +72,32 @@ describe('CachePump testing', () => {
expect(notFoundItems.values()).toMatchSnapshot()
await cachePump.disposeAsync()
})
test('can dispose while waiting and not wait', async () => {
const i1: Item = { baseId: 'id1', base: { id: 'id', speckle_type: 'type' } }
const i2: Item = { baseId: 'id2', base: { id: 'id', speckle_type: 'type' } }
const db: Database = {
getAll: async () => Promise.resolve([])
} as unknown as Database
const gathered = new AsyncGeneratorQueue<Item>()
const deferments = new DefermentManager({ maxSizeInMb: 1, ttlms: 1 })
const cachePump = new CachePump(db, gathered, deferments, {
maxCacheReadSize: 1,
maxCacheWriteSize: 1,
maxCacheBatchWriteWait: 1,
maxCacheBatchReadWait: 1,
maxWriteQueueSize: 1
})
const foundItems = new BufferQueue<Item>()
const notFoundItems = new BufferQueue<string>()
await cachePump.disposeAsync()
await cachePump.pumpItems({
ids: [i1.baseId, i2.baseId],
foundItems,
notFoundItems
})
})
})
@@ -57,6 +57,7 @@ export class CachePump implements Pump {
const maxCacheReadSize = this.#options.maxCacheReadSize
for (let i = 0; i < ids.length; ) {
if (this.#writeQueue?.isDisposed()) break
if ((this.#writeQueue?.count() ?? 0) > this.#options.maxWriteQueueSize) {
this.#logger(
'pausing reads (# in write queue: ' + this.#writeQueue?.count() + ')'
@@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest'
import { DefermentManager } from './defermentManager.js'
import { DefermentManagerOptions } from '../operations/options.js'
import { Item } from '../types/types.js'
describe('DefermentManager disposal', () => {
const options: DefermentManagerOptions = { ttlms: 10, maxSizeInMb: 1 }
const makeItem = (id: string): Item => ({
baseId: id,
base: { id, speckle_type: 'test' }
})
it('should throw on get/defer/undefer after dispose', async () => {
const manager = new DefermentManager(options)
manager.dispose()
expect(() => manager.get('a')).toThrow('DefermentManager is disposed')
expect(() => manager.undefer(makeItem('a'))).toThrow('DefermentManager is disposed')
await expect(manager.defer({ id: 'a' })).rejects.toThrow(
'DefermentManager is disposed'
)
})
it('dispose is idempotent', () => {
const manager = new DefermentManager(options)
manager.dispose()
expect(() => manager.dispose()).not.toThrow()
})
})
@@ -7,6 +7,7 @@ export class DefermentManager {
private timer?: ReturnType<typeof setTimeout>
private logger: CustomLogger
private currentSize = 0
private disposed = false
constructor(private options: DefermentManagerOptions) {
this.resetGlobalTimer()
@@ -22,10 +23,12 @@ export class DefermentManager {
}
get(id: string): DeferredBase | undefined {
if (this.disposed) throw new Error('DefermentManager is disposed')
return this.deferments.get(id)
}
async defer(params: { id: string }): Promise<Base> {
if (this.disposed) throw new Error('DefermentManager is disposed')
const now = this.now()
const deferredBase = this.deferments.get(params.id)
if (deferredBase) {
@@ -42,6 +45,7 @@ export class DefermentManager {
}
undefer(item: Item): void {
if (this.disposed) throw new Error('DefermentManager is disposed')
const now = this.now()
this.currentSize += item.size || 0
//order matters here with found before undefer
@@ -65,6 +69,8 @@ export class DefermentManager {
}
dispose(): void {
if (this.disposed) return
this.disposed = true
if (this.timer) {
clearTimeout(this.timer)
this.timer = undefined
@@ -48,48 +48,14 @@ describe('downloader', () => {
fetch: fetchMocker
})
downloader.initializePool({ results: pump, total: 2, maxDownloadBatchWait: 200 })
downloader.add('id')
downloader.add('id1')
downloader.add('id2')
await downloader.disposeAsync()
const r = []
for await (const x of pump.gather([i1.baseId, i2.baseId])) {
r.push(x)
}
expect(r).toMatchSnapshot()
})
test('download batch of three', async () => {
const fetchMocker = createFetchMock(vi)
const i1: Item = { baseId: 'id1', base: { id: 'id1', speckle_type: 'type' } }
const i2: Item = { baseId: 'id2', base: { id: 'id2', speckle_type: 'type' } }
const i3: Item = { baseId: 'id3', base: { id: 'id3', speckle_type: 'type' } }
fetchMocker.mockResponseOnce(
'id1\t' +
JSON.stringify(i1.base) +
'\nid2\t' +
JSON.stringify(i2.base) +
'\nid3\t' +
JSON.stringify(i3.base) +
'\n'
)
const pump = new MemoryPump()
const downloader = new ServerDownloader({
serverUrl: 'http://speckle.test',
streamId: 'streamId',
objectId: 'objectId',
token: 'token',
fetch: fetchMocker
})
downloader.initializePool({ results: pump, total: 2, maxDownloadBatchWait: 200 })
downloader.add('id')
await downloader.disposeAsync()
const r = []
for await (const x of pump.gather([i1.baseId, i2.baseId, i3.baseId])) {
r.push(x)
}
expect(r).toMatchSnapshot()
await downloader.disposeAsync()
})
@@ -118,8 +84,10 @@ describe('downloader', () => {
fetch: fetchMocker
})
downloader.initializePool({ results: pump, total: 2, maxDownloadBatchWait: 200 })
downloader.add('id')
downloader.initializePool({ results: pump, total: 3, maxDownloadBatchWait: 200 })
downloader.add('id1')
downloader.add('id2')
downloader.add('id3')
await downloader.disposeAsync()
const r = []
for await (const x of pump.gather([i1.baseId, i2.baseId, i3.baseId])) {
+1 -1
View File
@@ -13,7 +13,7 @@ const ignore = [
/** @type {import("mocha").MochaOptions} */
const config = {
spec: ['modules/**/*.spec.js', 'modules/**/*.spec.ts', 'observability/**/*.spec.ts'],
spec: ['modules/**/*.spec.ts', 'observability/**/*.spec.ts'],
require: ['test/hooks.ts'],
...(ignore.length ? { ignore } : {}),
slow: 0,
@@ -172,7 +172,7 @@ export = FF_GATEKEEPER_MODULE_ENABLED
return await canWorkspaceAccessFeatureFactory({
getWorkspacePlan: getWorkspacePlanFactory({ db })
})({
workspaceId: parent.id,
workspaceId: parent.workspaceId,
workspaceFeature: args.featureName
})
}