diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index 1c9c3fc7..5958a4f7 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -201,6 +201,14 @@ export type CultMeshCollectionWatcher = ( callback: (change: CultMeshCollectionChange) => void, ) => CultMeshUnsubscribe; +/** + * The value type a catalog lookup yields. An explicit type argument wins; otherwise a CultCache document definition + * names its own value type, and a bare schema descriptor yields `unknown`. + */ +export type CultMeshCatalogValue = [TDocument] extends [never] + ? TSchema extends AnyCultCacheDocumentDefinition ? CultCacheDocumentValue : unknown + : TDocument; + export interface CultMeshDocumentSchemaDescriptor { readonly type?: string; readonly schemaId?: string; @@ -1479,9 +1487,9 @@ export class CultMeshDocumentCatalog { return this; } - public tryDocument( - schema: CultMeshDocumentSchemaDescriptor, - ): CultMeshDocumentHandle | undefined { + public tryDocument( + schema: TSchema, + ): CultMeshDocumentHandle> | undefined { const descriptor = normalizeCultMeshDocumentSchema(schema); // One type owns a schema, so a typed lookup is answered by that type's handle or not at all. if (descriptor.type) { @@ -1492,99 +1500,93 @@ export class CultMeshDocumentCatalog { (key ? this.#bySchemaNameVersion.get(key) : undefined); } - public document( - schema: CultMeshDocumentSchemaDescriptor, - ): CultMeshDocumentHandle { - const document = this.tryDocument(schema); - if (!document) { - throw new Error(`Document catalog has no document for ${cultMeshSchemaLabel(schema)}.`); - } - return document; + public document( + schema: TSchema, + ): CultMeshDocumentHandle> { + return this.#require(schema); } - public latest( - schema: CultMeshDocumentSchemaDescriptor, + public latest( + schema: TSchema, context: CultMeshQueryContext | string = "local", - ): Promise { - return this.document(schema).latest(context); + ): Promise> { + return this.#require(schema).latest(context); } public canReplace(schema: CultMeshDocumentSchemaDescriptor): boolean { return this.tryDocument(schema)?.canReplace ?? false; } - public replace( - schema: CultMeshDocumentSchemaDescriptor, - value: TDocument, + public replace( + schema: TSchema, + value: CultMeshCatalogValue, options?: { context?: CultMeshQueryContext | string }, ): Promise; - public replace( - schema: CultMeshDocumentSchemaDescriptor, + public replace( + schema: TSchema, context: CultMeshQueryContext | string, - value: TDocument, + value: CultMeshCatalogValue, ): Promise; - public replace( + public replace( schema: CultMeshDocumentSchemaDescriptor, - contextOrValue: CultMeshQueryContext | string | TDocument, - valueOrOptions?: TDocument | { context?: CultMeshQueryContext | string }, + contextOrValue: unknown, + valueOrOptions?: unknown, ): Promise { - const hasContext = - typeof contextOrValue === "string" || isCultMeshQueryContext(contextOrValue); - const context = hasContext - ? contextOrValue as CultMeshQueryContext | string - : (valueOrOptions as { context?: CultMeshQueryContext | string } | undefined)?.context ?? "local"; - const value = hasContext ? valueOrOptions as TDocument : contextOrValue as TDocument; - return this.document(schema).replace(context, value); + const [context, value] = cultMeshCatalogWriteArguments(contextOrValue, valueOrOptions); + return this.#require(schema).replace(context, value); } public canSubmitPrediction(schema: CultMeshDocumentSchemaDescriptor): boolean { return this.tryDocument(schema)?.canSubmitPrediction ?? false; } - public submitPrediction( - schema: CultMeshDocumentSchemaDescriptor, - value: TDocument, + public submitPrediction( + schema: TSchema, + value: CultMeshCatalogValue, options?: { context?: CultMeshQueryContext | string }, ): Promise; - public submitPrediction( - schema: CultMeshDocumentSchemaDescriptor, + public submitPrediction( + schema: TSchema, context: CultMeshQueryContext | string, - value: TDocument, + value: CultMeshCatalogValue, ): Promise; - public submitPrediction( + public submitPrediction( schema: CultMeshDocumentSchemaDescriptor, - contextOrValue: CultMeshQueryContext | string | TDocument, - valueOrOptions?: TDocument | { context?: CultMeshQueryContext | string }, + contextOrValue: unknown, + valueOrOptions?: unknown, ): Promise { - const hasContext = - typeof contextOrValue === "string" || isCultMeshQueryContext(contextOrValue); - const context = hasContext - ? contextOrValue as CultMeshQueryContext | string - : (valueOrOptions as { context?: CultMeshQueryContext | string } | undefined)?.context ?? "local"; - const value = hasContext ? valueOrOptions as TDocument : contextOrValue as TDocument; - return this.document(schema).submitPrediction(context, value); + const [context, value] = cultMeshCatalogWriteArguments(contextOrValue, valueOrOptions); + return this.#require(schema).submitPrediction(context, value); } - public authoritativeWriter( - schema: CultMeshDocumentSchemaDescriptor, + public authoritativeWriter( + schema: TSchema, options: { context?: CultMeshQueryContext | string } = {}, - ): CultMeshDocumentWriter { - return this.document(schema).authoritativeWriter(options.context ?? "local"); + ): CultMeshDocumentWriter & object> { + return this.#require(schema).authoritativeWriter(options.context ?? "local"); } - public predictionWriter( - schema: CultMeshDocumentSchemaDescriptor, + public predictionWriter( + schema: TSchema, options: { context?: CultMeshQueryContext | string } = {}, - ): CultMeshDocumentWriter { - return this.document(schema).predictionWriter(options.context ?? "local"); + ): CultMeshDocumentWriter & object> { + return this.#require(schema).predictionWriter(options.context ?? "local"); } - public watch( - schema: CultMeshDocumentSchemaDescriptor, - callback: (value: TDocument) => void, + public watch( + schema: TSchema, + callback: (value: CultMeshCatalogValue) => void, options: { context?: CultMeshQueryContext | string } = {}, ): CultMeshUnsubscribe { - return this.document(schema).watch(options.context ?? "local", callback); + return this.#require(schema).watch(options.context ?? "local", callback); + } + + #require(schema: CultMeshDocumentSchemaDescriptor): CultMeshDocumentHandle { + const document = this.tryDocument(schema); + if (!document) { + throw new Error(`Document catalog has no document for ${cultMeshSchemaLabel(schema)}.`); + } + return document; } } @@ -1696,9 +1698,9 @@ export class CultMeshCollectionCatalog { return this; } - public tryCollection( - schema: CultMeshDocumentSchemaDescriptor, - ): CultMeshCollectionHandle | undefined { + public tryCollection( + schema: TSchema, + ): CultMeshCollectionHandle> | undefined { const descriptor = normalizeCultMeshDocumentSchema(schema); // One type owns a schema, so a typed lookup is answered by that type's handle or not at all. if (descriptor.type) { @@ -1709,30 +1711,34 @@ export class CultMeshCollectionCatalog { (key ? this.#bySchemaNameVersion.get(key) : undefined); } - public collection( - schema: CultMeshDocumentSchemaDescriptor, - ): CultMeshCollectionHandle { - const collection = this.tryCollection(schema); + public collection( + schema: TSchema, + ): CultMeshCollectionHandle> { + return this.#require(schema); + } + + public latest( + schema: TSchema, + context: CultMeshQueryContext | string = "local", + ): Promise>> { + return this.#require(schema).latest(context); + } + + public watchChanges( + schema: TSchema, + callback: (change: CultMeshCollectionChange>) => void, + options: { context?: CultMeshQueryContext | string } = {}, + ): CultMeshUnsubscribe { + return this.#require(schema).watchChanges(options.context ?? "local", callback); + } + + #require(schema: CultMeshDocumentSchemaDescriptor): CultMeshCollectionHandle { + const collection = this.tryCollection(schema); if (!collection) { throw new Error(`Collection catalog has no collection for ${cultMeshSchemaLabel(schema)}.`); } return collection; } - - public latest( - schema: CultMeshDocumentSchemaDescriptor, - context: CultMeshQueryContext | string = "local", - ): Promise> { - return this.collection(schema).latest(context); - } - - public watchChanges( - schema: CultMeshDocumentSchemaDescriptor, - callback: (change: CultMeshCollectionChange) => void, - options: { context?: CultMeshQueryContext | string } = {}, - ): CultMeshUnsubscribe { - return this.collection(schema).watchChanges(options.context ?? "local", callback); - } } export class CultMeshBoundCollectionHandle { @@ -2308,6 +2314,17 @@ function resolveCultMeshVerseContext(verse: CultMeshVerseContext | CultMeshVerse return verse instanceof CultMeshVerse ? verse.context : verse; } +// A catalog write takes (value, options?) or (context, value); both resolve to one context and one value. +function cultMeshCatalogWriteArguments( + contextOrValue: unknown, + valueOrOptions: unknown, +): [CultMeshQueryContext | string, unknown] { + if (typeof contextOrValue === "string" || isCultMeshQueryContext(contextOrValue)) { + return [contextOrValue, valueOrOptions]; + } + return [(valueOrOptions as { context?: CultMeshQueryContext | string } | undefined)?.context ?? "local", contextOrValue]; +} + function isCultMeshQueryContext(value: unknown): value is CultMeshQueryContext { if (typeof value !== "object" || value === null) { return false; diff --git a/packages/cultmesh-ts/test/cultmesh.test.ts b/packages/cultmesh-ts/test/cultmesh.test.ts index b6fee823..943d2a85 100644 --- a/packages/cultmesh-ts/test/cultmesh.test.ts +++ b/packages/cultmesh-ts/test/cultmesh.test.ts @@ -135,7 +135,7 @@ test("CultMesh TS document handles hide local cache plumbing behind typed reacti body: "catalog-updated", }); assert.equal( - (await catalog.latest(noteDocument, "browser-client")).body, + (await catalog.latest(noteDocument, "browser-client")).body, "catalog-updated", ); // A typed lookup is answered by its own type's handle; a handle of another type for the same schema is not it. @@ -150,6 +150,41 @@ test("CultMesh TS document handles hide local cache plumbing behind typed reacti assert.equal((await document.latest()).body, "catalog-updated:updated-through-bound-set"); }); +test("CultMesh TS catalogs infer a lookup's value type from its document definition", async () => { + const documents = CultMesh.documents(CultMesh.document("note:typed", noteDocument, async () => ({ noteId: "note:typed", body: "typed" }))); + const collections = CultMesh.collections(CultMesh.collection("notes:typed", noteDocument, async () => [{ noteId: "note:typed", body: "typed" }])); + const inferred: Note = await documents.latest(noteDocument); + const inferredRows: readonly Note[] = await collections.latest(noteDocument); + assert.equal(inferred.body, "typed"); + assert.equal(inferredRows[0]?.body, "typed"); + // @ts-expect-error a definition names its value type: body is a string + const wrong: number = (await documents.latest(noteDocument)).body; + // @ts-expect-error a collection lookup infers its rows the same way + const wrongRows: readonly number[] = await collections.latest(noteDocument); + // @ts-expect-error a bare schema descriptor yields unknown + void (await documents.latest({ schemaId: "cultmesh.note.v0" })).body; + const explicit: { body: string } = await documents.latest<{ body: string }>({ schemaId: "cultmesh.note.v0" }); + assert.equal(explicit.body, "typed"); + void wrong; + void wrongRows; + + // Writes are checked against the definition's value type. Never called: the handle is read-only. + const typeOnly = () => { + // @ts-expect-error replace checks the value against the definition + void documents.replace(noteDocument, { noteId: "note:typed", body: 1 }); + // @ts-expect-error so does a prediction, with a context + void documents.submitPrediction(noteDocument, "local", { noteId: "note:typed" }); + documents.watch(noteDocument, value => { + // @ts-expect-error a watched value is the definition's value + const n: number = value.body; + void n; + }); + // @ts-expect-error a writer writes the definition's value + void documents.authoritativeWriter(noteDocument).write({ noteId: 1, body: "x" }); + }; + void typeOnly; +}); + test("CultMesh TS document handles submit predictions through configured authority hooks", async () => { let current = { noteId: "note:prediction", @@ -941,7 +976,7 @@ test("CultMesh TS binds publication document catalogs from source resolvers", as ["daemon:first", "daemon:second"], ); assert.equal( - (await catalog.document(noteDocument).latest()).body, + (await catalog.document(noteDocument).latest()).body, "second source", ); assert.equal(catalog.document(noteDocument).routeHint.description, "publication catalog"); @@ -1117,7 +1152,7 @@ test("CultMesh TS collection handles expose typed snapshots and reset watches", const catalog = CultMesh.collections(collection); assert.deepEqual( - (await catalog.latest(noteDocument, "local")).map(note => note.body).sort(), + (await catalog.latest(noteDocument, "local")).map(note => note.body).sort(), ["alpha", "bravo"], ); // A typed lookup is answered by its own type's handle; a handle of another type for the same schema is not it.