diff --git a/src/lib/server/catalog.test.ts b/src/lib/server/catalog.test.ts index 8d10c78..ca3730b 100644 --- a/src/lib/server/catalog.test.ts +++ b/src/lib/server/catalog.test.ts @@ -26,7 +26,11 @@ import { reserveCollectionLocator, recordCatalogCollectionCreated, recordCatalogCollectionTitleChanged, - recordCatalogCollectionDeleted + recordCatalogCollectionDeleted, + reserveRecordLocator, + releaseRecordLocator, + resolveShardForParent, + resolveShardForRecord } from './catalog'; const WS = 'default'; @@ -338,6 +342,59 @@ describe('catalog: two workspaces reusing the same record id stay isolated', () }); }); +describe('catalog: record/row locator and shard resolution (#120)', () => { + it('resolveShardForParent finds a Document or Collection by its own id', () => { + const { defaultSpaceId } = bootstrap(); + reserveDocumentLocator(WS, defaultSpaceId, 'a-document', SHARD); + reserveCollectionLocator(WS, defaultSpaceId, 'a-collection', 'other-shard'); + + expect(resolveShardForParent(WS, 'a-document')).toEqual({ shardId: SHARD, kind: 'document' }); + expect(resolveShardForParent(WS, 'a-collection')).toEqual({ + shardId: 'other-shard', + kind: 'collection' + }); + }); + + it('resolveShardForParent returns undefined for an untracked id', () => { + bootstrap(); + expect(resolveShardForParent(WS, 'never-created')).toBeUndefined(); + }); + + it('reserves and resolves a record/row locator independently of Document/Collection locators', () => { + const { defaultSpaceId } = bootstrap(); + reserveRecordLocator(WS, defaultSpaceId, 'row-1', 'collection-shard-x'); + + expect(resolveShardForRecord(WS, 'row-1')).toEqual({ shardId: 'collection-shard-x' }); + // A record-kind locator entry must never satisfy a parent lookup — a + // row is never itself a valid parentId. + expect(resolveShardForParent(WS, 'row-1')).toBeUndefined(); + }); + + it('releaseRecordLocator removes the entry, and the id becomes reservable again', () => { + const { defaultSpaceId } = bootstrap(); + reserveRecordLocator(WS, defaultSpaceId, 'row-2', SHARD); + expect(resolveShardForRecord(WS, 'row-2')).toEqual({ shardId: SHARD }); + + releaseRecordLocator(WS, 'row-2'); + + expect(resolveShardForRecord(WS, 'row-2')).toBeUndefined(); + expect(() => reserveRecordLocator(WS, defaultSpaceId, 'row-2', SHARD)).not.toThrow(); + }); + + it('releaseRecordLocator on a never-reserved id is a safe no-op', () => { + bootstrap(); + expect(() => releaseRecordLocator(WS, 'never-reserved')).not.toThrow(); + }); + + it('rejects a duplicate record id reservation, consistent with Document/Collection locators', () => { + const { defaultSpaceId } = bootstrap(); + reserveRecordLocator(WS, defaultSpaceId, 'row-3', SHARD); + expect(() => reserveRecordLocator(WS, defaultSpaceId, 'row-3', SHARD)).toThrow( + RecordIdConflictError + ); + }); +}); + describe('catalog: spaceId is workspace-scoped, not just globally unique', () => { it('rejects a catalog_documents row whose workspaceId disagrees with its spaceId’s real workspace', () => { const { defaultSpaceId } = bootstrap(); diff --git a/src/lib/server/catalog.ts b/src/lib/server/catalog.ts index 9f9d09e..8c7e4b1 100644 --- a/src/lib/server/catalog.ts +++ b/src/lib/server/catalog.ts @@ -87,11 +87,13 @@ function bumpRevisionAndAppendOutbox( * silent-overwrite-on-duplicate-id behavior of data/records.ts's * createDocument/createCollection. */ +type LocatorKind = ParentKind | 'record'; + function reserveLocator( workspaceId: string, spaceId: string, recordId: string, - kind: ParentKind, + kind: LocatorKind, shardId: string ): void { try { @@ -125,6 +127,65 @@ export function reserveCollectionLocator( reserveLocator(workspaceId, spaceId, id, 'collection', shardId); } +/** + * Reserves a locator entry for one record/row within a sharded Collection — + * unlike Documents/Collections, individual records aren't catalog-navigable + * entities (§3.1), so this exists purely so write_record/delete_record/ + * hold_records/release_records (which only ever receive a bare recordId, no + * parent hint) can resolve which shard to operate against. Document blocks + * are never locator-tracked — they're always in the default shard as long + * as Documents themselves aren't sharded, so resolveShardForRecord's + * "not found" fallback already routes them correctly. + */ +export function reserveRecordLocator( + workspaceId: string, + spaceId: string, + recordId: string, + shardId: string +): void { + reserveLocator(workspaceId, spaceId, recordId, 'record', shardId); +} + +export function releaseRecordLocator(workspaceId: string, recordId: string): void { + getDb() + .delete(recordLocator) + .where(and(eq(recordLocator.workspaceId, workspaceId), eq(recordLocator.recordId, recordId))) + .run(); +} + +/** + * Resolves the shard a Document or Collection lives in, for callers that + * already have its own id (query_collection's collectionId, create_record's + * parentId). Returns undefined when untracked — content written directly to + * the Y.Doc, bypassing the service layer, or an id that doesn't exist — + * callers fall back to the default context in that case. + */ +export function resolveShardForParent( + workspaceId: string, + parentId: string +): { shardId: string; kind: 'document' | 'collection' } | undefined { + const row = getDb() + .select({ shardId: recordLocator.shardId, kind: recordLocator.kind }) + .from(recordLocator) + .where(and(eq(recordLocator.workspaceId, workspaceId), eq(recordLocator.recordId, parentId))) + .get(); + if (!row || row.kind === 'record') return undefined; + return { shardId: row.shardId, kind: row.kind }; +} + +/** Resolves the shard a single record/row lives in, for callers that only have a bare recordId. */ +export function resolveShardForRecord( + workspaceId: string, + recordId: string +): { shardId: string } | undefined { + const row = getDb() + .select({ shardId: recordLocator.shardId }) + .from(recordLocator) + .where(and(eq(recordLocator.workspaceId, workspaceId), eq(recordLocator.recordId, recordId))) + .get(); + return row ? { shardId: row.shardId } : undefined; +} + export function recordCatalogDocumentCreated(input: { workspaceId: string; spaceId: string; diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index e5bd637..71dd092 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -127,14 +127,16 @@ export const catalogCollections = sqliteTable( // The workspace-wide (workspace_id, record_id) locator required by §3.1: the // mechanism that actually rejects a duplicate id across Documents/Collections -// (today's separate Y.Maps for each don't prevent that at all). +// (today's separate Y.Maps for each don't prevent that at all). Also covers +// individual records/rows within a sharded Collection ('record' kind) — see +// reserveRecordLocator in catalog.ts. export const recordLocator = sqliteTable( 'record_locator', { id: integer('id').primaryKey({ autoIncrement: true }), workspaceId: text('workspace_id').notNull().default('default'), recordId: text('record_id').notNull(), - kind: text('kind').notNull().$type<'document' | 'collection'>(), + kind: text('kind').notNull().$type<'document' | 'collection' | 'record'>(), spaceId: text('space_id').notNull(), shardId: text('shard_id').notNull().default('default'), createdAt: integer('created_at').notNull() diff --git a/src/lib/services/collections.ts b/src/lib/services/collections.ts index 99b9b1c..8b7ad3e 100644 --- a/src/lib/services/collections.ts +++ b/src/lib/services/collections.ts @@ -23,6 +23,7 @@ import { actorForCaller, isAccessToken, requireAccessibleParent, + resolveParentWorkspaceContext, type CallerIdentity } from './permissions'; @@ -91,7 +92,7 @@ export function queryCollection( collection: CollectionMeta | undefined; records: WorkspaceRecord[]; } { - const { doc } = resolveWorkspaceContext(); + const { doc } = resolveParentWorkspaceContext(collectionId); const actor = actorForCaller(caller); requireAccessibleParent(caller, collectionId, 'query_collection'); @@ -103,7 +104,7 @@ export function queryCollection( } export function deleteCollection(caller: CallerIdentity, collectionId: string): void { - const { doc, workspaceId } = resolveWorkspaceContext(); + const { doc, workspaceId } = resolveParentWorkspaceContext(collectionId); const actor = actorForCaller(caller); requireAccessibleParent(caller, collectionId, 'delete_collection'); @@ -117,7 +118,7 @@ export function updateCollectionTitle( collectionId: string, title: string ): void { - const { doc, workspaceId } = resolveWorkspaceContext(); + const { doc, workspaceId } = resolveParentWorkspaceContext(collectionId); const actor = actorForCaller(caller); requireAccessibleParent(caller, collectionId, 'update_collection_title'); diff --git a/src/lib/services/holds.ts b/src/lib/services/holds.ts index 2396316..88e52b0 100644 --- a/src/lib/services/holds.ts +++ b/src/lib/services/holds.ts @@ -5,26 +5,42 @@ import { logAudit } from '$lib/server/audit'; import { tokenAllowsParent } from '$lib/mcp/tokens'; import { actorForCaller, + groupRecordIdsByShard, isAccessToken, requireAccessibleRecord, type CallerIdentity } from './permissions'; +// A hold_records/release_records call can legitimately span more than one +// shard (a cross-document agent batch is a stated acceptance criterion — +// see docs/specifications/collaboration.md) — recordIds are grouped by +// their resolved shard, and requestAgentHold/releaseAgentHold run once per +// shard's own Awareness, merging results. In production every group +// resolves to the same default shard today (#120 hasn't cut over shard +// assignment yet), so this is a no-op split until it does. export function holdRecords( caller: CallerIdentity, recordIds: string[] ): { granted: string[]; denied: string[] } { - const { doc, awareness } = resolveWorkspaceContext(); const actor = actorForCaller(caller); let result: { granted: string[]; denied: string[] }; if (isAccessToken(caller)) { const clientId = clientIdForToken(caller.tokenHash); - result = requestAgentHold(awareness, clientId, actor, recordIds, (id) => { - const record = getRecord(doc, id); - return record ? tokenAllowsParent(caller, record.parentId) : false; - }); + const { workspaceId } = resolveWorkspaceContext(); + const granted: string[] = []; + const denied: string[] = []; + for (const [shardId, ids] of groupRecordIdsByShard(recordIds)) { + const { doc, awareness } = resolveWorkspaceContext({ workspaceId, shardId }); + const groupResult = requestAgentHold(awareness, clientId, actor, ids, (id) => { + const record = getRecord(doc, id); + return record ? tokenAllowsParent(caller, record.parentId) : false; + }); + granted.push(...groupResult.granted); + denied.push(...groupResult.denied); + } + result = { granted, denied }; } else { // Human callers: check record existence and permission const granted: string[] = []; @@ -45,12 +61,15 @@ export function holdRecords( } export function releaseRecords(caller: CallerIdentity, recordIds: string[]): void { - const { awareness } = resolveWorkspaceContext(); const actor = actorForCaller(caller); if (isAccessToken(caller)) { const clientId = clientIdForToken(caller.tokenHash); - releaseAgentHold(awareness, clientId, recordIds); + const { workspaceId } = resolveWorkspaceContext(); + for (const [shardId, ids] of groupRecordIdsByShard(recordIds)) { + const { awareness } = resolveWorkspaceContext({ workspaceId, shardId }); + releaseAgentHold(awareness, clientId, ids); + } } logAudit({ actor, action: 'release_records', diff: { recordIds } }); diff --git a/src/lib/services/permissions.ts b/src/lib/services/permissions.ts index 99efa4b..19a4c7f 100644 --- a/src/lib/services/permissions.ts +++ b/src/lib/services/permissions.ts @@ -1,8 +1,9 @@ import type { ActorId } from '$lib/data/types'; -import { resolveWorkspaceContext } from '$lib/server/workspace-store'; +import { resolveWorkspaceContext, type WorkspaceContext } from '$lib/server/workspace-store'; import { getRecord } from '$lib/data/records'; import { tokenAllowsParent, type AccessToken } from '$lib/mcp/tokens'; import { logAudit } from '$lib/server/audit'; +import { resolveShardForParent, resolveShardForRecord } from '$lib/server/catalog'; export type CallerIdentity = AccessToken | ActorId; @@ -57,7 +58,7 @@ export function requireAccessibleRecord( recordId: string, action?: string ): NonNullable> { - const { doc } = resolveWorkspaceContext(); + const { doc } = resolveRecordWorkspaceContext(recordId); const record = getRecord(doc, recordId); if (!record) { logDenial(caller, action, recordId); @@ -66,3 +67,46 @@ export function requireAccessibleRecord( requireAccessibleParent(caller, record.parentId, action); return record; } + +/** + * Resolves the WorkspaceContext a Document/Collection actually lives in, + * for callers that already have its own id (query_collection's + * collectionId, create_record's parentId) — see catalog.ts's + * resolveShardForParent. Falls back to the default context when untracked + * (content written directly to the Y.Doc, or a Document — Documents aren't + * sharded yet, so they're never locator-tracked). + */ +export function resolveParentWorkspaceContext( + parentId: string +): WorkspaceContext & { parentKind?: 'document' | 'collection' } { + const { workspaceId } = resolveWorkspaceContext(); + const shard = resolveShardForParent(workspaceId, parentId); + const ctx = resolveWorkspaceContext( + shard ? { workspaceId, shardId: shard.shardId } : { workspaceId } + ); + return { ...ctx, parentKind: shard?.kind }; +} + +/** + * Resolves the WorkspaceContext a single record/row lives in, for callers + * that only have a bare recordId (write_record, delete_record, get_record). + * See catalog.ts's resolveShardForRecord. + */ +export function resolveRecordWorkspaceContext(recordId: string): WorkspaceContext { + const { workspaceId } = resolveWorkspaceContext(); + const shard = resolveShardForRecord(workspaceId, recordId); + return resolveWorkspaceContext(shard ? { workspaceId, shardId: shard.shardId } : { workspaceId }); +} + +/** Groups recordIds by their resolved shard, for a hold/release call that may legitimately span more than one. */ +export function groupRecordIdsByShard(recordIds: string[]): Map { + const { workspaceId, shardId: defaultShardId } = resolveWorkspaceContext(); + const groups = new Map(); + for (const id of recordIds) { + const shardId = resolveShardForRecord(workspaceId, id)?.shardId ?? defaultShardId; + const list = groups.get(shardId); + if (list) list.push(id); + else groups.set(shardId, [id]); + } + return groups; +} diff --git a/src/lib/services/records.ts b/src/lib/services/records.ts index 36c907b..3a410d5 100644 --- a/src/lib/services/records.ts +++ b/src/lib/services/records.ts @@ -10,6 +10,7 @@ import { updateRecordProperties } from '$lib/data/records'; import { logAudit } from '$lib/server/audit'; +import { reserveRecordLocator, releaseRecordLocator } from '$lib/server/catalog'; import { markdownToRichText } from '$lib/mcp/markdown-transcode'; import { yTextToRichText } from '$lib/data/richtext'; import { tokenAllowsParent } from '$lib/mcp/tokens'; @@ -19,6 +20,8 @@ import { isAccessToken, requireAccessibleParent, requireAccessibleRecord, + resolveParentWorkspaceContext, + resolveRecordWorkspaceContext, type CallerIdentity } from './permissions'; @@ -61,7 +64,9 @@ export function createRecord( referencedRecordId?: string; } ): WorkspaceRecord { - const { doc } = resolveWorkspaceContext(); + const { doc, workspaceId, shardId, defaultSpaceId, parentKind } = resolveParentWorkspaceContext( + input.parentId + ); const actor = actorForCaller(caller); requireAccessibleParent(caller, input.parentId, 'create_record'); @@ -88,6 +93,13 @@ export function createRecord( actor ); + // Document blocks stay untracked — they're always in the default shard as + // long as Documents themselves aren't sharded, so resolveRecordWorkspaceContext's + // "not found" fallback already routes them correctly without a locator row. + if (parentKind === 'collection') { + reserveRecordLocator(workspaceId, defaultSpaceId, record.id, shardId); + } + logAudit({ actor, action: 'create_record', targetRecordId: record.id }); return record; } @@ -105,7 +117,7 @@ export function writeRecord( throw new Error('write_record requires markdown, properties, or referencedRecordId'); } - const { doc, awareness } = resolveWorkspaceContext(); + const { doc, awareness } = resolveRecordWorkspaceContext(recordId); const actor = actorForCaller(caller); const record = requireAccessibleRecord(caller, recordId, 'write_record'); @@ -188,11 +200,12 @@ export function writeRecord( } export function deleteRecord(caller: CallerIdentity, recordId: string): void { - const { doc } = resolveWorkspaceContext(); + const { doc, workspaceId } = resolveRecordWorkspaceContext(recordId); const actor = actorForCaller(caller); requireAccessibleRecord(caller, recordId, 'delete_record'); crdtDeleteRecord(doc, recordId); + releaseRecordLocator(workspaceId, recordId); logAudit({ actor, action: 'delete_record', targetRecordId: recordId }); } diff --git a/src/lib/services/search.ts b/src/lib/services/search.ts index 57d2c06..c3252a4 100644 --- a/src/lib/services/search.ts +++ b/src/lib/services/search.ts @@ -1,6 +1,7 @@ import { resolveWorkspaceContext } from '$lib/server/workspace-store'; import { listCollections, listDocuments, listRecordsForParent } from '$lib/data/records'; import { logAudit } from '$lib/server/audit'; +import { listCatalogCollections, resolveShardForParent } from '$lib/server/catalog'; import { tokenAllowsParent } from '$lib/mcp/tokens'; import { richTextToMarkdown } from '$lib/mcp/markdown-transcode'; import { actorForCaller, isAccessToken, type CallerIdentity } from './permissions'; @@ -17,7 +18,7 @@ export function searchWorkspace( caller: CallerIdentity, query: string ): Array<{ recordId: string; snippet: string }> { - const { doc } = resolveWorkspaceContext(); + const { doc, workspaceId } = resolveWorkspaceContext(); const actor = actorForCaller(caller); const needle = query.toLowerCase(); const results: Array<{ recordId: string; snippet: string }> = []; @@ -32,9 +33,8 @@ export function searchWorkspace( } } - for (const collection of listCollections(doc)) { - if (isAccessToken(caller) && !tokenAllowsParent(caller, collection.id)) continue; - for (const row of listRecordsForParent(doc, collection.id)) { + function searchCollectionRows(collectionId: string, collectionDoc: typeof doc): void { + for (const row of listRecordsForParent(collectionDoc, collectionId)) { for (const value of Object.values(row.properties ?? {})) { const text = value.type === 'text' || value.type === 'select' ? value.value : ''; if (text.toLowerCase().includes(needle)) { @@ -45,6 +45,31 @@ export function searchWorkspace( } } + // Catalog-listed Collections first — resolving each one's own shard from + // the locator, since a fully-sharded Collection's own meta entry (not + // just its rows) can live in a doc other than the default one, which + // listCollections(doc) below could never see. + const catalogCollectionIds = new Set(); + for (const collectionMeta of listCatalogCollections(workspaceId)) { + catalogCollectionIds.add(collectionMeta.id); + if (isAccessToken(caller) && !tokenAllowsParent(caller, collectionMeta.id)) continue; + const shard = resolveShardForParent(workspaceId, collectionMeta.id); + const collectionDoc = shard + ? resolveWorkspaceContext({ workspaceId, shardId: shard.shardId }).doc + : doc; + searchCollectionRows(collectionMeta.id, collectionDoc); + } + + // Then any Collection written directly to the Y.Doc, bypassing the + // service layer entirely (and therefore uncataloged) — the catalog loop + // above can't see these at all, so they're only findable via the default + // doc directly, matching today's completeness for that case. + for (const collection of listCollections(doc)) { + if (catalogCollectionIds.has(collection.id)) continue; + if (isAccessToken(caller) && !tokenAllowsParent(caller, collection.id)) continue; + searchCollectionRows(collection.id, doc); + } + logAudit({ actor, action: 'search_workspace', diff --git a/src/lib/services/services.test.ts b/src/lib/services/services.test.ts index b4b7886..0b3f6ca 100644 --- a/src/lib/services/services.test.ts +++ b/src/lib/services/services.test.ts @@ -30,12 +30,16 @@ import { createDocument as crdtCreateDocument, createCollection as crdtCreateCollection, getDocument as crdtGetDocument, - getCollection as crdtGetCollection + getCollection as crdtGetCollection, + getRecord as crdtGetRecord } from '$lib/data/records'; import { listCatalogCollections, listCatalogDocuments, - RecordIdConflictError + RecordIdConflictError, + reserveCollectionLocator, + recordCatalogCollectionCreated, + resolveShardForRecord } from '$lib/server/catalog'; import type { ActorId } from '$lib/data/types'; @@ -886,3 +890,142 @@ describe('service layer: catalog stays in sync with Y.Doc document/collection mu expect(crdtGetCollection(doc, directDocument.id)).toBeUndefined(); }); }); + +describe('service layer: resolves a genuinely separate Collection shard (#120)', () => { + const OTHER_SHARD = 'other-shard'; + let nextId = 0; + + // createCollection always assigns shardId 'default' (the real + // shard-assignment cutover is a separate, later step — see #120) — this + // bypasses it to construct a Collection whose catalog row names a + // genuinely different shard, proving every service function resolves it + // correctly rather than assuming the default doc. + function createSyntheticShardedCollection(): { collectionId: string; workspaceId: string } { + const { workspaceId, defaultSpaceId } = resolveWorkspaceContext(); + const collectionId = `synthetic-shard-collection-${nextId++}`; + reserveCollectionLocator(workspaceId, defaultSpaceId, collectionId, OTHER_SHARD); + recordCatalogCollectionCreated({ + workspaceId, + spaceId: defaultSpaceId, + id: collectionId, + title: 'Synthetic Sharded Table', + shardId: OTHER_SHARD + }); + const { doc: otherDoc } = resolveWorkspaceContext({ workspaceId, shardId: OTHER_SHARD }); + crdtCreateCollection(otherDoc, { + id: collectionId, + title: 'Synthetic Sharded Table', + schema: [] + }); + return { collectionId, workspaceId }; + } + + it('queryCollection reads rows from the resolved shard, not the default doc', () => { + const { collectionId, workspaceId } = createSyntheticShardedCollection(); + const { doc: otherDoc } = resolveWorkspaceContext({ workspaceId, shardId: OTHER_SHARD }); + crdtCreateRecord( + otherDoc, + { + parentId: collectionId, + properties: { name: { type: 'text', value: 'Row In Other Shard' } } + }, + human + ); + + const result = queryCollection(human, collectionId); + expect(result.collection?.title).toBe('Synthetic Sharded Table'); + expect(result.records).toHaveLength(1); + }); + + it('createRecord targeting a sharded Collection writes into that shard and reserves a row locator', () => { + const { collectionId, workspaceId } = createSyntheticShardedCollection(); + + const record = createRecord(human, { + parentId: collectionId, + properties: { name: { type: 'text', value: 'New Row' } } + }); + + expect(resolveShardForRecord(workspaceId, record.id)).toEqual({ shardId: OTHER_SHARD }); + const { doc: otherDoc } = resolveWorkspaceContext({ workspaceId, shardId: OTHER_SHARD }); + expect(crdtGetRecord(otherDoc, record.id)?.properties?.name).toEqual({ + type: 'text', + value: 'New Row' + }); + }); + + it('writeRecord updates content in the resolved shard', () => { + const { collectionId, workspaceId } = createSyntheticShardedCollection(); + const record = createRecord(human, { parentId: collectionId, properties: {} }); + + writeRecord(human, record.id, { properties: { status: { type: 'text', value: 'Done' } } }); + + const { doc: otherDoc } = resolveWorkspaceContext({ workspaceId, shardId: OTHER_SHARD }); + expect(crdtGetRecord(otherDoc, record.id)?.properties?.status).toEqual({ + type: 'text', + value: 'Done' + }); + }); + + it('getRecord reads from the resolved shard', () => { + const { collectionId } = createSyntheticShardedCollection(); + const record = createRecord(human, { + parentId: collectionId, + properties: { a: { type: 'text', value: '1' } } + }); + + expect(getRecord(human, record.id)?.properties?.a).toEqual({ type: 'text', value: '1' }); + }); + + it('deleteRecord removes it from the resolved shard and releases its row locator', () => { + const { collectionId, workspaceId } = createSyntheticShardedCollection(); + const record = createRecord(human, { parentId: collectionId, properties: {} }); + + deleteRecord(human, record.id); + + expect(resolveShardForRecord(workspaceId, record.id)).toBeUndefined(); + const { doc: otherDoc } = resolveWorkspaceContext({ workspaceId, shardId: OTHER_SHARD }); + expect(crdtGetRecord(otherDoc, record.id)).toBeUndefined(); + }); + + it('holdRecords/releaseRecords (token caller) operate against the resolved shard Awareness, never the default one', () => { + const { collectionId, workspaceId } = createSyntheticShardedCollection(); + const record = createRecord(human, { parentId: collectionId, properties: {} }); + + const { record: tokenRecord } = createToken({ + clientLabel: 'Shard Test Bot', + allowedDocumentIds: [], + allowedCollectionIds: [collectionId] + }); + + const holdResult = holdRecords(tokenRecord, [record.id]); + expect(holdResult).toEqual({ granted: [record.id], denied: [] }); + + function isHeldSomewhere(workspaceIdArg: string, shardId: string | undefined): boolean { + const { awareness } = resolveWorkspaceContext( + shardId !== undefined + ? { workspaceId: workspaceIdArg, shardId } + : { workspaceId: workspaceIdArg } + ); + return Array.from(awareness.getStates().values()).some((s) => + (s as { heldRecordIds?: string[] } | undefined)?.heldRecordIds?.includes(record.id) + ); + } + + expect(isHeldSomewhere(workspaceId, OTHER_SHARD)).toBe(true); + expect(isHeldSomewhere(workspaceId, undefined)).toBe(false); + + releaseRecords(tokenRecord, [record.id]); + expect(isHeldSomewhere(workspaceId, OTHER_SHARD)).toBe(false); + }); + + it('searchWorkspace finds content living in the resolved shard, not just the default doc', () => { + const { collectionId } = createSyntheticShardedCollection(); + createRecord(human, { + parentId: collectionId, + properties: { name: { type: 'text', value: 'Findable Needle Value' } } + }); + + const results = searchWorkspace(human, 'needle'); + expect(results.some((r) => r.snippet.includes('Needle'))).toBe(true); + }); +});