Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 58 additions & 1 deletion src/lib/server/catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ import {
reserveCollectionLocator,
recordCatalogCollectionCreated,
recordCatalogCollectionTitleChanged,
recordCatalogCollectionDeleted
recordCatalogCollectionDeleted,
reserveRecordLocator,
releaseRecordLocator,
resolveShardForParent,
resolveShardForRecord
} from './catalog';

const WS = 'default';
Expand Down Expand Up @@ -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();
Expand Down
63 changes: 62 additions & 1 deletion src/lib/server/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 4 additions & 2 deletions src/lib/server/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
7 changes: 4 additions & 3 deletions src/lib/services/collections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
actorForCaller,
isAccessToken,
requireAccessibleParent,
resolveParentWorkspaceContext,
type CallerIdentity
} from './permissions';

Expand Down Expand Up @@ -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');
Expand All @@ -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');
Expand All @@ -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');
Expand Down
33 changes: 26 additions & 7 deletions src/lib/services/holds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand All @@ -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 } });
Expand Down
48 changes: 46 additions & 2 deletions src/lib/services/permissions.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -57,7 +58,7 @@ export function requireAccessibleRecord(
recordId: string,
action?: string
): NonNullable<ReturnType<typeof getRecord>> {
const { doc } = resolveWorkspaceContext();
const { doc } = resolveRecordWorkspaceContext(recordId);
const record = getRecord(doc, recordId);
if (!record) {
logDenial(caller, action, recordId);
Expand All @@ -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<string, string[]> {
const { workspaceId, shardId: defaultShardId } = resolveWorkspaceContext();
const groups = new Map<string, string[]>();
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;
}
Loading
Loading