From d2734c3433f59da7a8f6ba8407ccebedf751a383 Mon Sep 17 00:00:00 2001 From: Artem Savchenko Date: Mon, 31 Aug 2026 14:58:21 +0700 Subject: [PATCH 1/5] Fix controlled document ID allocation Signed-off-by: Artem Savchenko --- .../core/src/__tests__/identifier.test.ts | 163 ++++++++++++ foundations/core/packages/core/src/classes.ts | 9 + .../core/packages/core/src/component.ts | 2 + .../core/packages/core/src/identifier.ts | 112 +++++++++ foundations/core/packages/core/src/index.ts | 1 + models/controlled-documents/src/migration.ts | 58 +++++ models/core/src/core.ts | 10 + models/core/src/index.ts | 2 + .../create-doc/steps/InfoStep.svelte | 57 +---- plugins/controlled-documents/src/docutils.ts | 53 +++- plugins/controlled-documents/src/utils.ts | 13 +- plugins/export-resources/src/export.ts | 3 +- .../src/__tests__/data-mapper.test.ts | 233 +++--------------- .../pod-export/src/workspace/data-mapper.ts | 73 +++--- 14 files changed, 497 insertions(+), 292 deletions(-) create mode 100644 foundations/core/packages/core/src/__tests__/identifier.test.ts create mode 100644 foundations/core/packages/core/src/identifier.ts diff --git a/foundations/core/packages/core/src/__tests__/identifier.test.ts b/foundations/core/packages/core/src/__tests__/identifier.test.ts new file mode 100644 index 0000000000..48ae34fc4c --- /dev/null +++ b/foundations/core/packages/core/src/__tests__/identifier.test.ts @@ -0,0 +1,163 @@ +// +// Copyright © 2026 TraceX SAS. +// +// Licensed under the PolyForm Shield License 1.0.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://polyformproject.org/licenses/shield/1.0.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { allocateIdentifier, parseIdentifier } from '../identifier' +import type { CustomSequence, Identifier, Ref, TxOperations } from '..' + +interface PendingOperation { + creates: Array<{ _class: string, id: string, data: CustomSequence | Identifier }> + update?: { id: string, sequence: number } + notMatches: Array<{ _class: string, query: Record }> +} + +class IdentifierTestClient { + private readonly sequences = new Map() + private readonly identifiers = new Map() + + async findOne (_class: string, query: Record): Promise { + if (typeof query._id === 'string') { + return Array.from(this.sequences.values()).find((sequence) => sequence._id === query._id) + } + + return Array.from(this.sequences.values()).find( + (sequence) => + sequence.namespace === query.namespace && sequence.scope === query.scope && sequence.prefix === query.prefix + ) + } + + apply (): { + notMatch: (_class: string, query: Record) => void + createDoc: (_class: string, space: string, data: CustomSequence, id: Ref) => Promise + updateDoc: (_class: string, space: string, id: string, data: { sequence: number }) => Promise + commit: () => Promise<{ result: boolean }> + } { + const pending: PendingOperation = { creates: [], notMatches: [] } + return { + notMatch: (_class, query) => { + pending.notMatches.push({ _class, query }) + }, + createDoc: async (_class, _space, data, id) => { + pending.creates.push({ _class, id, data }) + }, + updateDoc: async (_class, _space, id, data) => { + pending.update = { id, sequence: data.sequence } + }, + commit: async () => { + if (pending.notMatches.some(({ _class, query }) => this.matchesAny(_class, query))) return { result: false } + for (const create of pending.creates) { + if ('sequence' in create.data) { + this.sequences.set(create.id, { ...create.data, _id: create.id } as CustomSequence) + } else { + this.identifiers.set(create.id, { ...create.data, _id: create.id } as Identifier) + } + } + if (pending.update !== undefined) { + const sequence = this.sequences.get(pending.update.id) + if (sequence === undefined || sequence.sequence >= pending.update.sequence) return { result: false } + sequence.sequence = pending.update.sequence + return { result: true } + } + return { result: pending.creates.length > 0 || pending.update !== undefined } + } + } + } + + private matchesAny (_class: string, query: Record): boolean { + const values: Iterable = + 'code' in query ? this.identifiers.values() : this.sequences.values() + return Array.from(values).some((sequence) => { + const record = sequence as unknown as Record + if (typeof query._id === 'string' && record._id !== query._id) return false + if (typeof query.namespace === 'string' && record.namespace !== query.namespace) return false + if (typeof query.scope === 'string' && record.scope !== query.scope) return false + if (typeof query.prefix === 'string' && record.prefix !== query.prefix) return false + const sequenceQuery = query.sequence as { $gte?: number } | undefined + return ( + sequenceQuery?.$gte === undefined || + (typeof record.sequence === 'number' && record.sequence >= sequenceQuery.$gte) + ) + }) + } +} + +function client (): TxOperations { + return new IdentifierTestClient() as unknown as TxOperations +} + +describe('allocateIdentifier', () => { + it('allocates identifiers sequentially within a namespace and prefix', async () => { + const operations = client() + + await expect(allocateIdentifier(operations, { namespace: 'documents', prefix: 'TESTTMP' })).resolves.toEqual({ + code: 'TESTTMP-1', + sequence: 1 + }) + await expect(allocateIdentifier(operations, { namespace: 'documents', prefix: 'TESTTMP' })).resolves.toEqual({ + code: 'TESTTMP-2', + sequence: 2 + }) + }) + + it('keeps sequences independent by namespace and scope', async () => { + const operations = client() + + await allocateIdentifier(operations, { namespace: 'documents', scope: 'template-a', prefix: 'TESTTMP' }) + await allocateIdentifier(operations, { namespace: 'documents', scope: 'template-b', prefix: 'TESTTMP' }) + const result = await allocateIdentifier(operations, { + namespace: 'training', + scope: 'template-a', + prefix: 'TESTTMP' + }) + + expect(result).toEqual({ code: 'TESTTMP-1', sequence: 1 }) + }) + + it('reserves at least the requested number', async () => { + await expect( + allocateIdentifier(client(), { namespace: 'documents', prefix: 'TESTTMP', minimum: 12 }) + ).resolves.toEqual({ + code: 'TESTTMP-12', + sequence: 12 + }) + }) + + it('preserves a free requested number below the current sequence', async () => { + const operations = client() + await allocateIdentifier(operations, { namespace: 'documents', prefix: 'TESTTMP', minimum: 10 }) + + await expect( + allocateIdentifier(operations, { namespace: 'documents', prefix: 'TESTTMP', requested: 1 }) + ).resolves.toEqual({ code: 'TESTTMP-1', sequence: 1 }) + }) + + it('parses prefixes containing hyphens', () => { + expect(parseIdentifier('TEST-TMP-1')).toEqual({ prefix: 'TEST-TMP', sequence: 1 }) + }) + + it('allocates distinct codes for concurrent requests', async () => { + const operations = client() + + const allocations = await Promise.all( + Array.from( + { length: 8 }, + async () => await allocateIdentifier(operations, { namespace: 'documents', prefix: 'TESTTMP' }) + ) + ) + + expect(new Set(allocations.map(({ code }) => code)).size).toBe(8) + expect(allocations.map(({ sequence }) => sequence).sort((left, right) => left - right)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]) + }) + +}) diff --git a/foundations/core/packages/core/src/classes.ts b/foundations/core/packages/core/src/classes.ts index f0feceb209..3223fcdc1d 100644 --- a/foundations/core/packages/core/src/classes.ts +++ b/foundations/core/packages/core/src/classes.ts @@ -705,6 +705,15 @@ export interface Sequence extends Doc { export interface CustomSequence extends Sequence { prefix: string + namespace?: string + scope?: string +} + +/** A code reserved by the generic identifier allocator. */ +export interface Identifier extends Doc { + namespace: string + scope: string + code: string } /** diff --git a/foundations/core/packages/core/src/component.ts b/foundations/core/packages/core/src/component.ts index 1b0ec6ef0e..bae268b69a 100644 --- a/foundations/core/packages/core/src/component.ts +++ b/foundations/core/packages/core/src/component.ts @@ -32,6 +32,7 @@ import type { Configuration, ConfigurationElement, CustomSequence, + Identifier, Doc, DomainIndexConfiguration, Enum, @@ -181,6 +182,7 @@ export default plugin(coreId, { RelationMetadata: '' as Ref>, Sequence: '' as Ref>, CustomSequence: '' as Ref>, + Identifier: '' as Ref>, ClassCollaborators: '' as Ref>>, Collaborator: '' as Ref>, ModulePermissionGroup: '' as Ref> diff --git a/foundations/core/packages/core/src/identifier.ts b/foundations/core/packages/core/src/identifier.ts new file mode 100644 index 0000000000..8ea4570f38 --- /dev/null +++ b/foundations/core/packages/core/src/identifier.ts @@ -0,0 +1,112 @@ +// +// Copyright © 2026 TraceX SAS. +// +// Licensed under the PolyForm Shield License 1.0.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://polyformproject.org/licenses/shield/1.0.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import core from './component' +import { type CustomSequence, type Identifier } from './classes' +import { type TxOperations } from './operations' +import { generateId } from './utils' + +export interface IdentifierAllocationRequest { + namespace: string + scope?: string + prefix: string + minimum?: number + /** Prefer this exact number before allocating the next available number. */ + requested?: number +} + +export interface IdentifierAllocation { + code: string + sequence: number +} + +export interface ParsedIdentifier { + prefix: string + sequence: number +} + +/** Parses identifiers with a non-empty prefix and a positive numeric suffix. */ +export function parseIdentifier (code: string): ParsedIdentifier | null { + const match = code.match(/^(?.+)-(?\d+)$/) + const prefix = match?.groups?.prefix + const sequence = match?.groups?.sequence + if (prefix === undefined || prefix.trim() === '' || sequence === undefined) return null + + const parsedSequence = Number(sequence) + if (!Number.isSafeInteger(parsedSequence) || parsedSequence < 1) return null + return { prefix, sequence: parsedSequence } +} + +export async function allocateIdentifier ( + client: TxOperations, + request: IdentifierAllocationRequest +): Promise { + if (request.namespace === '' || request.prefix === '') { + throw new Error('Identifier namespace and prefix are required') + } + + const scope = request.scope ?? '' + const minimum = request.minimum ?? 1 + const requested = request.requested + if ( + !Number.isSafeInteger(minimum) || + minimum < 1 || + (requested !== undefined && (!Number.isSafeInteger(requested) || requested < 1)) + ) { + throw new Error('Identifier minimum must be a positive integer') + } + + const query = { namespace: request.namespace, scope, prefix: request.prefix } + let preferred = requested + for (;;) { + let sequence = await client.findOne(core.class.CustomSequence, query) + if (sequence === undefined) { + const sequenceId = generateId() + const operations = client.apply('create-identifier-sequence') + operations.notMatch(core.class.CustomSequence, query) + await operations.createDoc( + core.class.CustomSequence, + core.space.Workspace, + { + attachedTo: core.class.CustomSequence, + namespace: request.namespace, + scope, + prefix: request.prefix, + sequence: 0 + }, + sequenceId + ) + if (!(await operations.commit()).result) continue + sequence = await client.findOne(core.class.CustomSequence, { _id: sequenceId }) + if (sequence === undefined) continue + } + + const next = preferred ?? Math.max(sequence.sequence + 1, minimum) + const code = `${request.prefix}-${next}` + const operations = client.apply('allocate-identifier') + operations.notMatch(core.class.Identifier, { namespace: request.namespace, scope, code }) + if (next > sequence.sequence) { + operations.notMatch(core.class.CustomSequence, { _id: sequence._id, sequence: { $gte: next } }) + await operations.updateDoc(sequence._class, sequence.space, sequence._id, { sequence: next }) + } + await operations.createDoc(core.class.Identifier, core.space.Workspace, { + namespace: request.namespace, + scope, + code + }) + if ((await operations.commit()).result) return { code, sequence: next } + preferred = undefined + } +} diff --git a/foundations/core/packages/core/src/index.ts b/foundations/core/packages/core/src/index.ts index ddafa5c49d..1538507cd9 100644 --- a/foundations/core/packages/core/src/index.ts +++ b/foundations/core/packages/core/src/index.ts @@ -27,6 +27,7 @@ export { configUserAccountUuid } from './component' export * from './hierarchy' +export * from './identifier' export * from '@hcengineering/measurements' export * from './memdb' export * from './objvalue' diff --git a/models/controlled-documents/src/migration.ts b/models/controlled-documents/src/migration.ts index d9be18a157..77c32d9fd7 100644 --- a/models/controlled-documents/src/migration.ts +++ b/models/controlled-documents/src/migration.ts @@ -1,5 +1,6 @@ // // Copyright @ 2022-2023 Hardcore Engineering Inc. +// Copyright © 2026 TraceX SAS. // import attachment, { type Attachment } from '@hcengineering/attachment' @@ -22,12 +23,15 @@ import { type DocumentReviewRequest, documentsId, DocumentState, + matchDocumentId, type ProjectMeta } from '@hcengineering/controlled-documents' import { type Class, + type CustomSequence, type Data, type Doc, + type Identifier, DOMAIN_SEQUENCE, DOMAIN_TX, generateId, @@ -184,6 +188,53 @@ async function createTemplateSequence (tx: TxOperations): Promise { } } +async function createDocumentIdentifierSequences (tx: TxOperations): Promise { + const documentsWithCodes = await tx.findAll(documents.class.Document, {}, { projection: { code: 1 } }) + const sequenceByPrefix = new Map() + const codes = new Set() + + for (const document of documentsWithCodes) { + const parsedCode = matchDocumentId(document.code) + if (parsedCode === null) { + continue + } + + const current = sequenceByPrefix.get(parsedCode.prefix) ?? 0 + sequenceByPrefix.set(parsedCode.prefix, Math.max(current, parsedCode.seqNumber)) + codes.add(document.code) + } + + for (const [prefix, sequence] of sequenceByPrefix) { + const existing = await tx.findOne(core.class.CustomSequence, { + namespace: documentsId, + scope: '', + prefix + }) + if (existing !== undefined) { + continue + } + + await tx.createDoc(core.class.CustomSequence, core.space.Workspace, { + attachedTo: core.class.CustomSequence, + namespace: documentsId, + scope: '', + prefix, + sequence + }) + } + + for (const code of codes) { + const existing = await tx.findOne(core.class.Identifier, { namespace: documentsId, scope: '', code }) + if (existing === undefined) { + await tx.createDoc(core.class.Identifier, core.space.Workspace, { + namespace: documentsId, + scope: '', + code + }) + } + } +} + async function createDocumentCategories (tx: TxOperations): Promise { const categories: Pick, 'code' | 'title'>[] = [ { code: 'CA', title: 'CAPA (Corrective and Preventive Action)' }, @@ -585,6 +636,13 @@ export const documentsOperation: MigrateOperation = { await createDocumentCategories(tx) await createProductChangeControlTemplate(tx) } + }, + { + state: 'init-document-identifier-sequences', + func: async (client) => { + const tx = new TxOperations(client, core.account.System) + await createDocumentIdentifierSequences(tx) + } } ]) } diff --git a/models/core/src/core.ts b/models/core/src/core.ts index 2c3af463c0..b5d8190b12 100644 --- a/models/core/src/core.ts +++ b/models/core/src/core.ts @@ -29,6 +29,7 @@ import { type Configuration, type ConfigurationElement, type CustomSequence, + type Identifier, type Doc, type Domain, DOMAIN_BLOB, @@ -438,6 +439,15 @@ export class TSequence extends TDoc implements Sequence { @Model(core.class.CustomSequence, core.class.Sequence) export class TCustomSequence extends TSequence implements CustomSequence { prefix!: string + namespace?: string + scope?: string +} + +@Model(core.class.Identifier, core.class.Doc) +export class TIdentifier extends TDoc implements Identifier { + namespace!: string + scope!: string + code!: string } @Model(core.class.ClassCollaborators, core.class.Doc, DOMAIN_MODEL) diff --git a/models/core/src/index.ts b/models/core/src/index.ts index 0377df43ec..b5492105c4 100644 --- a/models/core/src/index.ts +++ b/models/core/src/index.ts @@ -44,6 +44,7 @@ import { TEnum, TEnumOf, TFullTextSearchContext, + TIdentifier, TIndexConfiguration, TInterface, TMigrationState, @@ -176,6 +177,7 @@ export function createModel (builder: Builder): void { TStatus, TSequence, TCustomSequence, + TIdentifier, TDomainStatusPlaceholder, TStatusCategory, TMigrationState, diff --git a/plugins/controlled-documents-resources/src/components/create-doc/steps/InfoStep.svelte b/plugins/controlled-documents-resources/src/components/create-doc/steps/InfoStep.svelte index 49346e40f9..bcbfc58eff 100644 --- a/plugins/controlled-documents-resources/src/components/create-doc/steps/InfoStep.svelte +++ b/plugins/controlled-documents-resources/src/components/create-doc/steps/InfoStep.svelte @@ -1,5 +1,6 @@