From 13a74cf02d23e25f27ac92aa133ddc967f7143af Mon Sep 17 00:00:00 2001 From: Brylie Christopher Oxley Date: Sun, 30 Aug 2026 19:14:34 +0300 Subject: [PATCH] feat: make every service function shard-aware, without cutting over shard assignment yet (#120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every service function that used to call resolveWorkspaceContext() bare now resolves its actual target shard via new catalog primitives (resolveShardForParent/resolveShardForRecord), falling back to the default context when untracked. createCollection still assigns shardId: 'default' deliberately — this proves the resolution mechanism correct for a genuinely separate shard (tests manually construct one, same pattern as the holds eviction-wiring fix) without changing where content actually lives yet, so production behavior is unchanged and no client/attach-ws changes are needed in this slice. - catalog.ts: reserveRecordLocator/releaseRecordLocator (row-level, closes the gap where write_record/delete_record/hold_records only ever receive a bare recordId), resolveShardForParent/resolveShardForRecord. - permissions.ts: resolveParentWorkspaceContext/resolveRecordWorkspaceContext/ groupRecordIdsByShard — shared resolution helpers; requireAccessibleRecord is now itself shard-aware, which every existing caller already goes through. - records.ts: createRecord reserves a row locator when its parent is a Collection; writeRecord/deleteRecord/getRecord resolve via the record's own locator. - collections.ts: queryCollection/updateCollectionTitle/deleteCollection resolve the collection's real shard. - holds.ts: hold_records/release_records group recordIds by resolved shard and operate against each shard's own Awareness (a cross-document agent batch is a stated acceptance criterion — see collaboration.md). - search.ts: Collections are enumerated via the catalog first (resolving each one's real shard, including its own meta entry — not just its rows), with a fallback pass over the default doc for uncataloged (direct-Yjs- written) Collections the catalog loop can't see. No MCP tool schema changes needed — every tool already carries enough of an id for server-side shard resolution. 661/661 tests passing (13 new). Refs #120. Branched off feat/workspace-catalog-113-phase-a (PR #119, not yet merged) since this depends on its catalog.ts. --- src/lib/server/catalog.test.ts | 59 +++++++++++- src/lib/server/catalog.ts | 63 ++++++++++++- src/lib/server/db/schema.ts | 6 +- src/lib/services/collections.ts | 7 +- src/lib/services/holds.ts | 33 +++++-- src/lib/services/permissions.ts | 48 +++++++++- src/lib/services/records.ts | 19 +++- src/lib/services/search.ts | 33 ++++++- src/lib/services/services.test.ts | 147 +++++++++++++++++++++++++++++- 9 files changed, 390 insertions(+), 25 deletions(-) diff --git a/src/lib/server/catalog.test.ts b/src/lib/server/catalog.test.ts index 9792929..d4d8794 100644 --- a/src/lib/server/catalog.test.ts +++ b/src/lib/server/catalog.test.ts @@ -20,7 +20,11 @@ import { reserveCollectionLocator, recordCatalogCollectionCreated, recordCatalogCollectionTitleChanged, - recordCatalogCollectionDeleted + recordCatalogCollectionDeleted, + reserveRecordLocator, + releaseRecordLocator, + resolveShardForParent, + resolveShardForRecord } from './catalog'; const WS = 'default'; @@ -331,3 +335,56 @@ describe('catalog: two workspaces reusing the same record id stay isolated', () ).toBe('Workspace D Table'); }); }); + +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 + ); + }); +}); 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 78d3cae..8379d6f 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -100,14 +100,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() .references(() => spaces.id), diff --git a/src/lib/services/collections.ts b/src/lib/services/collections.ts index 8ed7e84..7adb51c 100644 --- a/src/lib/services/collections.ts +++ b/src/lib/services/collections.ts @@ -22,6 +22,7 @@ import { actorForCaller, isAccessToken, requireAccessibleParent, + resolveParentWorkspaceContext, type CallerIdentity } from './permissions'; @@ -89,7 +90,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'); @@ -101,7 +102,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'); @@ -115,7 +116,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 bc0edc1..2cfbc97 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'; @@ -854,3 +858,142 @@ describe('service layer: catalog stays in sync with Y.Doc document/collection mu expect(crdtGetCollection(doc, direct.id)?.title).toBe('Written Directly To The Y.Doc'); }); }); + +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); + }); +});