CultMesh TS: a catalog lookup infers its value type from the definition
The deleted parse option was the only thing that typed a catalog lookup, so every caller had to name the value type. Catalog methods now take the schema argument as a type parameter: a CultCache document definition yields its own value type, an explicit type argument still wins, and a bare schema descriptor yields unknown. CultMeshCatalogValue names that rule. - Document catalog: tryDocument, document, latest, replace, submitPrediction, authoritativeWriter, predictionWriter, watch. Collection catalog: tryCollection, collection, latest, watchChanges. - The two copies of the (value, options) / (context, value) argument split collapse into cultMeshCatalogWriteArguments, and the typed lookups share one private #require per catalog. - Tests: the three reads that named their type infer it again. A type-level test pins inference for reads, rows, watches, writes and writers with @ts-expect-error on wrong assignments. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
6e47710e81
commit
43ed9396d3
2 changed files with 133 additions and 81 deletions
|
|
@ -201,6 +201,14 @@ export type CultMeshCollectionWatcher<TDocument> = (
|
|||
callback: (change: CultMeshCollectionChange<TDocument>) => 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, TSchema> = [TDocument] extends [never]
|
||||
? TSchema extends AnyCultCacheDocumentDefinition ? CultCacheDocumentValue<TSchema> : unknown
|
||||
: TDocument;
|
||||
|
||||
export interface CultMeshDocumentSchemaDescriptor {
|
||||
readonly type?: string;
|
||||
readonly schemaId?: string;
|
||||
|
|
@ -1479,9 +1487,9 @@ export class CultMeshDocumentCatalog {
|
|||
return this;
|
||||
}
|
||||
|
||||
public tryDocument<TDocument>(
|
||||
schema: CultMeshDocumentSchemaDescriptor,
|
||||
): CultMeshDocumentHandle<TDocument> | undefined {
|
||||
public tryDocument<TDocument = never, TSchema extends CultMeshDocumentSchemaDescriptor = CultMeshDocumentSchemaDescriptor>(
|
||||
schema: TSchema,
|
||||
): CultMeshDocumentHandle<CultMeshCatalogValue<TDocument, TSchema>> | 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<TDocument>(
|
||||
schema: CultMeshDocumentSchemaDescriptor,
|
||||
): CultMeshDocumentHandle<TDocument> {
|
||||
const document = this.tryDocument<TDocument>(schema);
|
||||
if (!document) {
|
||||
throw new Error(`Document catalog has no document for ${cultMeshSchemaLabel(schema)}.`);
|
||||
}
|
||||
return document;
|
||||
public document<TDocument = never, TSchema extends CultMeshDocumentSchemaDescriptor = CultMeshDocumentSchemaDescriptor>(
|
||||
schema: TSchema,
|
||||
): CultMeshDocumentHandle<CultMeshCatalogValue<TDocument, TSchema>> {
|
||||
return this.#require(schema);
|
||||
}
|
||||
|
||||
public latest<TDocument>(
|
||||
schema: CultMeshDocumentSchemaDescriptor,
|
||||
public latest<TDocument = never, TSchema extends CultMeshDocumentSchemaDescriptor = CultMeshDocumentSchemaDescriptor>(
|
||||
schema: TSchema,
|
||||
context: CultMeshQueryContext | string = "local",
|
||||
): Promise<TDocument> {
|
||||
return this.document<TDocument>(schema).latest(context);
|
||||
): Promise<CultMeshCatalogValue<TDocument, TSchema>> {
|
||||
return this.#require(schema).latest(context);
|
||||
}
|
||||
|
||||
public canReplace(schema: CultMeshDocumentSchemaDescriptor): boolean {
|
||||
return this.tryDocument(schema)?.canReplace ?? false;
|
||||
}
|
||||
|
||||
public replace<TDocument>(
|
||||
schema: CultMeshDocumentSchemaDescriptor,
|
||||
value: TDocument,
|
||||
public replace<TDocument = never, TSchema extends CultMeshDocumentSchemaDescriptor = CultMeshDocumentSchemaDescriptor>(
|
||||
schema: TSchema,
|
||||
value: CultMeshCatalogValue<TDocument, TSchema>,
|
||||
options?: { context?: CultMeshQueryContext | string },
|
||||
): Promise<void>;
|
||||
public replace<TDocument>(
|
||||
schema: CultMeshDocumentSchemaDescriptor,
|
||||
public replace<TDocument = never, TSchema extends CultMeshDocumentSchemaDescriptor = CultMeshDocumentSchemaDescriptor>(
|
||||
schema: TSchema,
|
||||
context: CultMeshQueryContext | string,
|
||||
value: TDocument,
|
||||
value: CultMeshCatalogValue<TDocument, TSchema>,
|
||||
): Promise<void>;
|
||||
public replace<TDocument>(
|
||||
public replace(
|
||||
schema: CultMeshDocumentSchemaDescriptor,
|
||||
contextOrValue: CultMeshQueryContext | string | TDocument,
|
||||
valueOrOptions?: TDocument | { context?: CultMeshQueryContext | string },
|
||||
contextOrValue: unknown,
|
||||
valueOrOptions?: unknown,
|
||||
): Promise<void> {
|
||||
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<TDocument>(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<TDocument>(
|
||||
schema: CultMeshDocumentSchemaDescriptor,
|
||||
value: TDocument,
|
||||
public submitPrediction<TDocument = never, TSchema extends CultMeshDocumentSchemaDescriptor = CultMeshDocumentSchemaDescriptor>(
|
||||
schema: TSchema,
|
||||
value: CultMeshCatalogValue<TDocument, TSchema>,
|
||||
options?: { context?: CultMeshQueryContext | string },
|
||||
): Promise<void>;
|
||||
public submitPrediction<TDocument>(
|
||||
schema: CultMeshDocumentSchemaDescriptor,
|
||||
public submitPrediction<TDocument = never, TSchema extends CultMeshDocumentSchemaDescriptor = CultMeshDocumentSchemaDescriptor>(
|
||||
schema: TSchema,
|
||||
context: CultMeshQueryContext | string,
|
||||
value: TDocument,
|
||||
value: CultMeshCatalogValue<TDocument, TSchema>,
|
||||
): Promise<void>;
|
||||
public submitPrediction<TDocument>(
|
||||
public submitPrediction(
|
||||
schema: CultMeshDocumentSchemaDescriptor,
|
||||
contextOrValue: CultMeshQueryContext | string | TDocument,
|
||||
valueOrOptions?: TDocument | { context?: CultMeshQueryContext | string },
|
||||
contextOrValue: unknown,
|
||||
valueOrOptions?: unknown,
|
||||
): Promise<void> {
|
||||
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<TDocument>(schema).submitPrediction(context, value);
|
||||
const [context, value] = cultMeshCatalogWriteArguments(contextOrValue, valueOrOptions);
|
||||
return this.#require(schema).submitPrediction(context, value);
|
||||
}
|
||||
|
||||
public authoritativeWriter<TDocument extends object>(
|
||||
schema: CultMeshDocumentSchemaDescriptor,
|
||||
public authoritativeWriter<TDocument extends object = never, TSchema extends CultMeshDocumentSchemaDescriptor = CultMeshDocumentSchemaDescriptor>(
|
||||
schema: TSchema,
|
||||
options: { context?: CultMeshQueryContext | string } = {},
|
||||
): CultMeshDocumentWriter<TDocument> {
|
||||
return this.document<TDocument>(schema).authoritativeWriter(options.context ?? "local");
|
||||
): CultMeshDocumentWriter<CultMeshCatalogValue<TDocument, TSchema> & object> {
|
||||
return this.#require(schema).authoritativeWriter(options.context ?? "local");
|
||||
}
|
||||
|
||||
public predictionWriter<TDocument extends object>(
|
||||
schema: CultMeshDocumentSchemaDescriptor,
|
||||
public predictionWriter<TDocument extends object = never, TSchema extends CultMeshDocumentSchemaDescriptor = CultMeshDocumentSchemaDescriptor>(
|
||||
schema: TSchema,
|
||||
options: { context?: CultMeshQueryContext | string } = {},
|
||||
): CultMeshDocumentWriter<TDocument> {
|
||||
return this.document<TDocument>(schema).predictionWriter(options.context ?? "local");
|
||||
): CultMeshDocumentWriter<CultMeshCatalogValue<TDocument, TSchema> & object> {
|
||||
return this.#require(schema).predictionWriter(options.context ?? "local");
|
||||
}
|
||||
|
||||
public watch<TDocument>(
|
||||
schema: CultMeshDocumentSchemaDescriptor,
|
||||
callback: (value: TDocument) => void,
|
||||
public watch<TDocument = never, TSchema extends CultMeshDocumentSchemaDescriptor = CultMeshDocumentSchemaDescriptor>(
|
||||
schema: TSchema,
|
||||
callback: (value: CultMeshCatalogValue<TDocument, TSchema>) => void,
|
||||
options: { context?: CultMeshQueryContext | string } = {},
|
||||
): CultMeshUnsubscribe {
|
||||
return this.document<TDocument>(schema).watch(options.context ?? "local", callback);
|
||||
return this.#require(schema).watch(options.context ?? "local", callback);
|
||||
}
|
||||
|
||||
#require(schema: CultMeshDocumentSchemaDescriptor): CultMeshDocumentHandle<any> {
|
||||
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<TDocument>(
|
||||
schema: CultMeshDocumentSchemaDescriptor,
|
||||
): CultMeshCollectionHandle<TDocument> | undefined {
|
||||
public tryCollection<TDocument = never, TSchema extends CultMeshDocumentSchemaDescriptor = CultMeshDocumentSchemaDescriptor>(
|
||||
schema: TSchema,
|
||||
): CultMeshCollectionHandle<CultMeshCatalogValue<TDocument, TSchema>> | 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<TDocument>(
|
||||
schema: CultMeshDocumentSchemaDescriptor,
|
||||
): CultMeshCollectionHandle<TDocument> {
|
||||
const collection = this.tryCollection<TDocument>(schema);
|
||||
public collection<TDocument = never, TSchema extends CultMeshDocumentSchemaDescriptor = CultMeshDocumentSchemaDescriptor>(
|
||||
schema: TSchema,
|
||||
): CultMeshCollectionHandle<CultMeshCatalogValue<TDocument, TSchema>> {
|
||||
return this.#require(schema);
|
||||
}
|
||||
|
||||
public latest<TDocument = never, TSchema extends CultMeshDocumentSchemaDescriptor = CultMeshDocumentSchemaDescriptor>(
|
||||
schema: TSchema,
|
||||
context: CultMeshQueryContext | string = "local",
|
||||
): Promise<CultMeshCollectionSnapshot<CultMeshCatalogValue<TDocument, TSchema>>> {
|
||||
return this.#require(schema).latest(context);
|
||||
}
|
||||
|
||||
public watchChanges<TDocument = never, TSchema extends CultMeshDocumentSchemaDescriptor = CultMeshDocumentSchemaDescriptor>(
|
||||
schema: TSchema,
|
||||
callback: (change: CultMeshCollectionChange<CultMeshCatalogValue<TDocument, TSchema>>) => void,
|
||||
options: { context?: CultMeshQueryContext | string } = {},
|
||||
): CultMeshUnsubscribe {
|
||||
return this.#require(schema).watchChanges(options.context ?? "local", callback);
|
||||
}
|
||||
|
||||
#require(schema: CultMeshDocumentSchemaDescriptor): CultMeshCollectionHandle<any> {
|
||||
const collection = this.tryCollection(schema);
|
||||
if (!collection) {
|
||||
throw new Error(`Collection catalog has no collection for ${cultMeshSchemaLabel(schema)}.`);
|
||||
}
|
||||
return collection;
|
||||
}
|
||||
|
||||
public latest<TDocument>(
|
||||
schema: CultMeshDocumentSchemaDescriptor,
|
||||
context: CultMeshQueryContext | string = "local",
|
||||
): Promise<CultMeshCollectionSnapshot<TDocument>> {
|
||||
return this.collection<TDocument>(schema).latest(context);
|
||||
}
|
||||
|
||||
public watchChanges<TDocument>(
|
||||
schema: CultMeshDocumentSchemaDescriptor,
|
||||
callback: (change: CultMeshCollectionChange<TDocument>) => void,
|
||||
options: { context?: CultMeshQueryContext | string } = {},
|
||||
): CultMeshUnsubscribe {
|
||||
return this.collection<TDocument>(schema).watchChanges(options.context ?? "local", callback);
|
||||
}
|
||||
}
|
||||
|
||||
export class CultMeshBoundCollectionHandle<TDocument> {
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ test("CultMesh TS document handles hide local cache plumbing behind typed reacti
|
|||
body: "catalog-updated",
|
||||
});
|
||||
assert.equal(
|
||||
(await catalog.latest<Note>(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<Note>(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<Note>(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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue