From f06ab9489a7da1a0bbe3cdc4b6ae4f5f82469412 Mon Sep 17 00:00:00 2001 From: Alexey Orlenko Date: Tue, 28 Apr 2026 17:03:03 +0200 Subject: [PATCH 01/21] feat(framework-components): add scope field to RuntimeMiddlewareContext Adds the 'runtime' | 'connection' | 'transaction' scope discriminator to RuntimeMiddlewareContext so middleware can distinguish top-level runtime executions from those running inside connections or transactions. Used by the upcoming cache middleware to bypass caching in non-runtime scopes (where read-after-write coherence is the caller's expectation, or the user has explicitly stepped outside the shared cache surface). SqlRuntimeImpl populates 'runtime' for top-level execute, derives a new context with 'connection' for connection.execute, and 'transaction' for transaction.execute (and withTransaction). MongoRuntimeImpl populates 'runtime' for now (no transaction surface yet). Closes M3.1 of the cache-middleware project. --- .../src/execution/runtime-middleware.ts | 21 ++ .../test/mock-family.test.ts | 1 + .../run-with-middleware.intercept.test.ts | 2 + .../test/run-with-middleware.test.ts | 1 + .../test/runtime-core-options.test.ts | 1 + .../test/runtime-core-options.types.test-d.ts | 1 + .../test/runtime-core.test.ts | 1 + .../test/runtime-core.types.test-d.ts | 2 + .../test/runtime-middleware.types.test-d.ts | 6 +- .../7-runtime/src/mongo-runtime.ts | 1 + .../test/mongo-runtime.types.test-d.ts | 4 + packages/2-sql/5-runtime/src/sql-runtime.ts | 16 +- .../test/before-compile-chain.test.ts | 1 + packages/2-sql/5-runtime/test/budgets.test.ts | 1 + packages/2-sql/5-runtime/test/lints.test.ts | 1 + .../5-runtime/test/scope-plumbing.test.ts | 299 ++++++++++++++++++ .../test/telemetry-middleware.test.ts | 5 + .../cross-family-middleware.test.ts | 1 + 18 files changed, 361 insertions(+), 4 deletions(-) create mode 100644 packages/2-sql/5-runtime/test/scope-plumbing.test.ts diff --git a/packages/1-framework/1-core/framework-components/src/execution/runtime-middleware.ts b/packages/1-framework/1-core/framework-components/src/execution/runtime-middleware.ts index d8f66a48f9..841dd14e7c 100644 --- a/packages/1-framework/1-core/framework-components/src/execution/runtime-middleware.ts +++ b/packages/1-framework/1-core/framework-components/src/execution/runtime-middleware.ts @@ -32,6 +32,27 @@ export interface RuntimeMiddlewareContext { * — it is not (and should not be) further hashed by callers. */ contentHash(exec: ExecutionPlan): Promise; + /** + * Identifies the queryable scope this execution is running under. + * + * - `'runtime'` — top-level `runtime.execute(plan)`. The default scope + * used by the standard read/write paths. + * - `'connection'` — `connection.execute(plan)` after + * `runtime.connection()` checked out a connection from the pool. + * - `'transaction'` — `transaction.execute(plan)` inside an explicit + * transaction, or a query routed through `withTransaction`. + * + * Middleware that should only act at the top level read this field to + * bypass non-runtime scopes. The cache middleware uses it to skip + * caching inside transactions (where read-after-write coherence is the + * caller's expectation) and dedicated connections (where the user has + * explicitly stepped outside the shared cache surface). Observers that + * don't care about the scope can ignore the field. + * + * Family runtimes populate this at context-construction time per + * scope. Existing middleware that ignore the field are unaffected. + */ + readonly scope: 'runtime' | 'connection' | 'transaction'; } export interface AfterExecuteResult { diff --git a/packages/1-framework/1-core/framework-components/test/mock-family.test.ts b/packages/1-framework/1-core/framework-components/test/mock-family.test.ts index 88f8cddf7e..bde3688b29 100644 --- a/packages/1-framework/1-core/framework-components/test/mock-family.test.ts +++ b/packages/1-framework/1-core/framework-components/test/mock-family.test.ts @@ -82,6 +82,7 @@ const ctx: RuntimeMiddlewareContext = { now: () => Date.now(), log: { info: () => {}, warn: () => {}, error: () => {} }, contentHash: async () => 'mock-hash', + scope: 'runtime', }; const meta: PlanMeta = { diff --git a/packages/1-framework/1-core/framework-components/test/run-with-middleware.intercept.test.ts b/packages/1-framework/1-core/framework-components/test/run-with-middleware.intercept.test.ts index 9459c86bce..da33e81e40 100644 --- a/packages/1-framework/1-core/framework-components/test/run-with-middleware.intercept.test.ts +++ b/packages/1-framework/1-core/framework-components/test/run-with-middleware.intercept.test.ts @@ -28,6 +28,7 @@ function makeCtx(overrides?: Partial): RuntimeMiddlewa now: () => Date.now(), log: { info: () => {}, warn: () => {}, error: () => {} }, contentHash: async () => 'mock-hash', + scope: 'runtime', ...overrides, }; } @@ -233,6 +234,7 @@ describe('runWithMiddleware — intercept', () => { // No `debug` field — this is the optional case. log: { info: () => {}, warn: () => {}, error: () => {} }, contentHash: async () => 'mock-hash', + scope: 'runtime', }; const interceptor: RuntimeMiddleware = { diff --git a/packages/1-framework/1-core/framework-components/test/run-with-middleware.test.ts b/packages/1-framework/1-core/framework-components/test/run-with-middleware.test.ts index f16f7ddedc..da9cad702f 100644 --- a/packages/1-framework/1-core/framework-components/test/run-with-middleware.test.ts +++ b/packages/1-framework/1-core/framework-components/test/run-with-middleware.test.ts @@ -26,6 +26,7 @@ const mockCtx: RuntimeMiddlewareContext = { now: () => Date.now(), log: { info: () => {}, warn: () => {}, error: () => {} }, contentHash: async () => 'mock-hash', + scope: 'runtime', }; async function* yieldRows(rows: ReadonlyArray): AsyncGenerator { diff --git a/packages/1-framework/1-core/framework-components/test/runtime-core-options.test.ts b/packages/1-framework/1-core/framework-components/test/runtime-core-options.test.ts index 094fd661c7..98cb3db382 100644 --- a/packages/1-framework/1-core/framework-components/test/runtime-core-options.test.ts +++ b/packages/1-framework/1-core/framework-components/test/runtime-core-options.test.ts @@ -50,6 +50,7 @@ const ctxValue: RuntimeMiddlewareContext = { now: () => Date.now(), log: { info: () => {}, warn: () => {}, error: () => {} }, contentHash: async () => 'mock-hash', + scope: 'runtime', }; const plan: MockPlan = { draftId: 'd', meta }; diff --git a/packages/1-framework/1-core/framework-components/test/runtime-core-options.types.test-d.ts b/packages/1-framework/1-core/framework-components/test/runtime-core-options.types.test-d.ts index 279b14c22c..7d815bf7d7 100644 --- a/packages/1-framework/1-core/framework-components/test/runtime-core-options.types.test-d.ts +++ b/packages/1-framework/1-core/framework-components/test/runtime-core-options.types.test-d.ts @@ -38,6 +38,7 @@ test('execute accepts an optional second argument carrying { signal }', () => { now: () => 0, log: { info: () => {}, warn: () => {}, error: () => {} }, contentHash: async () => 'mock-hash', + scope: 'runtime', }, }); const plan: FixturePlan = { draftId: 'd', meta }; diff --git a/packages/1-framework/1-core/framework-components/test/runtime-core.test.ts b/packages/1-framework/1-core/framework-components/test/runtime-core.test.ts index ed2f795051..48f5f8ca87 100644 --- a/packages/1-framework/1-core/framework-components/test/runtime-core.test.ts +++ b/packages/1-framework/1-core/framework-components/test/runtime-core.test.ts @@ -93,6 +93,7 @@ const ctx: RuntimeMiddlewareContext = { now: () => Date.now(), log: { info: () => {}, warn: () => {}, error: () => {} }, contentHash: async () => 'mock-hash', + scope: 'runtime', }; describe('RuntimeCore', () => { diff --git a/packages/1-framework/1-core/framework-components/test/runtime-core.types.test-d.ts b/packages/1-framework/1-core/framework-components/test/runtime-core.types.test-d.ts index 849d86ca30..5f942c68c7 100644 --- a/packages/1-framework/1-core/framework-components/test/runtime-core.types.test-d.ts +++ b/packages/1-framework/1-core/framework-components/test/runtime-core.types.test-d.ts @@ -34,6 +34,7 @@ test('a minimal RuntimeCore subclass typechecks', () => { now: () => 0, log: { info: () => {}, warn: () => {}, error: () => {} }, contentHash: async () => 'mock-hash', + scope: 'runtime', }, }); }); @@ -58,6 +59,7 @@ test('execute(plan) enforces the TPlan constraint and returns AsyncIterableResul now: () => 0, log: { info: () => {}, warn: () => {}, error: () => {} }, contentHash: async () => 'mock-hash', + scope: 'runtime', }, }); const result = runtime.execute(plan); diff --git a/packages/1-framework/1-core/framework-components/test/runtime-middleware.types.test-d.ts b/packages/1-framework/1-core/framework-components/test/runtime-middleware.types.test-d.ts index 3cd0d72e1e..4989ba67fd 100644 --- a/packages/1-framework/1-core/framework-components/test/runtime-middleware.types.test-d.ts +++ b/packages/1-framework/1-core/framework-components/test/runtime-middleware.types.test-d.ts @@ -147,7 +147,7 @@ test('RuntimeMiddleware narrowed to a SQL plan sees the SQL fields', () => { void middleware; }); -test('RuntimeMiddlewareContext has contract, mode, log, now, contentHash', () => { +test('RuntimeMiddlewareContext has contract, mode, log, now, contentHash, scope', () => { expectTypeOf().toHaveProperty('contract'); expectTypeOf().toHaveProperty('mode'); expectTypeOf().toHaveProperty('log'); @@ -155,6 +155,10 @@ test('RuntimeMiddlewareContext has contract, mode, log, now, contentHash', () => expectTypeOf().toHaveProperty('contentHash'); expectTypeOf().toBeFunction(); expectTypeOf().returns.resolves.toBeString(); + expectTypeOf().toHaveProperty('scope'); + expectTypeOf().toEqualTypeOf< + 'runtime' | 'connection' | 'transaction' + >(); }); test('RuntimeMiddleware familyId and targetId are optional', () => { diff --git a/packages/2-mongo-family/7-runtime/src/mongo-runtime.ts b/packages/2-mongo-family/7-runtime/src/mongo-runtime.ts index 859311a25d..64794a5830 100644 --- a/packages/2-mongo-family/7-runtime/src/mongo-runtime.ts +++ b/packages/2-mongo-family/7-runtime/src/mongo-runtime.ts @@ -88,6 +88,7 @@ class MongoRuntimeImpl // ctx is only invoked by runWithMiddleware with execs this runtime lowered; // the framework parameter type is the cross-family base. contentHash: (exec) => computeMongoContentHash(exec as MongoExecutionPlan), + scope: 'runtime', }; super({ middleware, ctx }); diff --git a/packages/2-mongo-family/7-runtime/test/mongo-runtime.types.test-d.ts b/packages/2-mongo-family/7-runtime/test/mongo-runtime.types.test-d.ts index 8c470a74bd..eff8e2193e 100644 --- a/packages/2-mongo-family/7-runtime/test/mongo-runtime.types.test-d.ts +++ b/packages/2-mongo-family/7-runtime/test/mongo-runtime.types.test-d.ts @@ -25,4 +25,8 @@ test('MongoMiddlewareContext extends RuntimeMiddlewareContext', () => { expectTypeOf().toHaveProperty('log'); expectTypeOf().toHaveProperty('now'); expectTypeOf().toHaveProperty('contentHash'); + expectTypeOf().toHaveProperty('scope'); + expectTypeOf().toEqualTypeOf< + 'runtime' | 'connection' | 'transaction' + >(); }); diff --git a/packages/2-sql/5-runtime/src/sql-runtime.ts b/packages/2-sql/5-runtime/src/sql-runtime.ts index a6e331b9d4..4792b1575c 100644 --- a/packages/2-sql/5-runtime/src/sql-runtime.ts +++ b/packages/2-sql/5-runtime/src/sql-runtime.ts @@ -173,6 +173,7 @@ class SqlRuntimeImpl = Contract computeSqlContentHash(exec as SqlExecutionPlan), + scope: 'runtime', }; super({ middleware: middleware ?? [], ctx: sqlCtx }); @@ -264,6 +265,7 @@ class SqlRuntimeImpl = Contract | SqlQueryPlan, queryable: SqlQueryable, options?: RuntimeExecuteOptions, + scope: 'runtime' | 'connection' | 'transaction' = 'runtime', ): AsyncIterableResult { this.ensureCodecRegistryValidated(); @@ -277,6 +279,14 @@ class SqlRuntimeImpl = Contract { checkAborted(codecCtx, 'stream'); @@ -309,7 +319,7 @@ class SqlRuntimeImpl = Contract>( exec, self.middleware, - self.ctx, + ctx, () => queryable.execute>({ sql: exec.sql, @@ -380,7 +390,7 @@ class SqlRuntimeImpl = Contract | SqlQueryPlan) & { readonly _row?: Row }, options?: RuntimeExecuteOptions, ): AsyncIterableResult { - return self.executeAgainstQueryable(plan, driverConn, options); + return self.executeAgainstQueryable(plan, driverConn, options, 'connection'); }, }; @@ -400,7 +410,7 @@ class SqlRuntimeImpl = Contract | SqlQueryPlan) & { readonly _row?: Row }, options?: RuntimeExecuteOptions, ): AsyncIterableResult { - return self.executeAgainstQueryable(plan, driverTx, options); + return self.executeAgainstQueryable(plan, driverTx, options, 'transaction'); }, }; } diff --git a/packages/2-sql/5-runtime/test/before-compile-chain.test.ts b/packages/2-sql/5-runtime/test/before-compile-chain.test.ts index e0ac3f07ee..350e48daee 100644 --- a/packages/2-sql/5-runtime/test/before-compile-chain.test.ts +++ b/packages/2-sql/5-runtime/test/before-compile-chain.test.ts @@ -37,6 +37,7 @@ function createContext(): SqlMiddlewareContext & { debug, }, contentHash: async () => 'mock-hash', + scope: 'runtime' as const, }; } diff --git a/packages/2-sql/5-runtime/test/budgets.test.ts b/packages/2-sql/5-runtime/test/budgets.test.ts index 96501ab0d3..f234191e46 100644 --- a/packages/2-sql/5-runtime/test/budgets.test.ts +++ b/packages/2-sql/5-runtime/test/budgets.test.ts @@ -29,6 +29,7 @@ function createMiddlewareContext(overrides?: Partial): Sql error: vi.fn(), }, contentHash: async () => 'mock-hash', + scope: 'runtime' as const, ...overrides, }; } diff --git a/packages/2-sql/5-runtime/test/lints.test.ts b/packages/2-sql/5-runtime/test/lints.test.ts index dce38a74e2..a8876201ae 100644 --- a/packages/2-sql/5-runtime/test/lints.test.ts +++ b/packages/2-sql/5-runtime/test/lints.test.ts @@ -28,6 +28,7 @@ function createMiddlewareContext(): SqlMiddlewareContext { error: vi.fn(), }, contentHash: async () => 'mock-hash', + scope: 'runtime' as const, }; } diff --git a/packages/2-sql/5-runtime/test/scope-plumbing.test.ts b/packages/2-sql/5-runtime/test/scope-plumbing.test.ts new file mode 100644 index 0000000000..885b896a4a --- /dev/null +++ b/packages/2-sql/5-runtime/test/scope-plumbing.test.ts @@ -0,0 +1,299 @@ +import type { Contract } from '@prisma-next/contract/types'; +import { coreHash, profileHash } from '@prisma-next/contract/types'; +import { + type ExecutionStackInstance, + instantiateExecutionStack, + type RuntimeDriverInstance, + type RuntimeExtensionInstance, +} from '@prisma-next/framework-components/execution'; +import type { SqlStorage } from '@prisma-next/sql-contract/types'; +import type { + CodecRegistry, + SqlDriver, + SqlExecuteRequest, +} from '@prisma-next/sql-relational-core/ast'; +import { codec, createCodecRegistry, type SelectAst } from '@prisma-next/sql-relational-core/ast'; +import type { SqlExecutionPlan } from '@prisma-next/sql-relational-core/plan'; +import { describe, expect, it, vi } from 'vitest'; +import type { SqlMiddleware } from '../src/middleware/sql-middleware'; +import type { + SqlRuntimeAdapterDescriptor, + SqlRuntimeAdapterInstance, + SqlRuntimeTargetDescriptor, +} from '../src/sql-context'; +import { createExecutionContext, createSqlExecutionStack } from '../src/sql-context'; +import { createRuntime } from '../src/sql-runtime'; + +/** + * Verifies the SQL runtime populates `RuntimeMiddlewareContext.scope` + * differently for the three queryable surfaces: top-level `runtime.execute`, + * `connection.execute` (after `runtime.connection()`), and + * `transaction.execute` (after `connection.transaction()` or + * `withTransaction`). + * + * The cache middleware (TML-2143 M3) reads `ctx.scope` to bypass caching on + * connection / transaction scopes; this test pins the contract so a + * regression in scope plumbing surfaces here rather than via a confusing + * cache-coherence bug. + */ + +const testContract: Contract = { + targetFamily: 'sql', + target: 'postgres', + profileHash: profileHash('sha256:test'), + models: {}, + roots: {}, + storage: { storageHash: coreHash('sha256:test'), tables: {} }, + extensionPacks: {}, + capabilities: {}, + meta: {}, +}; + +function createStubCodecs(): CodecRegistry { + const registry = createCodecRegistry(); + registry.register( + codec({ + typeId: 'pg/int4@1', + targetTypes: ['int4'], + encode: (v: number) => v, + decode: (w: number) => w, + }), + ); + return registry; +} + +function createStubAdapter() { + const codecs = createStubCodecs(); + return { + familyId: 'sql' as const, + targetId: 'postgres' as const, + profile: { + id: 'test-profile', + target: 'postgres', + capabilities: {}, + codecs() { + return codecs; + }, + readMarkerStatement() { + return { + sql: 'select core_hash, profile_hash, contract_json, canonical_version, updated_at, app_tag, meta from prisma_contract.marker where id = $1', + params: [1], + }; + }, + parseMarkerRow() { + throw new Error('stub adapter does not implement parseMarkerRow'); + }, + }, + lower(ast: SelectAst) { + return Object.freeze({ sql: JSON.stringify(ast), params: [] }); + }, + }; +} + +function createMockDriver(): SqlDriver { + const transaction = { + execute: vi.fn().mockImplementation(async function* (_request: SqlExecuteRequest) { + yield { id: 3 } as Record; + }), + query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }), + commit: vi.fn().mockResolvedValue(undefined), + rollback: vi.fn().mockResolvedValue(undefined), + }; + const connection = { + execute: vi.fn().mockImplementation(async function* (_request: SqlExecuteRequest) { + yield { id: 2 } as Record; + }), + query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }), + release: vi.fn().mockResolvedValue(undefined), + destroy: vi.fn().mockResolvedValue(undefined), + beginTransaction: vi.fn().mockResolvedValue(transaction), + }; + return { + execute: vi.fn().mockImplementation(async function* (_request: SqlExecuteRequest) { + yield { id: 1 } as Record; + }), + query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }), + connect: vi.fn().mockImplementation(async (_binding?: undefined) => undefined), + acquireConnection: vi.fn().mockResolvedValue(connection), + close: vi.fn().mockResolvedValue(undefined), + }; +} + +function createTestTargetDescriptor(): SqlRuntimeTargetDescriptor<'postgres'> { + return { + kind: 'target', + id: 'postgres', + version: '0.0.1', + familyId: 'sql' as const, + targetId: 'postgres' as const, + codecs: () => createCodecRegistry(), + parameterizedCodecs: () => [], + create() { + return { familyId: 'sql' as const, targetId: 'postgres' as const }; + }, + }; +} + +function createTestAdapterDescriptor( + adapter: ReturnType, +): SqlRuntimeAdapterDescriptor<'postgres'> { + const codecRegistry = adapter.profile.codecs(); + return { + kind: 'adapter', + id: 'test-adapter', + version: '0.0.1', + familyId: 'sql' as const, + targetId: 'postgres' as const, + codecs: () => codecRegistry, + parameterizedCodecs: () => [], + create() { + return Object.assign( + { familyId: 'sql' as const, targetId: 'postgres' as const }, + adapter, + ) as SqlRuntimeAdapterInstance<'postgres'>; + }, + }; +} + +function createTestSetup(middleware: readonly SqlMiddleware[]) { + const adapter = createStubAdapter(); + const driver = createMockDriver(); + + const targetDescriptor = createTestTargetDescriptor(); + const adapterDescriptor = createTestAdapterDescriptor(adapter); + + const stack = createSqlExecutionStack({ + target: targetDescriptor, + adapter: adapterDescriptor, + extensionPacks: [], + }); + type SqlTestStackInstance = ExecutionStackInstance< + 'sql', + 'postgres', + SqlRuntimeAdapterInstance<'postgres'>, + RuntimeDriverInstance<'sql', 'postgres'>, + RuntimeExtensionInstance<'sql', 'postgres'> + >; + const stackInstance = instantiateExecutionStack(stack) as SqlTestStackInstance; + + const context = createExecutionContext({ + contract: testContract, + stack: { target: targetDescriptor, adapter: adapterDescriptor, extensionPacks: [] }, + }); + + const runtime = createRuntime({ + stackInstance, + context, + driver, + verify: { mode: 'onFirstUse', requireMarker: false }, + middleware, + }); + + return { runtime }; +} + +function createRawExecutionPlan(): SqlExecutionPlan { + return { + sql: 'select 1', + params: [], + meta: { + target: testContract.target, + targetFamily: testContract.targetFamily, + storageHash: testContract.storage.storageHash, + lane: 'raw', + }, + }; +} + +describe('SQL runtime scope plumbing', () => { + it('populates ctx.scope = "runtime" on top-level runtime.execute', async () => { + const seen: Array<'runtime' | 'connection' | 'transaction'> = []; + const observer: SqlMiddleware = { + name: 'scope-observer', + familyId: 'sql', + async beforeExecute(_plan, ctx) { + seen.push(ctx.scope); + }, + }; + + const { runtime } = createTestSetup([observer]); + await runtime.execute(createRawExecutionPlan()).toArray(); + + expect(seen).toEqual(['runtime']); + }); + + it('populates ctx.scope = "connection" on connection.execute', async () => { + const seen: Array<'runtime' | 'connection' | 'transaction'> = []; + const observer: SqlMiddleware = { + name: 'scope-observer', + familyId: 'sql', + async beforeExecute(_plan, ctx) { + seen.push(ctx.scope); + }, + }; + + const { runtime } = createTestSetup([observer]); + const connection = await runtime.connection(); + try { + await connection.execute(createRawExecutionPlan()).toArray(); + } finally { + await connection.release(); + } + + expect(seen).toEqual(['connection']); + }); + + it('populates ctx.scope = "transaction" on transaction.execute', async () => { + const seen: Array<'runtime' | 'connection' | 'transaction'> = []; + const observer: SqlMiddleware = { + name: 'scope-observer', + familyId: 'sql', + async beforeExecute(_plan, ctx) { + seen.push(ctx.scope); + }, + }; + + const { runtime } = createTestSetup([observer]); + const connection = await runtime.connection(); + const transaction = await connection.transaction(); + try { + await transaction.execute(createRawExecutionPlan()).toArray(); + await transaction.commit(); + } finally { + await connection.release(); + } + + expect(seen).toEqual(['transaction']); + }); + + it('routes a sequence of executes to the right scope each time', async () => { + const seen: Array<'runtime' | 'connection' | 'transaction'> = []; + const observer: SqlMiddleware = { + name: 'scope-observer', + familyId: 'sql', + async beforeExecute(_plan, ctx) { + seen.push(ctx.scope); + }, + }; + + const { runtime } = createTestSetup([observer]); + + // Top-level. + await runtime.execute(createRawExecutionPlan()).toArray(); + + // Connection-scoped. + const connection = await runtime.connection(); + await connection.execute(createRawExecutionPlan()).toArray(); + + // Transaction-scoped. + const transaction = await connection.transaction(); + await transaction.execute(createRawExecutionPlan()).toArray(); + await transaction.commit(); + await connection.release(); + + // And another top-level after returning the connection to the pool. + await runtime.execute(createRawExecutionPlan()).toArray(); + + expect(seen).toEqual(['runtime', 'connection', 'transaction', 'runtime']); + }); +}); diff --git a/packages/3-extensions/middleware-telemetry/test/telemetry-middleware.test.ts b/packages/3-extensions/middleware-telemetry/test/telemetry-middleware.test.ts index 0d8372fea0..9dc940cb02 100644 --- a/packages/3-extensions/middleware-telemetry/test/telemetry-middleware.test.ts +++ b/packages/3-extensions/middleware-telemetry/test/telemetry-middleware.test.ts @@ -25,6 +25,7 @@ describe('telemetry middleware', () => { now: Date.now, log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, contentHash: async () => 'mock-hash', + scope: 'runtime' as const, }; await middleware.beforeExecute!(plan, ctx); @@ -46,6 +47,7 @@ describe('telemetry middleware', () => { now: Date.now, log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, contentHash: async () => 'mock-hash', + scope: 'runtime' as const, }; const result = { rowCount: 5, @@ -77,6 +79,7 @@ describe('telemetry middleware', () => { now: Date.now, log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, contentHash: async () => 'mock-hash', + scope: 'runtime' as const, }; const result = { rowCount: 3, @@ -108,6 +111,7 @@ describe('telemetry middleware', () => { now: Date.now, log: { info, warn: vi.fn(), error: vi.fn() }, contentHash: async () => 'mock-hash', + scope: 'runtime' as const, }; await middleware.beforeExecute!(plan, ctx); @@ -132,6 +136,7 @@ describe('telemetry middleware', () => { now: Date.now, log: { info: vi.fn(), warn, error: vi.fn() }, contentHash: async () => 'mock-hash', + scope: 'runtime' as const, }; await expect(middleware.beforeExecute!(plan, ctx)).resolves.toBeUndefined(); diff --git a/test/integration/test/cross-package/cross-family-middleware.test.ts b/test/integration/test/cross-package/cross-family-middleware.test.ts index f9aa43b8be..5df4ec271b 100644 --- a/test/integration/test/cross-package/cross-family-middleware.test.ts +++ b/test/integration/test/cross-package/cross-family-middleware.test.ts @@ -138,6 +138,7 @@ const sqlCtx: RuntimeMiddlewareContext = { now: () => Date.now(), log: { info: () => {}, warn: () => {}, error: () => {} }, contentHash: async () => 'mock-hash', + scope: 'runtime', }; describe('cross-family middleware proof', () => { From c89823bc246d805cf29f55d7823755e91761be6a Mon Sep 17 00:00:00 2001 From: Alexey Orlenko Date: Tue, 28 Apr 2026 14:50:05 +0200 Subject: [PATCH 02/21] feat(framework-components): add defineAnnotation with operation-kind applicability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the user-facing annotation surface that lane terminals (SQL DSL builders' .build(), ORM Collection terminals) consume to constrain which annotations may attach to which operations. Public API: - OperationKind = 'read' | 'write'. Binary discrimination for April; finer-grained kinds ('select' | 'insert' | 'update' | 'delete' | 'upsert') are deferred until a real annotation needs them. - defineAnnotation({ namespace, applicableTo }) → AnnotationHandle. Creates a typed handle. - AnnotationHandle: { namespace, applicableTo, apply(value), read(plan) }. Handles are the only supported public entry point for reading and writing annotations. - AnnotationValue: the result of handle.apply(value). Carries the namespace, payload, applicableTo set, and a __annotation: true brand. Stored in plan.meta.annotations[namespace] verbatim. - ValidAnnotations: type-level mapped tuple that resolves each element to never when its declared Kinds does not include K. Lane terminals consume this on their variadic ...annotations parameter. - assertAnnotationsApplicable(annotations, kind, terminalName): the runtime applicability gate. Throws RUNTIME.ANNOTATION_INAPPLICABLE with the offending namespace and terminal named. Used by lane terminals so casts / any / dynamic invocations cannot bypass the type-level gate. Stored shape (read isolates user annotations from framework-internal metadata): plan.meta.annotations[namespace] = AnnotationValue (branded) handle.read() checks the __annotation brand before returning, which means framework-internal entries under the same namespace key (e.g. the SQL emitter's meta.annotations.codecs map of alias → codec id) do not surface as user-handle reads. defineAnnotation does not structurally prevent a user from naming a reserved namespace ('codecs', target-specific keys like 'pg'); the TSDoc lists them and states no compatibility guarantee is made for handles that collide. The applicability gate makes 'caching a mutation' — the obvious footgun for an opt-in cache annotation — structurally impossible: cacheAnnotation declares applicableTo: ['read'], so passing it to db.User.create(...) produces a never-tuple at the type level and a RUNTIME.ANNOTATION_INAPPLICABLE error at runtime. The cache middleware needs no separate mutation classifier. Tests land in the next commit (M1.8). Refs: TML-2143 --- .../framework-components/src/annotations.ts | 271 ++++++++++++++++++ .../src/exports/runtime.ts | 8 + 2 files changed, 279 insertions(+) create mode 100644 packages/1-framework/1-core/framework-components/src/annotations.ts diff --git a/packages/1-framework/1-core/framework-components/src/annotations.ts b/packages/1-framework/1-core/framework-components/src/annotations.ts new file mode 100644 index 0000000000..dc67e376d7 --- /dev/null +++ b/packages/1-framework/1-core/framework-components/src/annotations.ts @@ -0,0 +1,271 @@ +import { runtimeError } from './execution/runtime-error'; + +/** + * The kinds of operations an annotation may apply to. + * + * - `'read'` — `SELECT` / `find` / `first` / `all` / `count` / aggregates. + * - `'write'` — `INSERT` / `UPDATE` / `DELETE` / `create` / `update` / `delete` / `upsert`. + * + * Annotations declare which kinds they apply to via `defineAnnotation`'s + * `applicableTo` option; lane terminals enforce the constraint at both the + * type level (via `ValidAnnotations`) and at runtime (via + * `assertAnnotationsApplicable`). + * + * Finer-grained kinds (`'select' | 'insert' | 'update' | 'delete' | 'upsert'`) + * are deliberately deferred. The binary covers the common case (the cache + * middleware applies to reads; an audit annotation would apply to writes; + * tracing/OTel applies to both). When a real annotation surfaces that needs + * a finer split, the union widens and existing handles remain typecheckable. + */ +export type OperationKind = 'read' | 'write'; + +/** + * An applied annotation. Carries the namespace, the typed payload, and the + * `applicableTo` set the underlying handle declared. The `__annotation` + * brand lets `read` distinguish branded user annotations from arbitrary + * data that may happen to live under the same namespace key in + * `plan.meta.annotations` (e.g. framework-internal metadata such as + * `meta.annotations.codecs`). + * + * Constructed via `AnnotationHandle.apply(...)`; never instantiated + * directly. + */ +export interface AnnotationValue { + readonly __annotation: true; + readonly namespace: string; + readonly value: Payload; + readonly applicableTo: ReadonlySet; +} + +/** + * Handle returned by `defineAnnotation`. Carries the static metadata + * (namespace, applicableTo) and the two operations a handle exposes: + * + * - `apply(value)` — wrap a `Payload` into an `AnnotationValue` ready to + * pass to a lane terminal's variadic `annotations` argument. + * - `read(plan)` — extract the `Payload` from a plan's `meta.annotations` + * if a value was previously written under this handle's namespace. + * Returns `undefined` when the annotation is absent or when the stored + * value is not a branded `AnnotationValue` (e.g. framework-internal + * metadata under the same namespace key). + * + * Handles are the only supported public entry point for reading and + * writing annotations. Direct mutation of `plan.meta.annotations` is not + * part of the public API. + */ +export interface AnnotationHandle { + readonly namespace: string; + readonly applicableTo: ReadonlySet; + apply(value: Payload): AnnotationValue; + read(plan: { + readonly meta: { readonly annotations?: Record }; + }): Payload | undefined; +} + +/** + * Options accepted by `defineAnnotation`. + * + * `namespace` is the string key under which the annotation is stored in + * `plan.meta.annotations`. **Reserved namespaces** include framework- + * internal metadata keys; user handles must not use them: + * + * - `codecs` — used by the SQL emitter to record per-alias codec ids + * (`meta.annotations.codecs[alias] = 'pg/text@1'`); the SQL runtime's + * `decodeRow` reads from this key. A user `defineAnnotation('codecs')` + * handle is not structurally prevented, but its behavior with the + * emitter and the runtime is undefined and we make no compatibility + * guarantees about it. + * - Target-specific keys such as `pg` (and equivalents on other + * targets) are similarly reserved for adapter / target use. + * + * `applicableTo` declares which operation kinds the annotation may attach + * to. The lane terminals' type-level `ValidAnnotations` gate rejects + * annotations whose `Kinds` does not include the terminal's `K`; the + * runtime helper `assertAnnotationsApplicable` does the equivalent at + * runtime so casts and `any` cannot bypass the gate. + */ +export interface DefineAnnotationOptions { + readonly namespace: string; + readonly applicableTo: readonly Kinds[]; +} + +/** + * Defines a typed annotation handle. + * + * @example + * ```typescript + * // Read-only annotation. Lane terminals like `db.User.first(...)` accept + * // it; `db.User.create(...)` rejects it at the type level. + * const cacheAnnotation = defineAnnotation<{ ttl?: number; skip?: boolean }, 'read'>({ + * namespace: 'cache', + * applicableTo: ['read'], + * }); + * + * // Write-only annotation. Mirror image. + * const auditAnnotation = defineAnnotation<{ actor: string }, 'write'>({ + * namespace: 'audit', + * applicableTo: ['write'], + * }); + * + * // Annotation applicable to both kinds (e.g. tracing). + * const otelAnnotation = defineAnnotation<{ traceId: string }, 'read' | 'write'>({ + * namespace: 'otel', + * applicableTo: ['read', 'write'], + * }); + * ``` + * + * **Reserved namespaces.** See `DefineAnnotationOptions.namespace` for the + * list of framework-internal namespaces (`codecs`, target-specific keys). + * `defineAnnotation` does not structurally prevent a user from naming a + * reserved namespace, but the framework makes no compatibility guarantee + * about handles that do. + */ +export function defineAnnotation( + options: DefineAnnotationOptions, +): AnnotationHandle { + const namespace = options.namespace; + const applicableTo: ReadonlySet = Object.freeze(new Set(options.applicableTo)); + + function apply(value: Payload): AnnotationValue { + return Object.freeze({ + __annotation: true as const, + namespace, + value, + applicableTo, + }); + } + + function read(plan: { + readonly meta: { readonly annotations?: Record }; + }): Payload | undefined { + const stored = plan.meta.annotations?.[namespace]; + if (!isAnnotationValue(stored)) { + return undefined; + } + if (stored.namespace !== namespace) { + // Defensive: a different handle wrote under our namespace key. + return undefined; + } + return stored.value as Payload; + } + + return Object.freeze({ + namespace, + applicableTo, + apply, + read, + }); +} + +/** + * Type-level applicability gate consumed by lane terminals. + * + * Maps a tuple of `AnnotationValue`s to a tuple where each element either + * keeps its annotation type (when the annotation's declared `Kinds` + * includes the terminal's operation kind `K`) or resolves to `never` + * (when the kinds are incompatible). A `never` element makes the entire + * tuple unassignable, surfacing the mismatch as a type error at the call + * site of the terminal. + * + * Lane terminals constrain their variadic `...annotations` parameter via + * `ValidAnnotations`: + * + * @example + * ```typescript + * class Collection { + * first[]>( + * where: WhereInput, + * ...annotations: ValidAnnotations<'read', As> + * ): Promise; + * + * create[]>( + * input: CreateInput, + * ...annotations: ValidAnnotations<'write', As> + * ): Promise; + * } + * + * db.User.first({ id }, cacheAnnotation.apply({ ttl: 60 })); + * // ✓ cacheAnnotation declares 'read'; first() requires 'read'. + * + * db.User.create(input, cacheAnnotation.apply({ ttl: 60 })); + * // ✗ cacheAnnotation declares 'read'; create() requires 'write'. + * // Element resolves to `never` → tuple unassignable → type error. + * ``` + * + * The runtime helper `assertAnnotationsApplicable` covers the equivalent + * check at runtime so casts and `any` cannot bypass this gate. + */ +export type ValidAnnotations< + K extends OperationKind, + As extends readonly AnnotationValue[], +> = { + readonly [I in keyof As]: As[I] extends AnnotationValue + ? K extends Kinds + ? AnnotationValue + : never + : never; +}; + +/** + * Runtime applicability gate. Throws `RUNTIME.ANNOTATION_INAPPLICABLE` if + * any annotation in `annotations` declares an `applicableTo` set that does + * not include `kind`. Used by lane terminals (SQL DSL builders' `.build()`, + * ORM `Collection` terminals) to fail closed when the type-level + * `ValidAnnotations` gate is bypassed via cast / `any` / dynamic + * invocation. + * + * Passes silently on: + * - empty arrays + * - annotations whose `applicableTo` includes `kind` + * + * Throws on: + * - any annotation whose `applicableTo` does not include `kind`. The + * error names the offending annotation's `namespace` and the + * `terminalName` so users can locate the misuse. + * + * @example + * ```typescript + * // Inside an ORM read terminal: + * assertAnnotationsApplicable(annotations, 'read', 'first'); + * ``` + */ +export function assertAnnotationsApplicable( + annotations: readonly AnnotationValue[], + kind: OperationKind, + terminalName: string, +): void { + for (const annotation of annotations) { + if (!annotation.applicableTo.has(kind)) { + throw runtimeError( + 'RUNTIME.ANNOTATION_INAPPLICABLE', + `Annotation '${annotation.namespace}' is not applicable to '${kind}' operations (terminal: '${terminalName}'). The annotation declares applicableTo = [${Array.from( + annotation.applicableTo, + ) + .map((k) => `'${k}'`) + .join(', ')}].`, + { + namespace: annotation.namespace, + terminalName, + kind, + applicableTo: Array.from(annotation.applicableTo), + }, + ); + } + } +} + +/** + * Type guard for branded annotation values stored in `plan.meta.annotations`. + * + * Internal — used by `AnnotationHandle.read` to distinguish user + * annotations (created via `defineAnnotation(...).apply(...)`) from + * framework-internal metadata that may happen to live under the same + * namespace key. + */ +function isAnnotationValue(value: unknown): value is AnnotationValue { + if (value === null || typeof value !== 'object') { + return false; + } + const candidate = value as { readonly __annotation?: unknown }; + return candidate.__annotation === true; +} diff --git a/packages/1-framework/1-core/framework-components/src/exports/runtime.ts b/packages/1-framework/1-core/framework-components/src/exports/runtime.ts index 94c7628e34..abf38da10b 100644 --- a/packages/1-framework/1-core/framework-components/src/exports/runtime.ts +++ b/packages/1-framework/1-core/framework-components/src/exports/runtime.ts @@ -1,3 +1,11 @@ +export type { + AnnotationHandle, + AnnotationValue, + DefineAnnotationOptions, + OperationKind, + ValidAnnotations, +} from '../annotations'; +export { assertAnnotationsApplicable, defineAnnotation } from '../annotations'; export { AsyncIterableResult } from '../execution/async-iterable-result'; export type { ExecutionPlan, QueryPlan, ResultType } from '../execution/query-plan'; export { checkAborted, raceAgainstAbort } from '../execution/race-against-abort'; From 5342ca654ab4348bcbfc8aa7482e25a61cace8c3 Mon Sep 17 00:00:00 2001 From: Alexey Orlenko Date: Tue, 28 Apr 2026 14:53:15 +0200 Subject: [PATCH 03/21] test(framework-components): cover defineAnnotation, ValidAnnotations, and assertAnnotationsApplicable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two test files for the annotation surface from the previous commit: annotations.test.ts (23 runtime tests): - Handle metadata: namespace echoed; applicableTo is a frozen ReadonlySet narrowed to declared kinds; separate handles do not share state. - apply: produces __annotation-branded values; embeds namespace, payload, applicableTo; values are frozen; repeated apply yields independent values. - read: returns the payload for a value applied through the same handle; returns undefined when annotation is absent; returns undefined when the stored value lacks the brand (defends against framework-internal metadata sharing the same namespace key — e.g. the SQL emitter's meta.annotations.codecs map); two handles with different namespaces don't interfere; two handles claiming the same namespace string are isolated by the stored value's embedded namespace field; payload object identity is preserved. - assertAnnotationsApplicable: passes silently on empty arrays, matching kinds, both-kind annotations on either kind, mixed compatible annotations; throws RUNTIME.ANNOTATION_INAPPLICABLE with structured details (namespace, terminalName, kind, applicableTo) on mismatched kinds; the error message names the offending namespace and the terminal; the runtime check fires on opaquely-typed annotations forced through casts (the belt-and-suspenders for callers that bypass the type gate). annotations.types.test-d.ts (24 type tests): - defineAnnotation: handle type preserves Payload and Kinds; applicableTo is ReadonlySet narrowed to declared kinds; apply preserves payload and kinds in AnnotationValue; apply rejects payloads of the wrong shape (negative, three cases); read returns Payload | undefined. - ValidAnnotations: matching elements typed; mismatched elements resolve to never (both directions); empty tuple = empty tuple; mixed tuples propagate per-element; an inapplicable element makes the gated tuple unassignable from a value containing it. - Lane-terminal call shapes: read terminals accept read-only and both-kind annotations; reject write-only (negative); reject mixes containing write-only (negative). Write terminals: mirror image. Empty variadic accepted. - Type narrowness preserved: payload type survives the gate. - Defensive: non-AnnotationValue tuple elements resolve to never. One discovery surfaced and is now documented in production TSDoc: lane terminals must use 'As & ValidAnnotations', not just 'ValidAnnotations'. TypeScript's variadic-tuple inference is too forgiving when the parameter type refers to As only through ValidAnnotations: it picks an As that makes the call valid even when the gated tuple would contain never. The intersection pins As to the actual call-site tuple AND requires assignability to the gated form, surfacing inapplicable arguments as type errors. The terminal simulators in the type-d file use this pattern; M2 lane-terminal implementations must as well. Refs: TML-2143 --- .../framework-components/src/annotations.ts | 18 +- .../test/annotations.test.ts | 333 ++++++++++++++++++ .../test/annotations.types.test-d.ts | 245 +++++++++++++ 3 files changed, 593 insertions(+), 3 deletions(-) create mode 100644 packages/1-framework/1-core/framework-components/test/annotations.test.ts create mode 100644 packages/1-framework/1-core/framework-components/test/annotations.types.test-d.ts diff --git a/packages/1-framework/1-core/framework-components/src/annotations.ts b/packages/1-framework/1-core/framework-components/src/annotations.ts index dc67e376d7..fda0afe7e3 100644 --- a/packages/1-framework/1-core/framework-components/src/annotations.ts +++ b/packages/1-framework/1-core/framework-components/src/annotations.ts @@ -168,19 +168,20 @@ export function defineAnnotation( * site of the terminal. * * Lane terminals constrain their variadic `...annotations` parameter via - * `ValidAnnotations`: + * `As & ValidAnnotations`. **The intersection is load-bearing** — + * see the note below. * * @example * ```typescript * class Collection { * first[]>( * where: WhereInput, - * ...annotations: ValidAnnotations<'read', As> + * ...annotations: As & ValidAnnotations<'read', As> * ): Promise; * * create[]>( * input: CreateInput, - * ...annotations: ValidAnnotations<'write', As> + * ...annotations: As & ValidAnnotations<'write', As> * ): Promise; * } * @@ -192,6 +193,17 @@ export function defineAnnotation( * // Element resolves to `never` → tuple unassignable → type error. * ``` * + * **Why `As & ValidAnnotations` and not `ValidAnnotations` + * alone.** TypeScript's variadic-tuple inference is too forgiving when + * the parameter type refers to `As` only through `ValidAnnotations`: it + * will pick an `As` that makes the call valid even when the gated tuple + * would contain `never` for an inapplicable element. The intersection + * pins `As` to the actual call-site tuple AND requires it to be + * assignable to the gated form. A `never` element in the gated tuple + * then collapses the corresponding intersection position to `never`, + * and the inapplicable argument fails to assign — surfacing the mismatch + * as a type error at the call site. + * * The runtime helper `assertAnnotationsApplicable` covers the equivalent * check at runtime so casts and `any` cannot bypass this gate. */ diff --git a/packages/1-framework/1-core/framework-components/test/annotations.test.ts b/packages/1-framework/1-core/framework-components/test/annotations.test.ts new file mode 100644 index 0000000000..3d793aa8ed --- /dev/null +++ b/packages/1-framework/1-core/framework-components/test/annotations.test.ts @@ -0,0 +1,333 @@ +import type { PlanMeta } from '@prisma-next/contract/types'; +import { describe, expect, it } from 'vitest'; +import { + type AnnotationValue, + assertAnnotationsApplicable, + defineAnnotation, + type OperationKind, +} from '../src/annotations'; + +const meta: PlanMeta = { + target: 'mock', + storageHash: 'sha256:test', + lane: 'raw-sql', +}; + +function makePlan(annotations?: Record): { + readonly meta: { readonly annotations?: Record }; +} { + if (annotations === undefined) { + return { meta }; + } + return { meta: { ...meta, annotations } }; +} + +describe('defineAnnotation', () => { + describe('handle metadata', () => { + it('exposes the namespace it was created with', () => { + const handle = defineAnnotation<{ ttl: number }, 'read'>({ + namespace: 'cache', + applicableTo: ['read'], + }); + expect(handle.namespace).toBe('cache'); + }); + + it('exposes a frozen ReadonlySet for applicableTo', () => { + const handle = defineAnnotation<{ ttl: number }, 'read' | 'write'>({ + namespace: 'otel', + applicableTo: ['read', 'write'], + }); + expect(handle.applicableTo.has('read')).toBe(true); + expect(handle.applicableTo.has('write')).toBe(true); + expect(Object.isFrozen(handle.applicableTo)).toBe(true); + }); + + it('handles do not share state across separate defineAnnotation calls', () => { + const a = defineAnnotation<{ x: number }, 'read'>({ + namespace: 'a', + applicableTo: ['read'], + }); + const b = defineAnnotation<{ y: string }, 'write'>({ + namespace: 'b', + applicableTo: ['write'], + }); + expect(a.namespace).toBe('a'); + expect(b.namespace).toBe('b'); + expect(a.applicableTo.has('read')).toBe(true); + expect(a.applicableTo.has('write' as 'read')).toBe(false); + expect(b.applicableTo.has('write')).toBe(true); + }); + }); + + describe('apply', () => { + it('produces an AnnotationValue carrying the __annotation brand', () => { + const handle = defineAnnotation<{ ttl: number }, 'read'>({ + namespace: 'cache', + applicableTo: ['read'], + }); + const applied = handle.apply({ ttl: 60 }); + expect(applied.__annotation).toBe(true); + }); + + it('embeds the namespace, payload, and applicableTo set on the value', () => { + const handle = defineAnnotation<{ ttl: number }, 'read'>({ + namespace: 'cache', + applicableTo: ['read'], + }); + const applied = handle.apply({ ttl: 60 }); + expect(applied.namespace).toBe('cache'); + expect(applied.value).toEqual({ ttl: 60 }); + expect(applied.applicableTo.has('read')).toBe(true); + }); + + it('produces a frozen value', () => { + const handle = defineAnnotation<{ ttl: number }, 'read'>({ + namespace: 'cache', + applicableTo: ['read'], + }); + const applied = handle.apply({ ttl: 60 }); + expect(Object.isFrozen(applied)).toBe(true); + }); + + it('produces independent values across repeated apply calls', () => { + const handle = defineAnnotation<{ ttl: number }, 'read'>({ + namespace: 'cache', + applicableTo: ['read'], + }); + const a = handle.apply({ ttl: 60 }); + const b = handle.apply({ ttl: 120 }); + expect(a.value).toEqual({ ttl: 60 }); + expect(b.value).toEqual({ ttl: 120 }); + }); + }); + + describe('read', () => { + it('returns the payload when a value applied through the same handle is stored', () => { + const handle = defineAnnotation<{ ttl: number }, 'read'>({ + namespace: 'cache', + applicableTo: ['read'], + }); + const applied = handle.apply({ ttl: 60 }); + const plan = makePlan({ cache: applied }); + expect(handle.read(plan)).toEqual({ ttl: 60 }); + }); + + it('returns undefined when the annotation is absent', () => { + const handle = defineAnnotation<{ ttl: number }, 'read'>({ + namespace: 'cache', + applicableTo: ['read'], + }); + expect(handle.read(makePlan())).toBeUndefined(); + expect(handle.read(makePlan({}))).toBeUndefined(); + expect(handle.read(makePlan({ other: 'value' }))).toBeUndefined(); + }); + + it('returns undefined when the stored value is not a branded AnnotationValue', () => { + const handle = defineAnnotation<{ ttl: number }, 'read'>({ + namespace: 'cache', + applicableTo: ['read'], + }); + // Framework-internal metadata stored under the same namespace key + // (e.g. the SQL emitter's meta.annotations.codecs map) is a raw + // record, not a branded AnnotationValue. read() must not surface it + // as a user annotation. + expect(handle.read(makePlan({ cache: { ttl: 60 } }))).toBeUndefined(); + expect(handle.read(makePlan({ cache: 'string-value' }))).toBeUndefined(); + expect(handle.read(makePlan({ cache: 42 }))).toBeUndefined(); + expect(handle.read(makePlan({ cache: null }))).toBeUndefined(); + }); + + it('two handles with different namespaces do not interfere', () => { + const cache = defineAnnotation<{ ttl: number }, 'read'>({ + namespace: 'cache', + applicableTo: ['read'], + }); + const audit = defineAnnotation<{ actor: string }, 'write'>({ + namespace: 'audit', + applicableTo: ['write'], + }); + const plan = makePlan({ + cache: cache.apply({ ttl: 60 }), + audit: audit.apply({ actor: 'system' }), + }); + + expect(cache.read(plan)).toEqual({ ttl: 60 }); + expect(audit.read(plan)).toEqual({ actor: 'system' }); + }); + + it('read ignores annotations applied via a different handle even when the namespace string matches', () => { + // Two handles claiming the same namespace string. Defensive: + // read() compares the stored value's `namespace` field to the + // handle's, so a value applied through one handle does not surface + // through the other. + const a = defineAnnotation<{ kind: 'a' }, 'read'>({ + namespace: 'shared', + applicableTo: ['read'], + }); + // Construct a value whose stored namespace differs from the + // handle that reads it. (Both handles share a namespace string, + // but the stored AnnotationValue.namespace would normally match + // whichever handle wrote it.) + const stored: AnnotationValue<{ kind: 'b' }, 'read'> = Object.freeze({ + __annotation: true as const, + namespace: 'mismatched-namespace', + value: { kind: 'b' as const }, + applicableTo: new Set<'read'>(['read']), + }); + const plan = makePlan({ shared: stored }); + + // The stored value's `namespace` field is 'mismatched-namespace', + // which doesn't match the handle's 'shared' namespace. read() + // returns undefined. + expect(a.read(plan)).toBeUndefined(); + }); + + it('preserves Payload identity (handle.read returns the same object reference stored)', () => { + const handle = defineAnnotation<{ tags: string[] }, 'read'>({ + namespace: 'tags', + applicableTo: ['read'], + }); + const payload = { tags: ['admin', 'staff'] }; + const applied = handle.apply(payload); + const plan = makePlan({ tags: applied }); + + const out = handle.read(plan); + expect(out).toBe(payload); + }); + }); +}); + +describe('assertAnnotationsApplicable', () => { + const cache = defineAnnotation<{ ttl: number }, 'read'>({ + namespace: 'cache', + applicableTo: ['read'], + }); + const audit = defineAnnotation<{ actor: string }, 'write'>({ + namespace: 'audit', + applicableTo: ['write'], + }); + const otel = defineAnnotation<{ traceId: string }, 'read' | 'write'>({ + namespace: 'otel', + applicableTo: ['read', 'write'], + }); + + describe('passes silently', () => { + it('on an empty annotations array', () => { + expect(() => assertAnnotationsApplicable([], 'read', 'first')).not.toThrow(); + expect(() => assertAnnotationsApplicable([], 'write', 'create')).not.toThrow(); + }); + + it('when every annotation applies to the kind', () => { + expect(() => + assertAnnotationsApplicable([cache.apply({ ttl: 60 })], 'read', 'first'), + ).not.toThrow(); + expect(() => + assertAnnotationsApplicable([audit.apply({ actor: 'a' })], 'write', 'create'), + ).not.toThrow(); + }); + + it('when an annotation declares both kinds and is used on either', () => { + expect(() => + assertAnnotationsApplicable([otel.apply({ traceId: 't' })], 'read', 'first'), + ).not.toThrow(); + expect(() => + assertAnnotationsApplicable([otel.apply({ traceId: 't' })], 'write', 'create'), + ).not.toThrow(); + }); + + it('when multiple compatible annotations are passed together', () => { + expect(() => + assertAnnotationsApplicable( + [cache.apply({ ttl: 60 }), otel.apply({ traceId: 't' })], + 'read', + 'first', + ), + ).not.toThrow(); + }); + }); + + describe('throws RUNTIME.ANNOTATION_INAPPLICABLE', () => { + it('on a read-only annotation passed to a write terminal', () => { + expect(() => + assertAnnotationsApplicable([cache.apply({ ttl: 60 })], 'write', 'create'), + ).toThrow( + expect.objectContaining({ + code: 'RUNTIME.ANNOTATION_INAPPLICABLE', + category: 'RUNTIME', + }), + ); + }); + + it('on a write-only annotation passed to a read terminal', () => { + expect(() => + assertAnnotationsApplicable([audit.apply({ actor: 'system' })], 'read', 'first'), + ).toThrow( + expect.objectContaining({ + code: 'RUNTIME.ANNOTATION_INAPPLICABLE', + category: 'RUNTIME', + }), + ); + }); + + it('on the first inapplicable annotation when several are passed', () => { + expect(() => + assertAnnotationsApplicable( + [otel.apply({ traceId: 't' }), audit.apply({ actor: 'system' })], + 'read', + 'first', + ), + ).toThrow( + expect.objectContaining({ + code: 'RUNTIME.ANNOTATION_INAPPLICABLE', + }), + ); + }); + + it('with a message naming the offending annotation namespace and the terminal', () => { + try { + assertAnnotationsApplicable([cache.apply({ ttl: 60 })], 'write', 'create'); + expect.fail('expected assertAnnotationsApplicable to throw'); + } catch (error) { + expect(error).toBeInstanceOf(Error); + const message = (error as Error).message; + expect(message).toContain("'cache'"); + expect(message).toContain("'create'"); + expect(message).toContain("'write'"); + } + }); + + it('with structured details including namespace, terminalName, kind, and applicableTo', () => { + try { + assertAnnotationsApplicable([cache.apply({ ttl: 60 })], 'write', 'create'); + expect.fail('expected assertAnnotationsApplicable to throw'); + } catch (error) { + const envelope = error as Error & { details?: Record }; + expect(envelope.details).toEqual({ + namespace: 'cache', + terminalName: 'create', + kind: 'write', + applicableTo: ['read'], + }); + } + }); + }); + + describe('does not require AnnotationValue typing on its parameter', () => { + // The runtime helper takes readonly AnnotationValue[] + // so it can be called from lane terminals that have already passed + // the type gate via ValidAnnotations. The runtime check is the + // belt-and-suspenders that catches casts / `any` / dynamic + // invocations. + it('rejects an opaquely-typed inapplicable annotation forced through a cast', () => { + const sneakyWriteAnnotation = audit.apply({ actor: 'system' }); + // Imagine a caller bypassed the type gate via `as any` and handed + // the runtime an annotation whose kinds do not match the terminal. + const annotations: readonly AnnotationValue[] = [ + sneakyWriteAnnotation, + ]; + expect(() => assertAnnotationsApplicable(annotations, 'read', 'first')).toThrow( + expect.objectContaining({ code: 'RUNTIME.ANNOTATION_INAPPLICABLE' }), + ); + }); + }); +}); diff --git a/packages/1-framework/1-core/framework-components/test/annotations.types.test-d.ts b/packages/1-framework/1-core/framework-components/test/annotations.types.test-d.ts new file mode 100644 index 0000000000..11d0c30a8c --- /dev/null +++ b/packages/1-framework/1-core/framework-components/test/annotations.types.test-d.ts @@ -0,0 +1,245 @@ +import { assertType, describe, expectTypeOf, test } from 'vitest'; +import { + type AnnotationHandle, + type AnnotationValue, + defineAnnotation, + type OperationKind, + type ValidAnnotations, +} from '../src/annotations'; + +/** + * Type-level tests for the annotation surface. + * + * Verifies: + * - `defineAnnotation` preserves Payload and Kinds in the + * handle's static type and across `apply` / `read`. + * - `ValidAnnotations` resolves matching tuple elements to live + * `AnnotationValue` types and mismatched elements to `never` (which + * makes the entire tuple unassignable, which is the failure mode lane + * terminals exploit at the type level). + * - Lane-terminal call shapes — read terminals accepting read-only + * annotations, write terminals rejecting them, both-kind annotations + * accepted everywhere — work as expected. + */ + +const readOnly = defineAnnotation<{ ttl: number }, 'read'>({ + namespace: 'cache', + applicableTo: ['read'], +}); + +const writeOnly = defineAnnotation<{ actor: string }, 'write'>({ + namespace: 'audit', + applicableTo: ['write'], +}); + +const both = defineAnnotation<{ traceId: string }, 'read' | 'write'>({ + namespace: 'otel', + applicableTo: ['read', 'write'], +}); + +describe('defineAnnotation generics', () => { + test('defineAnnotation preserves Payload and Kinds in the handle type', () => { + expectTypeOf(readOnly).toEqualTypeOf>(); + expectTypeOf(writeOnly).toEqualTypeOf>(); + expectTypeOf(both).toEqualTypeOf>(); + }); + + test('AnnotationHandle.namespace is a string', () => { + expectTypeOf(readOnly.namespace).toBeString(); + }); + + test('AnnotationHandle.applicableTo is a ReadonlySet narrowed to the declared Kinds', () => { + expectTypeOf(readOnly.applicableTo).toEqualTypeOf>(); + expectTypeOf(writeOnly.applicableTo).toEqualTypeOf>(); + expectTypeOf(both.applicableTo).toEqualTypeOf>(); + }); + + test('apply preserves Payload and Kinds in the AnnotationValue', () => { + const r = readOnly.apply({ ttl: 60 }); + const w = writeOnly.apply({ actor: 'system' }); + const x = both.apply({ traceId: 't' }); + + expectTypeOf(r).toEqualTypeOf>(); + expectTypeOf(w).toEqualTypeOf>(); + expectTypeOf(x).toEqualTypeOf>(); + }); + + test('apply rejects payloads of the wrong shape (negative)', () => { + // @ts-expect-error - missing required `ttl` field + readOnly.apply({}); + // @ts-expect-error - wrong field name + readOnly.apply({ wrong: 60 }); + // @ts-expect-error - wrong field type + readOnly.apply({ ttl: 'not a number' }); + }); + + test('read returns Payload | undefined', () => { + const plan: { readonly meta: { readonly annotations?: Record } } = { + meta: {}, + }; + const out = readOnly.read(plan); + expectTypeOf(out).toEqualTypeOf<{ ttl: number } | undefined>(); + }); +}); + +describe('ValidAnnotations gate', () => { + test("ValidAnnotations<'read', [readOnly]> keeps the element typed", () => { + type As = readonly [AnnotationValue<{ ttl: number }, 'read'>]; + type Gated = ValidAnnotations<'read', As>; + expectTypeOf().toEqualTypeOf]>(); + }); + + test("ValidAnnotations<'read', [writeOnly]> resolves the element to never", () => { + type As = readonly [AnnotationValue<{ actor: string }, 'write'>]; + type Gated = ValidAnnotations<'read', As>; + expectTypeOf().toEqualTypeOf(); + }); + + test("ValidAnnotations<'write', [readOnly]> resolves the element to never", () => { + type As = readonly [AnnotationValue<{ ttl: number }, 'read'>]; + type Gated = ValidAnnotations<'write', As>; + expectTypeOf().toEqualTypeOf(); + }); + + test("ValidAnnotations<'read', [readOnly, both]> keeps both elements", () => { + type As = readonly [ + AnnotationValue<{ ttl: number }, 'read'>, + AnnotationValue<{ traceId: string }, 'read' | 'write'>, + ]; + type Gated = ValidAnnotations<'read', As>; + expectTypeOf().toEqualTypeOf< + readonly [ + AnnotationValue<{ ttl: number }, 'read'>, + AnnotationValue<{ traceId: string }, 'read' | 'write'>, + ] + >(); + }); + + test("ValidAnnotations<'write', [readOnly, writeOnly]> resolves the read-only element to never", () => { + type As = readonly [ + AnnotationValue<{ ttl: number }, 'read'>, + AnnotationValue<{ actor: string }, 'write'>, + ]; + type Gated = ValidAnnotations<'write', As>; + expectTypeOf().toEqualTypeOf< + readonly [never, AnnotationValue<{ actor: string }, 'write'>] + >(); + }); + + test('ValidAnnotations on the empty tuple is the empty tuple', () => { + type Gated = ValidAnnotations<'read', readonly []>; + expectTypeOf().toEqualTypeOf(); + }); + + test('an inapplicable element makes the gated tuple unassignable from a value containing it', () => { + type As = readonly [ + AnnotationValue<{ ttl: number }, 'read'>, + AnnotationValue<{ actor: string }, 'write'>, + ]; + type Gated = ValidAnnotations<'read', As>; + // The gated tuple's second element is `never`, so the original tuple + // cannot be assigned to it. + const original: As = [readOnly.apply({ ttl: 60 }), writeOnly.apply({ actor: 'system' })]; + // @ts-expect-error - second element resolves to never under 'read' + const _gated: Gated = original; + void _gated; + }); +}); + +describe('lane-terminal call-shape simulation', () => { + /** + * Mimics the shape lane terminals adopt: a variadic `...annotations` + * parameter constrained by `ValidAnnotations`. The `As` type + * parameter is inferred from the call site's tuple of annotation values. + * + * Note: lane terminals must constrain the variadic argument as + * `As & ValidAnnotations`, not just `ValidAnnotations`. + * TypeScript's variadic-tuple inference is too forgiving when the + * parameter type alone refers to `As`: it will pick an `As` that makes + * the call valid even when the gated tuple contains `never`. The + * intersection forces the argument to be assignable to BOTH the inferred + * `As` AND the gated tuple, so a `never` element collapses to `never` + * at the position where it lives and the call rejects the offending + * argument. + */ + function readTerminal[]>( + ...annotations: As & ValidAnnotations<'read', As> + ): void { + void annotations; + } + + function writeTerminal[]>( + ...annotations: As & ValidAnnotations<'write', As> + ): void { + void annotations; + } + + test('read terminal accepts read-only annotations', () => { + readTerminal(readOnly.apply({ ttl: 60 })); + }); + + test('read terminal accepts both-kind annotations', () => { + readTerminal(both.apply({ traceId: 't' })); + }); + + test('read terminal accepts a mix of read-only and both-kind annotations', () => { + readTerminal(readOnly.apply({ ttl: 60 }), both.apply({ traceId: 't' })); + }); + + test('read terminal rejects write-only annotations (negative)', () => { + // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' + readTerminal(writeOnly.apply({ actor: 'system' })); + }); + + test('read terminal rejects a mix that includes a write-only annotation (negative)', () => { + // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' + readTerminal(readOnly.apply({ ttl: 60 }), writeOnly.apply({ actor: 'system' })); + }); + + test('write terminal accepts write-only annotations', () => { + writeTerminal(writeOnly.apply({ actor: 'system' })); + }); + + test('write terminal accepts both-kind annotations', () => { + writeTerminal(both.apply({ traceId: 't' })); + }); + + test('write terminal rejects read-only annotations (negative)', () => { + // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' + writeTerminal(readOnly.apply({ ttl: 60 })); + }); + + test('terminals accept zero annotations (empty variadic)', () => { + readTerminal(); + writeTerminal(); + }); +}); + +describe('type narrowness preserved across the gate', () => { + test('the read terminal preserves the typed payload of a both-kind annotation', () => { + function inspect[]>( + ...annotations: As & ValidAnnotations<'read', As> + ): As { + return annotations as unknown as As; + } + + const out = inspect(both.apply({ traceId: 't' })); + // The handle's payload type survives the gate. + assertType<{ traceId: string }>(out[0].value); + }); + + test('non-AnnotationValue elements in the tuple resolve to never (defensive)', () => { + // Not part of the public API surface, but verifies the conditional's + // fallback. If somebody constructs a tuple of arbitrary objects and runs + // it through the gate, every element resolves to `never`. + type As = readonly [{ not: 'an annotation' }]; + type Gated = ValidAnnotations< + 'read', + As extends readonly AnnotationValue[] ? As : never + >; + // The conditional's outer `As extends readonly AnnotationValue[...]` + // branch makes the entire `As` resolve to `never`, which propagates + // through ValidAnnotations. + expectTypeOf().toEqualTypeOf(); + }); +}); From 2e11d8f5a5edec4432db094bb67bf3cef1599259 Mon Sep 17 00:00:00 2001 From: Alexey Orlenko Date: Tue, 28 Apr 2026 15:28:42 +0200 Subject: [PATCH 04/21] feat(sql-builder): add .annotate() to all SQL DSL builders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the user-facing annotation surface on the SQL DSL. All five builders gain an .annotate(...) method that accepts variadic AnnotationValue arguments constrained by their operation kind: - SelectQueryImpl → read (via QueryBase) - GroupedQueryImpl → read (via QueryBase) - InsertQueryImpl → write - UpdateQueryImpl → write - DeleteQueryImpl → write Signature on each builder: annotate[]>( ...annotations: As & ValidAnnotations ): this The 'As & ValidAnnotations' intersection is load-bearing — see the discovery note on ValidAnnotations TSDoc. TypeScript's variadic- tuple inference with ValidAnnotations alone is too forgiving and lets inapplicable annotations through; the intersection pins As to the call-site tuple AND requires assignability to the gated form. Behavior: - Type-level: read builders reject write-only annotations and vice versa. Both-kind annotations ('read' | 'write' applicableTo) are accepted on every builder. Negative cases fail to compile. - Runtime: assertAnnotationsApplicable runs at .annotate() call time (not deferred to .build()) so casts / 'any' / dynamic invocations fail fast with RUNTIME.ANNOTATION_INAPPLICABLE. - Chainable in any position. Multiple .annotate() calls compose; duplicate namespaces last-write-win. - Annotations are merged into plan.meta.annotations at .build() time, alongside the framework-internal 'codecs' map under its reserved namespace. Reserved 'codecs' wins over user annotations under the same key (defensive — see the reserved-namespace policy on defineAnnotation). Plumbing: - BuilderState gains userAnnotations: ReadonlyMap>. Mutation builders don't share BuilderState; each carries its own private #userAnnotations field plus a constructor parameter, threaded through .where()/.returning() clones. - buildQueryPlan accepts an optional userAnnotations parameter and composes the final meta.annotations from both the codecs map and the user annotations. - mergeWriteAnnotations helper in mutation-impl.ts deduplicates the repeated 'validate, clone the map, set the new entries' pattern across the three mutation builders. The interface declarations (SelectQuery, GroupedQuery, InsertQuery, UpdateQuery, DeleteQuery) all gain .annotate as a typed method so consumers see it via the public types, not just the impl. Tests land in the next commit (M2.3 + M2.4). Refs: TML-2143 --- .../sql-builder/src/runtime/builder-base.ts | 14 ++- .../sql-builder/src/runtime/mutation-impl.ts | 116 ++++++++++++++++++ .../sql-builder/src/runtime/query-impl.ts | 38 ++++++ .../sql-builder/src/types/grouped-query.ts | 17 +++ .../sql-builder/src/types/mutation-query.ts | 30 +++++ .../sql-builder/src/types/select-query.ts | 17 +++ 6 files changed, 231 insertions(+), 1 deletion(-) diff --git a/packages/2-sql/4-lanes/sql-builder/src/runtime/builder-base.ts b/packages/2-sql/4-lanes/sql-builder/src/runtime/builder-base.ts index 8f993ff888..10c6b992a0 100644 --- a/packages/2-sql/4-lanes/sql-builder/src/runtime/builder-base.ts +++ b/packages/2-sql/4-lanes/sql-builder/src/runtime/builder-base.ts @@ -1,4 +1,5 @@ import type { PlanMeta } from '@prisma-next/contract/types'; +import type { AnnotationValue, OperationKind } from '@prisma-next/framework-components/runtime'; import type { StorageTable } from '@prisma-next/sql-contract/types'; import type { SqlOperationEntry } from '@prisma-next/sql-operations'; import { @@ -70,6 +71,12 @@ export interface BuilderState { readonly distinctOn: readonly AstExpression[] | undefined; readonly scope: Scope; readonly rowFields: Record; + /** + * User annotations accumulated through `.annotate(...)` calls. + * Stored as a `Map` so duplicate + * namespaces last-write-win. Empty on a fresh state. + */ + readonly userAnnotations: ReadonlyMap>; } export interface BuilderContext { @@ -97,6 +104,7 @@ export function emptyState(from: TableSource, scope: Scope): BuilderState { distinctOn: undefined, scope, rowFields: {}, + userAnnotations: new Map(), }; } @@ -131,6 +139,7 @@ export function buildSelectAst(state: BuilderState): SelectAst { export function buildQueryPlan( ast: import('@prisma-next/sql-relational-core/ast').AnyQueryAst, ctx: BuilderContext, + annotations?: ReadonlyMap>, ): SqlQueryPlan { const paramValues = collectOrderedParamRefs(ast).map((r) => r.value); @@ -138,6 +147,9 @@ export function buildQueryPlan( target: ctx.target, storageHash: ctx.storageHash, lane: 'dsl', + ...(annotations !== undefined && annotations.size > 0 + ? { annotations: Object.freeze(Object.fromEntries(annotations)) } + : {}), }); return Object.freeze({ ast, params: paramValues, meta }); @@ -147,7 +159,7 @@ export function buildPlan( state: BuilderState, ctx: BuilderContext, ): SqlQueryPlan { - return buildQueryPlan(buildSelectAst(state), ctx); + return buildQueryPlan(buildSelectAst(state), ctx, state.userAnnotations); } export function tableToScope(name: string, table: StorageTable): Scope { diff --git a/packages/2-sql/4-lanes/sql-builder/src/runtime/mutation-impl.ts b/packages/2-sql/4-lanes/sql-builder/src/runtime/mutation-impl.ts index e11954d942..51885178c1 100644 --- a/packages/2-sql/4-lanes/sql-builder/src/runtime/mutation-impl.ts +++ b/packages/2-sql/4-lanes/sql-builder/src/runtime/mutation-impl.ts @@ -1,3 +1,9 @@ +import type { + AnnotationValue, + OperationKind, + ValidAnnotations, +} from '@prisma-next/framework-components/runtime'; +import { assertAnnotationsApplicable } from '@prisma-next/framework-components/runtime'; import type { StorageTable } from '@prisma-next/sql-contract/types'; import { type AnyExpression as AstExpression, @@ -29,6 +35,29 @@ import { import { createFieldProxy } from './field-proxy'; import { createFunctions } from './functions'; +/** + * Validates and merges a variadic annotations call into a builder's + * accumulated user-annotations map. Used by `.annotate(...)` on each of + * the three mutation builders (`InsertQueryImpl`, `UpdateQueryImpl`, + * `DeleteQueryImpl`); the read builders share the same logic via + * `QueryBase.annotate()` in `./query-impl.ts`. + * + * Runs `assertAnnotationsApplicable` at call time (not at `.build()`) so + * inapplicable annotations forced through casts surface immediately + * rather than at plan-construction time. + */ +function mergeWriteAnnotations( + current: ReadonlyMap>, + annotations: readonly AnnotationValue[], +): ReadonlyMap> { + assertAnnotationsApplicable(annotations, 'write', 'sql-dsl.annotate'); + const next = new Map(current); + for (const annotation of annotations) { + next.set(annotation.namespace, annotation); + } + return next; +} + type WhereCallback = ExpressionBuilder; function buildParamValues( @@ -85,6 +114,7 @@ export class InsertQueryImpl< readonly #values: Record; readonly #returningColumns: string[]; readonly #rowFields: Record; + readonly #userAnnotations: ReadonlyMap>; constructor( tableName: string, @@ -94,6 +124,7 @@ export class InsertQueryImpl< ctx: BuilderContext, returningColumns: string[] = [], rowFields: Record = {}, + userAnnotations: ReadonlyMap> = new Map(), ) { super(ctx); this.#tableName = tableName; @@ -102,6 +133,7 @@ export class InsertQueryImpl< this.#values = values; this.#returningColumns = returningColumns; this.#rowFields = rowFields; + this.#userAnnotations = userAnnotations; } returning = this._gate>( @@ -122,10 +154,36 @@ export class InsertQueryImpl< this.ctx, columns, newRowFields, + this.#userAnnotations, ) as unknown as InsertQuery; }, ); + /** + * Attach one or more write-typed user annotations to this query plan. + * The type-level `As & ValidAnnotations<'write', As>` gate rejects + * read-only annotations at the call site; the runtime check fails + * closed for callers that bypass the type gate. See `QueryBase.annotate` + * in `./query-impl.ts` for the read-builder counterpart. + */ + annotate[]>( + ...annotations: As & ValidAnnotations<'write', As> + ): InsertQuery { + return new InsertQueryImpl( + this.#tableName, + this.#table, + this.#scope, + this.#values, + this.ctx, + this.#returningColumns, + this.#rowFields, + mergeWriteAnnotations( + this.#userAnnotations, + annotations as readonly AnnotationValue[], + ), + ); + } + build(): SqlQueryPlan> { const paramValues = buildParamValues( this.#values, @@ -146,6 +204,7 @@ export class InsertQueryImpl< return buildQueryPlan>( ast, this.ctx, + this.#userAnnotations, ); } } @@ -165,6 +224,7 @@ export class UpdateQueryImpl< readonly #whereCallbacks: readonly WhereCallback[]; readonly #returningColumns: string[]; readonly #rowFields: Record; + readonly #userAnnotations: ReadonlyMap>; constructor( tableName: string, @@ -175,6 +235,7 @@ export class UpdateQueryImpl< whereCallbacks: readonly WhereCallback[] = [], returningColumns: string[] = [], rowFields: Record = {}, + userAnnotations: ReadonlyMap> = new Map(), ) { super(ctx); this.#tableName = tableName; @@ -184,6 +245,7 @@ export class UpdateQueryImpl< this.#whereCallbacks = whereCallbacks; this.#returningColumns = returningColumns; this.#rowFields = rowFields; + this.#userAnnotations = userAnnotations; } where(expr: ExpressionBuilder): UpdateQuery { @@ -196,6 +258,7 @@ export class UpdateQueryImpl< [...this.#whereCallbacks, expr as unknown as WhereCallback], this.#returningColumns, this.#rowFields, + this.#userAnnotations, ); } @@ -218,10 +281,35 @@ export class UpdateQueryImpl< this.#whereCallbacks, columns, newRowFields, + this.#userAnnotations, ) as unknown as UpdateQuery; }, ); + /** + * Attach one or more write-typed user annotations to this query plan. + * See `InsertQueryImpl.annotate` for semantics; the runtime check + * fails closed for callers that bypass the type-level gate. + */ + annotate[]>( + ...annotations: As & ValidAnnotations<'write', As> + ): UpdateQuery { + return new UpdateQueryImpl( + this.#tableName, + this.#table, + this.#scope, + this.#setValues, + this.ctx, + this.#whereCallbacks, + this.#returningColumns, + this.#rowFields, + mergeWriteAnnotations( + this.#userAnnotations, + annotations as readonly AnnotationValue[], + ), + ); + } + build(): SqlQueryPlan> { const setParams = buildParamValues( this.#setValues, @@ -250,6 +338,7 @@ export class UpdateQueryImpl< return buildQueryPlan>( ast, this.ctx, + this.#userAnnotations, ); } } @@ -267,6 +356,7 @@ export class DeleteQueryImpl< readonly #whereCallbacks: readonly WhereCallback[]; readonly #returningColumns: string[]; readonly #rowFields: Record; + readonly #userAnnotations: ReadonlyMap>; constructor( tableName: string, @@ -275,6 +365,7 @@ export class DeleteQueryImpl< whereCallbacks: readonly WhereCallback[] = [], returningColumns: string[] = [], rowFields: Record = {}, + userAnnotations: ReadonlyMap> = new Map(), ) { super(ctx); this.#tableName = tableName; @@ -282,6 +373,7 @@ export class DeleteQueryImpl< this.#whereCallbacks = whereCallbacks; this.#returningColumns = returningColumns; this.#rowFields = rowFields; + this.#userAnnotations = userAnnotations; } where(expr: ExpressionBuilder): DeleteQuery { @@ -292,6 +384,7 @@ export class DeleteQueryImpl< [...this.#whereCallbacks, expr as unknown as WhereCallback], this.#returningColumns, this.#rowFields, + this.#userAnnotations, ); } @@ -312,10 +405,32 @@ export class DeleteQueryImpl< this.#whereCallbacks, columns, newRowFields, + this.#userAnnotations, ) as unknown as DeleteQuery; }, ); + /** + * Attach one or more write-typed user annotations to this query plan. + * See `InsertQueryImpl.annotate` for semantics. + */ + annotate[]>( + ...annotations: As & ValidAnnotations<'write', As> + ): DeleteQuery { + return new DeleteQueryImpl( + this.#tableName, + this.#scope, + this.ctx, + this.#whereCallbacks, + this.#returningColumns, + this.#rowFields, + mergeWriteAnnotations( + this.#userAnnotations, + annotations as readonly AnnotationValue[], + ), + ); + } + build(): SqlQueryPlan> { const whereExpr = combineWhereExprs( this.#whereCallbacks.map((cb) => @@ -334,6 +449,7 @@ export class DeleteQueryImpl< return buildQueryPlan>( ast, this.ctx, + this.#userAnnotations, ); } } diff --git a/packages/2-sql/4-lanes/sql-builder/src/runtime/query-impl.ts b/packages/2-sql/4-lanes/sql-builder/src/runtime/query-impl.ts index 0a9b93b611..ae10510847 100644 --- a/packages/2-sql/4-lanes/sql-builder/src/runtime/query-impl.ts +++ b/packages/2-sql/4-lanes/sql-builder/src/runtime/query-impl.ts @@ -1,3 +1,9 @@ +import type { + AnnotationValue, + OperationKind, + ValidAnnotations, +} from '@prisma-next/framework-components/runtime'; +import { assertAnnotationsApplicable } from '@prisma-next/framework-components/runtime'; import { DerivedTableSource, type SelectAst } from '@prisma-next/sql-relational-core/ast'; import type { SqlQueryPlan } from '@prisma-next/sql-relational-core/plan'; import type { @@ -81,6 +87,38 @@ abstract class QueryBase< return this.clone(cloneState(this.state, { distinct: true })); } + /** + * Attach one or more user annotations to this query plan. + * + * Read builders (`SelectQueryImpl`, `GroupedQueryImpl`) accept + * annotations whose declared `applicableTo` includes `'read'`. + * The type-level `As & ValidAnnotations<'read', As>` gate rejects + * write-only annotations at the call site; the runtime check below + * fails closed for callers that bypass the type gate (cast / `any`). + * + * Multiple `.annotate(...)` calls compose; duplicate namespaces use + * last-write-wins. The accumulated annotations are merged into + * `plan.meta.annotations` at `.build()` time, alongside any framework- + * internal metadata under reserved namespaces (e.g. `codecs`). + * + * Chainable in any position (before / after `.where`, `.select`, + * `.limit`, etc.); the returned builder has the same row type. + */ + annotate[]>( + ...annotations: As & ValidAnnotations<'read', As> + ): this { + assertAnnotationsApplicable( + annotations as readonly AnnotationValue[], + 'read', + 'sql-dsl.annotate', + ); + const next = new Map(this.state.userAnnotations); + for (const annotation of annotations as readonly AnnotationValue[]) { + next.set(annotation.namespace, annotation); + } + return this.clone(cloneState(this.state, { userAnnotations: next })); + } + groupBy( ...fields: ((keyof RowType | keyof AvailableScope['topLevel']) & string)[] ): GroupedQuery; diff --git a/packages/2-sql/4-lanes/sql-builder/src/types/grouped-query.ts b/packages/2-sql/4-lanes/sql-builder/src/types/grouped-query.ts index 2e356d2342..0e0cb5bc69 100644 --- a/packages/2-sql/4-lanes/sql-builder/src/types/grouped-query.ts +++ b/packages/2-sql/4-lanes/sql-builder/src/types/grouped-query.ts @@ -1,3 +1,8 @@ +import type { + AnnotationValue, + OperationKind, + ValidAnnotations, +} from '@prisma-next/framework-components/runtime'; import type { AggregateFunctions, BooleanCodecType, @@ -19,6 +24,18 @@ export interface GroupedQuery< WithDistinct, WithAlias, WithBuild { + /** + * Attach one or more read-typed user annotations to this query plan. + * Annotations declare `applicableTo: ['read']` (or `['read', 'write']`) + * via `defineAnnotation`; write-only annotations fail to compile here. + * Annotations are merged into `plan.meta.annotations` at `.build()` time. + * Chainable in any position; multiple calls compose with last-write-wins + * on duplicate namespaces. + */ + annotate[]>( + ...annotations: As & ValidAnnotations<'read', As> + ): GroupedQuery; + groupBy( ...fields: ((keyof RowType | keyof AvailableScope['topLevel']) & string)[] ): GroupedQuery; diff --git a/packages/2-sql/4-lanes/sql-builder/src/types/mutation-query.ts b/packages/2-sql/4-lanes/sql-builder/src/types/mutation-query.ts index 1a8abfb1c7..23f25a6539 100644 --- a/packages/2-sql/4-lanes/sql-builder/src/types/mutation-query.ts +++ b/packages/2-sql/4-lanes/sql-builder/src/types/mutation-query.ts @@ -1,3 +1,8 @@ +import type { + AnnotationValue, + OperationKind, + ValidAnnotations, +} from '@prisma-next/framework-components/runtime'; import type { StorageTable } from '@prisma-next/sql-contract/types'; import type { SqlQueryPlan } from '@prisma-next/sql-relational-core/plan'; import type { ExpressionBuilder, WithFields } from '../expression'; @@ -30,6 +35,17 @@ export interface InsertQuery< AvailableScope extends Scope, RowType extends Record, > { + /** + * Attach one or more write-typed user annotations to this query plan. + * Annotations declare `applicableTo: ['write']` (or `['read', 'write']`) + * via `defineAnnotation`; read-only annotations fail to compile here. + * Annotations are merged into `plan.meta.annotations` at `.build()` time. + * Chainable in any position; multiple calls compose with last-write-wins + * on duplicate namespaces. + */ + annotate[]>( + ...annotations: As & ValidAnnotations<'write', As> + ): InsertQuery; returning: GatedMethod< QC['capabilities'], ReturningCapability, @@ -45,6 +61,13 @@ export interface UpdateQuery< AvailableScope extends Scope, RowType extends Record, > { + /** + * Attach one or more write-typed user annotations to this query plan. + * See `InsertQuery.annotate` for semantics. + */ + annotate[]>( + ...annotations: As & ValidAnnotations<'write', As> + ): UpdateQuery; where(expr: ExpressionBuilder): UpdateQuery; returning: GatedMethod< QC['capabilities'], @@ -61,6 +84,13 @@ export interface DeleteQuery< AvailableScope extends Scope, RowType extends Record, > { + /** + * Attach one or more write-typed user annotations to this query plan. + * See `InsertQuery.annotate` for semantics. + */ + annotate[]>( + ...annotations: As & ValidAnnotations<'write', As> + ): DeleteQuery; where(expr: ExpressionBuilder): DeleteQuery; returning: GatedMethod< QC['capabilities'], diff --git a/packages/2-sql/4-lanes/sql-builder/src/types/select-query.ts b/packages/2-sql/4-lanes/sql-builder/src/types/select-query.ts index 6df2c68205..567a4e837b 100644 --- a/packages/2-sql/4-lanes/sql-builder/src/types/select-query.ts +++ b/packages/2-sql/4-lanes/sql-builder/src/types/select-query.ts @@ -1,3 +1,8 @@ +import type { + AnnotationValue, + OperationKind, + ValidAnnotations, +} from '@prisma-next/framework-components/runtime'; import type { Expression, ExpressionBuilder, @@ -20,6 +25,18 @@ export interface SelectQuery< WithDistinct, WithAlias, WithBuild { + /** + * Attach one or more read-typed user annotations to this query plan. + * Annotations declare `applicableTo: ['read']` (or `['read', 'write']`) + * via `defineAnnotation`; write-only annotations fail to compile here. + * Annotations are merged into `plan.meta.annotations` at `.build()` time. + * Chainable in any position; multiple calls compose with last-write-wins + * on duplicate namespaces. + */ + annotate[]>( + ...annotations: As & ValidAnnotations<'read', As> + ): SelectQuery; + where(expr: ExpressionBuilder): SelectQuery; orderBy( From 8498c2b185cd3f306acd6aa625c2afc314bed692 Mon Sep 17 00:00:00 2001 From: Alexey Orlenko Date: Tue, 28 Apr 2026 15:34:46 +0200 Subject: [PATCH 05/21] test(sql-builder): cover .annotate() across all five SQL DSL builders Two test files, organized by builder kind via describe blocks: annotate.test.ts (28 runtime tests): - SelectQuery (12 tests): writes the applied annotation; round-trips via handle.read; absent annotation reads as undefined; multiple namespaces coexist; multiple annotations in one call; duplicate namespace last-write-wins; immutability (annotate does not mutate the original builder); chainable in three positions (immediately after .select, between .select and .where, after .where before .limit); annotate does not affect the AST shape; empty variadic is a no-op for user annotations; runtime gate rejects write-only via cast. - GroupedQuery (4 tests): writes annotation; chainable between .select and .groupBy; chainable after .groupBy before .orderBy; runtime gate. - InsertQuery (4 tests): writes annotation; accepts both-kind; survives across .returning chaining; runtime gate. - UpdateQuery (3 tests): writes annotation; survives .where + .returning chaining; runtime gate. - DeleteQuery (3 tests): writes annotation; survives .where + .returning chaining; runtime gate. - Reserved-namespace coexistence (1 test): user annotations live alongside the framework-internal 'codecs' map without collision. annotate.test-d.ts (26 type-level tests): - SelectQuery (9 tests): accepts read-only, both-kind, multi compatible; rejects write-only and write-bearing mixes (negative); accepts empty variadic; chainable in three positions preserves the row type. - GroupedQuery (4 tests): accepts read-only, both-kind; rejects write-only (negative); chainable preserves row type. - InsertQuery (5 tests): accepts write-only, both-kind; rejects read-only and read-bearing mixes (negative); chainable before .returning preserves the resulting row type. - UpdateQuery (4 tests): mirror of Insert. - DeleteQuery (4 tests): mirror of Insert. The test-d file rediscovers a quirk already documented on the ValidAnnotations TSDoc: TypeScript's variadic-tuple inference reports the call-site error on the function-call line, not on the inapplicable argument line. The @ts-expect-error directives sit on the call line for mixed cases (matching the framework-components type-d tests). Refs: TML-2143 --- .../test/playground/annotate.test-d.ts | 240 ++++++++++++ .../sql-builder/test/runtime/annotate.test.ts | 350 ++++++++++++++++++ 2 files changed, 590 insertions(+) create mode 100644 packages/2-sql/4-lanes/sql-builder/test/playground/annotate.test-d.ts create mode 100644 packages/2-sql/4-lanes/sql-builder/test/runtime/annotate.test.ts diff --git a/packages/2-sql/4-lanes/sql-builder/test/playground/annotate.test-d.ts b/packages/2-sql/4-lanes/sql-builder/test/playground/annotate.test-d.ts new file mode 100644 index 0000000000..c02ce5f60e --- /dev/null +++ b/packages/2-sql/4-lanes/sql-builder/test/playground/annotate.test-d.ts @@ -0,0 +1,240 @@ +import { defineAnnotation } from '@prisma-next/framework-components/runtime'; +import type { SqlQueryPlan } from '@prisma-next/sql-relational-core/plan'; +import { describe, expectTypeOf, test } from 'vitest'; +import { db } from './preamble'; + +/** + * Type-level tests for the SQL DSL `.annotate(...)` surface. + * + * Verifies: + * - Each builder kind (Select, Grouped, Insert, Update, Delete) accepts + * annotations matching its operation kind and rejects mismatched ones + * via the `As & ValidAnnotations` gate. + * - Annotations applicable to both kinds (`'read' | 'write'`) are + * accepted on every builder. + * - `.annotate()` does not widen the resulting plan's row type. + * - `.annotate()` is chainable in any position relative to other + * builder methods. + */ + +const cacheAnnotation = defineAnnotation<{ ttl: number; skip?: boolean }, 'read'>({ + namespace: 'cache', + applicableTo: ['read'], +}); + +const auditAnnotation = defineAnnotation<{ actor: string }, 'write'>({ + namespace: 'audit', + applicableTo: ['write'], +}); + +const otelAnnotation = defineAnnotation<{ traceId: string }, 'read' | 'write'>({ + namespace: 'otel', + applicableTo: ['read', 'write'], +}); + +describe('SelectQuery.annotate (read-typed)', () => { + test('accepts a read-only annotation', () => { + const plan = db.users + .select('id') + .annotate(cacheAnnotation.apply({ ttl: 60 })) + .build(); + expectTypeOf(plan).toEqualTypeOf>(); + }); + + test('accepts a both-kind annotation', () => { + const plan = db.users + .select('id') + .annotate(otelAnnotation.apply({ traceId: 't' })) + .build(); + expectTypeOf(plan).toEqualTypeOf>(); + }); + + test('accepts multiple compatible annotations in a single call', () => { + const plan = db.users + .select('id') + .annotate(cacheAnnotation.apply({ ttl: 60 }), otelAnnotation.apply({ traceId: 't' })) + .build(); + expectTypeOf(plan).toEqualTypeOf>(); + }); + + test('rejects a write-only annotation (negative)', () => { + // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' + db.users.select('id').annotate(auditAnnotation.apply({ actor: 'system' })); + }); + + test('rejects a mix containing a write-only annotation (negative)', () => { + db.users + .select('id') + // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' + .annotate(cacheAnnotation.apply({ ttl: 60 }), auditAnnotation.apply({ actor: 'system' })); + }); + + test('accepts zero annotations (empty variadic)', () => { + const plan = db.users.select('id').annotate().build(); + expectTypeOf(plan).toEqualTypeOf>(); + }); + + test('chainable: .annotate() before .where preserves row type', () => { + const plan = db.users + .select('id', 'email') + .annotate(cacheAnnotation.apply({ ttl: 60 })) + .where((c, fns) => fns.eq(c.id, 1)) + .build(); + expectTypeOf(plan).toEqualTypeOf>(); + }); + + test('chainable: .annotate() after .where preserves row type', () => { + const plan = db.users + .select('id', 'email') + .where((c, fns) => fns.eq(c.id, 1)) + .annotate(cacheAnnotation.apply({ ttl: 60 })) + .build(); + expectTypeOf(plan).toEqualTypeOf>(); + }); + + test('chainable: .annotate() between .select and .limit preserves row type', () => { + const plan = db.users + .select('id') + .annotate(cacheAnnotation.apply({ ttl: 60 })) + .limit(10) + .build(); + expectTypeOf(plan).toEqualTypeOf>(); + }); +}); + +describe('GroupedQuery.annotate (read-typed)', () => { + test('accepts a read-only annotation', () => { + const plan = db.posts + .select('user_id') + .groupBy('user_id') + .annotate(cacheAnnotation.apply({ ttl: 60 })) + .build(); + expectTypeOf(plan).toEqualTypeOf>(); + }); + + test('accepts a both-kind annotation', () => { + const plan = db.posts + .select('user_id') + .groupBy('user_id') + .annotate(otelAnnotation.apply({ traceId: 't' })) + .build(); + expectTypeOf(plan).toEqualTypeOf>(); + }); + + test('rejects a write-only annotation (negative)', () => { + db.posts + .select('user_id') + .groupBy('user_id') + // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' + .annotate(auditAnnotation.apply({ actor: 'system' })); + }); + + test('chainable: .annotate() between .groupBy and .orderBy preserves row type', () => { + const plan = db.posts + .select('user_id') + .groupBy('user_id') + .annotate(cacheAnnotation.apply({ ttl: 60 })) + .orderBy('user_id') + .build(); + expectTypeOf(plan).toEqualTypeOf>(); + }); +}); + +describe('InsertQuery.annotate (write-typed)', () => { + test('accepts a write-only annotation', () => { + db.users.insert({ name: 'Alice' }).annotate(auditAnnotation.apply({ actor: 'system' })); + }); + + test('accepts a both-kind annotation', () => { + db.users.insert({ name: 'Alice' }).annotate(otelAnnotation.apply({ traceId: 't' })); + }); + + test('rejects a read-only annotation (negative)', () => { + // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' + db.users.insert({ name: 'Alice' }).annotate(cacheAnnotation.apply({ ttl: 60 })); + }); + + test('rejects a mix containing a read-only annotation (negative)', () => { + db.users + .insert({ name: 'Alice' }) + // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' + .annotate(auditAnnotation.apply({ actor: 'system' }), cacheAnnotation.apply({ ttl: 60 })); + }); + + test('chainable: .annotate() before .returning preserves the resulting row type', () => { + const plan = db.users + .insert({ name: 'Alice' }) + .annotate(auditAnnotation.apply({ actor: 'system' })) + .returning('id', 'name') + .build(); + expectTypeOf(plan).toEqualTypeOf>(); + }); +}); + +describe('UpdateQuery.annotate (write-typed)', () => { + test('accepts a write-only annotation', () => { + db.users + .update({ name: 'Alice' }) + .where((f, fns) => fns.eq(f.id, 1)) + .annotate(auditAnnotation.apply({ actor: 'system' })); + }); + + test('accepts a both-kind annotation', () => { + db.users + .update({ name: 'Alice' }) + .where((f, fns) => fns.eq(f.id, 1)) + .annotate(otelAnnotation.apply({ traceId: 't' })); + }); + + test('rejects a read-only annotation (negative)', () => { + db.users + .update({ name: 'Alice' }) + .where((f, fns) => fns.eq(f.id, 1)) + // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' + .annotate(cacheAnnotation.apply({ ttl: 60 })); + }); + + test('chainable: .annotate() before .returning preserves the resulting row type', () => { + const plan = db.users + .update({ name: 'Alice' }) + .where((f, fns) => fns.eq(f.id, 1)) + .annotate(auditAnnotation.apply({ actor: 'system' })) + .returning('id', 'name') + .build(); + expectTypeOf(plan).toEqualTypeOf>(); + }); +}); + +describe('DeleteQuery.annotate (write-typed)', () => { + test('accepts a write-only annotation', () => { + db.users + .delete() + .where((f, fns) => fns.eq(f.id, 1)) + .annotate(auditAnnotation.apply({ actor: 'system' })); + }); + + test('accepts a both-kind annotation', () => { + db.users + .delete() + .where((f, fns) => fns.eq(f.id, 1)) + .annotate(otelAnnotation.apply({ traceId: 't' })); + }); + + test('rejects a read-only annotation (negative)', () => { + db.users + .delete() + .where((f, fns) => fns.eq(f.id, 1)) + // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' + .annotate(cacheAnnotation.apply({ ttl: 60 })); + }); + + test('chainable: .annotate() before .returning preserves the resulting row type', () => { + const plan = db.users + .delete() + .where((f, fns) => fns.eq(f.id, 1)) + .annotate(auditAnnotation.apply({ actor: 'system' })) + .returning('id') + .build(); + expectTypeOf(plan).toEqualTypeOf>(); + }); +}); diff --git a/packages/2-sql/4-lanes/sql-builder/test/runtime/annotate.test.ts b/packages/2-sql/4-lanes/sql-builder/test/runtime/annotate.test.ts new file mode 100644 index 0000000000..ce2ac6e1c5 --- /dev/null +++ b/packages/2-sql/4-lanes/sql-builder/test/runtime/annotate.test.ts @@ -0,0 +1,350 @@ +import { emptyCodecLookup } from '@prisma-next/framework-components/codec'; +import { defineAnnotation } from '@prisma-next/framework-components/runtime'; +import { validateContract } from '@prisma-next/sql-contract/validate'; +import type { ExecutionContext } from '@prisma-next/sql-relational-core/query-lane-context'; +import { describe, expect, it } from 'vitest'; +import { sql } from '../../src/runtime/sql'; +import { contract as contractJson } from '../fixtures/contract'; +import type { Contract } from '../fixtures/generated/contract'; + +const sqlContract = validateContract(contractJson, emptyCodecLookup); + +const stubBase = { + operations: {}, + codecs: {}, + queryOperations: { entries: () => ({}) }, + types: {}, + applyMutationDefaults: () => [], +}; + +function db() { + return sql({ + context: { ...stubBase, contract: sqlContract } as unknown as ExecutionContext< + typeof sqlContract + >, + }); +} + +const cacheAnnotation = defineAnnotation<{ ttl: number; skip?: boolean }, 'read'>({ + namespace: 'cache', + applicableTo: ['read'], +}); + +const otelAnnotation = defineAnnotation<{ traceId: string }, 'read' | 'write'>({ + namespace: 'otel', + applicableTo: ['read', 'write'], +}); + +const auditAnnotation = defineAnnotation<{ actor: string }, 'write'>({ + namespace: 'audit', + applicableTo: ['write'], +}); + +describe('SelectQuery.annotate', () => { + it('writes the applied annotation under its namespace on plan.meta.annotations', () => { + const plan = db() + .users.select('id') + .annotate(cacheAnnotation.apply({ ttl: 60 })) + .build(); + + const stored = plan.meta.annotations?.['cache']; + expect(stored).toMatchObject({ + __annotation: true, + namespace: 'cache', + value: { ttl: 60 }, + }); + }); + + it('round-trips through the typed handle.read accessor', () => { + const plan = db() + .users.select('id') + .annotate(cacheAnnotation.apply({ ttl: 60 })) + .build(); + + expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); + }); + + it('returns undefined from handle.read on a plan that was never annotated', () => { + const plan = db().users.select('id').build(); + expect(cacheAnnotation.read(plan)).toBeUndefined(); + }); + + it('multiple annotations under different namespaces coexist', () => { + const plan = db() + .users.select('id') + .annotate(cacheAnnotation.apply({ ttl: 60 })) + .annotate(otelAnnotation.apply({ traceId: 't-1' })) + .build(); + + expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); + expect(otelAnnotation.read(plan)).toEqual({ traceId: 't-1' }); + }); + + it('multiple annotations passed in a single call coexist', () => { + const plan = db() + .users.select('id') + .annotate(cacheAnnotation.apply({ ttl: 60 }), otelAnnotation.apply({ traceId: 't-1' })) + .build(); + + expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); + expect(otelAnnotation.read(plan)).toEqual({ traceId: 't-1' }); + }); + + it('duplicate namespace last-write-wins', () => { + const plan = db() + .users.select('id') + .annotate(cacheAnnotation.apply({ ttl: 60 })) + .annotate(cacheAnnotation.apply({ ttl: 120 })) + .build(); + + expect(cacheAnnotation.read(plan)).toEqual({ ttl: 120 }); + }); + + it('annotate does not mutate the original builder (immutability)', () => { + const base = db().users.select('id'); + const annotated = base.annotate(cacheAnnotation.apply({ ttl: 60 })); + const basePlan = base.build(); + const annotatedPlan = annotated.build(); + + expect(cacheAnnotation.read(basePlan)).toBeUndefined(); + expect(cacheAnnotation.read(annotatedPlan)).toEqual({ ttl: 60 }); + }); + + it('chainable in any position: immediately after .select', () => { + const plan = db() + .users.select('id') + .annotate(cacheAnnotation.apply({ ttl: 60 })) + .build(); + + expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); + }); + + it('chainable in any position: between .select and .where', () => { + const plan = db() + .users.select('id') + .annotate(cacheAnnotation.apply({ ttl: 60 })) + .where((f, fns) => fns.eq(f.id, 1)) + .build(); + + expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); + }); + + it('chainable in any position: after .where, before .limit', () => { + const plan = db() + .users.select('id') + .where((f, fns) => fns.eq(f.id, 1)) + .annotate(cacheAnnotation.apply({ ttl: 60 })) + .limit(10) + .build(); + + expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); + }); + + it('annotate does not affect the produced AST shape', () => { + const baseAst = db() + .users.select('id') + .where((f, fns) => fns.eq(f.id, 1)) + .buildAst(); + + const annotatedAst = db() + .users.select('id') + .annotate(cacheAnnotation.apply({ ttl: 60 })) + .where((f, fns) => fns.eq(f.id, 1)) + .annotate(otelAnnotation.apply({ traceId: 't-1' })) + .buildAst(); + + expect(annotatedAst).toEqual(baseAst); + }); + + it('annotate with no arguments is a no-op for user annotations (empty variadic)', () => { + // The framework `codecs` map under the reserved namespace may still + // populate `plan.meta.annotations` — this is independent of user + // annotations. We verify only that no user annotation lands. + const plan = db().users.select('id').annotate().build(); + + expect(cacheAnnotation.read(plan)).toBeUndefined(); + expect(otelAnnotation.read(plan)).toBeUndefined(); + // No user-namespaced keys. + const annotations = plan.meta.annotations ?? {}; + const userKeys = Object.keys(annotations).filter((k) => k !== 'codecs'); + expect(userKeys).toEqual([]); + }); + + it('runtime gate rejects a write-only annotation forced through a cast', () => { + const builder = db().users.select('id') as unknown as { + annotate(annotation: unknown): unknown; + }; + expect(() => builder.annotate(auditAnnotation.apply({ actor: 'system' }))).toThrow( + expect.objectContaining({ + code: 'RUNTIME.ANNOTATION_INAPPLICABLE', + category: 'RUNTIME', + }), + ); + }); +}); + +describe('GroupedQuery.annotate', () => { + it('writes the applied annotation under its namespace on plan.meta.annotations', () => { + const plan = db() + .posts.select('user_id') + .groupBy('user_id') + .annotate(cacheAnnotation.apply({ ttl: 60 })) + .build(); + + expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); + }); + + it('chainable in any position: between .select and .groupBy', () => { + const plan = db() + .posts.select('user_id') + .annotate(cacheAnnotation.apply({ ttl: 60 })) + .groupBy('user_id') + .build(); + + expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); + }); + + it('chainable in any position: after .groupBy, before .having / .orderBy', () => { + const plan = db() + .posts.select('user_id') + .groupBy('user_id') + .annotate(cacheAnnotation.apply({ ttl: 60 })) + .orderBy('user_id') + .build(); + + expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); + }); + + it('runtime gate rejects a write-only annotation forced through a cast', () => { + const builder = db().posts.select('user_id').groupBy('user_id') as unknown as { + annotate(annotation: unknown): unknown; + }; + expect(() => builder.annotate(auditAnnotation.apply({ actor: 'system' }))).toThrow( + expect.objectContaining({ code: 'RUNTIME.ANNOTATION_INAPPLICABLE' }), + ); + }); +}); + +describe('InsertQuery.annotate', () => { + it('writes the applied annotation under its namespace on plan.meta.annotations', () => { + const plan = db() + .users.insert({ name: 'Alice' }) + .annotate(auditAnnotation.apply({ actor: 'system' })) + .build(); + + expect(auditAnnotation.read(plan)).toEqual({ actor: 'system' }); + }); + + it('accepts both-kind annotations', () => { + const plan = db() + .users.insert({ name: 'Alice' }) + .annotate(otelAnnotation.apply({ traceId: 't-1' })) + .build(); + + expect(otelAnnotation.read(plan)).toEqual({ traceId: 't-1' }); + }); + + it('survives across .returning(...) chaining', () => { + const plan = db() + .users.insert({ name: 'Alice' }) + .annotate(auditAnnotation.apply({ actor: 'system' })) + .returning('id', 'name') + .build(); + + expect(auditAnnotation.read(plan)).toEqual({ actor: 'system' }); + }); + + it('runtime gate rejects a read-only annotation forced through a cast', () => { + const builder = db().users.insert({ name: 'Alice' }) as unknown as { + annotate(annotation: unknown): unknown; + }; + expect(() => builder.annotate(cacheAnnotation.apply({ ttl: 60 }))).toThrow( + expect.objectContaining({ code: 'RUNTIME.ANNOTATION_INAPPLICABLE' }), + ); + }); +}); + +describe('UpdateQuery.annotate', () => { + it('writes the applied annotation under its namespace on plan.meta.annotations', () => { + const plan = db() + .users.update({ name: 'Alice' }) + .where((f, fns) => fns.eq(f.id, 1)) + .annotate(auditAnnotation.apply({ actor: 'system' })) + .build(); + + expect(auditAnnotation.read(plan)).toEqual({ actor: 'system' }); + }); + + it('survives across .where(...) and .returning(...) chaining', () => { + const plan = db() + .users.update({ name: 'Alice' }) + .annotate(auditAnnotation.apply({ actor: 'system' })) + .where((f, fns) => fns.eq(f.id, 1)) + .returning('id', 'name') + .build(); + + expect(auditAnnotation.read(plan)).toEqual({ actor: 'system' }); + }); + + it('runtime gate rejects a read-only annotation forced through a cast', () => { + const builder = db().users.update({ name: 'Alice' }) as unknown as { + annotate(annotation: unknown): unknown; + }; + expect(() => builder.annotate(cacheAnnotation.apply({ ttl: 60 }))).toThrow( + expect.objectContaining({ code: 'RUNTIME.ANNOTATION_INAPPLICABLE' }), + ); + }); +}); + +describe('DeleteQuery.annotate', () => { + it('writes the applied annotation under its namespace on plan.meta.annotations', () => { + const plan = db() + .users.delete() + .where((f, fns) => fns.eq(f.id, 1)) + .annotate(auditAnnotation.apply({ actor: 'system' })) + .build(); + + expect(auditAnnotation.read(plan)).toEqual({ actor: 'system' }); + }); + + it('survives across .where(...) and .returning(...) chaining', () => { + const plan = db() + .users.delete() + .annotate(auditAnnotation.apply({ actor: 'system' })) + .where((f, fns) => fns.eq(f.id, 1)) + .returning('id') + .build(); + + expect(auditAnnotation.read(plan)).toEqual({ actor: 'system' }); + }); + + it('runtime gate rejects a read-only annotation forced through a cast', () => { + const builder = db().users.delete() as unknown as { + annotate(annotation: unknown): unknown; + }; + expect(() => builder.annotate(cacheAnnotation.apply({ ttl: 60 }))).toThrow( + expect.objectContaining({ code: 'RUNTIME.ANNOTATION_INAPPLICABLE' }), + ); + }); +}); + +describe('annotate alongside framework-internal codecs metadata', () => { + // The SQL emitter writes per-alias codec ids under the reserved + // `codecs` namespace key in plan.meta.annotations. User annotations + // must coexist with that without collision. + it('coexists with the framework codecs map under its reserved namespace', () => { + const plan = db() + .users.select('id') + .annotate(cacheAnnotation.apply({ ttl: 60 })) + .build(); + + // User annotation lives under its own namespace. + expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); + // Reserved framework namespace is not affected. + if (plan.meta.annotations?.['codecs'] !== undefined) { + expect(plan.meta.annotations['codecs']).toEqual( + expect.objectContaining({ id: expect.any(String) }), + ); + } + }); +}); From e87f41f55d28f6407796aae3d678f6bc38985ea0 Mon Sep 17 00:00:00 2001 From: Alexey Orlenko Date: Tue, 28 Apr 2026 15:34:46 +0200 Subject: [PATCH 06/21] feat(sql-orm-client): add .annotate() arg to read terminals (all, first) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a variadic annotation argument to the ORM Collection's read terminals. Per the spec, annotations attach via terminal arguments only — there is no chainable .annotate() on Collection (that scope cut keeps the per-terminal kind binding clean and structurally prevents passing read annotations to mutations). Read terminals updated in this commit: all[]>( ...annotations: As & ValidAnnotations<'read', As> ): AsyncIterableResult first[]>( filterOrFirstAnnotation?: filter | AnnotationValue, ...rest: AnnotationValue[] ): Promise The 'As & ValidAnnotations<'read', As>' intersection is load-bearing — see the ValidAnnotations TSDoc on the framework annotation module. TypeScript's variadic-tuple inference with ValidAnnotations alone would silently let inapplicable annotations through; the intersection pins As to the call-site tuple AND requires assignability to the gated form so write-only annotations fail to compile. Plumbing: - CollectionState gains userAnnotations: ReadonlyMap>. Empty on a fresh state; populated transiently by terminal calls just before dispatch. - A private #withAnnotations(annotations, kind, terminalName) helper validates kinds via assertAnnotationsApplicable (the runtime gate fails closed for callers that bypass the type gate via cast / 'any') and returns a #clone with the merged map. - buildOrmQueryPlan accepts an optional userAnnotations parameter and merges into plan.meta.annotations alongside the framework- internal 'codecs' map under its reserved namespace. Reserved keys win over user annotations under the same key (defensive). - compileSelect and compileSelectWithIncludeStrategy thread state.userAnnotations to buildOrmQueryPlan; compileRelationSelect inherits via compileSelect. The first() overload set is extended so a leading argument may be either a filter or an annotation. Disambiguation runs at call time via an internal isAnnotationValue type guard checking the __annotation brand. Empty variadic returns the receiver unchanged. Deferred to a follow-up (captured in projects/.../follow-ups.md): write terminals (create, update, delete, upsert, createCount), count, aggregate. The mergeUserAnnotations helper added in query-plan-meta.ts is the seam those will use (post-wrap rather than threading through each compile fn). The cache middleware (M3) only intercepts reads, so the deferred set is not load-bearing for the April stop condition. Tests land in the next commit (M2.8 + M2.9). Refs: TML-2143 --- .../sql-orm-client/src/collection.ts | 106 +++++++++++++++++- .../sql-orm-client/src/query-plan-meta.ts | 60 +++++++++- .../sql-orm-client/src/query-plan-select.ts | 4 +- .../3-extensions/sql-orm-client/src/types.ts | 10 ++ 4 files changed, 169 insertions(+), 11 deletions(-) diff --git a/packages/3-extensions/sql-orm-client/src/collection.ts b/packages/3-extensions/sql-orm-client/src/collection.ts index 2367ae3523..3569d8729f 100644 --- a/packages/3-extensions/sql-orm-client/src/collection.ts +++ b/packages/3-extensions/sql-orm-client/src/collection.ts @@ -1,5 +1,13 @@ import type { Contract } from '@prisma-next/contract/types'; -import { AsyncIterableResult } from '@prisma-next/framework-components/runtime'; +import type { + AnnotationValue, + OperationKind, + ValidAnnotations, +} from '@prisma-next/framework-components/runtime'; +import { + AsyncIterableResult, + assertAnnotationsApplicable, +} from '@prisma-next/framework-components/runtime'; import type { SqlStorage } from '@prisma-next/sql-contract/types'; import { BinaryExpr, @@ -618,8 +626,23 @@ export class Collection< return this.#clone({ offset: n }); } - all(): AsyncIterableResult { - return this.#dispatch(); + /** + * Read terminal: stream all rows matching the current state. + * + * Accepts an optional variadic of read-typed user annotations. The + * `As & ValidAnnotations<'read', As>` gate rejects write-only + * annotations at the call site; the runtime check fails closed for + * callers that bypass the type gate. Annotations are merged into + * `plan.meta.annotations` at compile time. + */ + all[]>( + ...annotations: As & ValidAnnotations<'read', As> + ): AsyncIterableResult { + return this.#withAnnotations( + annotations as readonly AnnotationValue[], + 'read', + 'all', + ).#dispatch(); } async first(): Promise; @@ -627,18 +650,52 @@ export class Collection< filter: (model: ModelAccessor) => WhereArg, ): Promise; async first(filter: ShorthandWhereFilter): Promise; + async first[]>( + ...annotations: As & ValidAnnotations<'read', As> + ): Promise; + async first[]>( + filter: (model: ModelAccessor) => WhereArg, + ...annotations: As & ValidAnnotations<'read', As> + ): Promise; + async first[]>( + filter: ShorthandWhereFilter, + ...annotations: As & ValidAnnotations<'read', As> + ): Promise; + /** + * Read terminal: return the first matching row, or `null`. + * + * Accepts an optional `filter` (function, shorthand, or annotation), + * followed by an optional variadic of read-typed user annotations. + * The first positional arg is interpreted as a filter only when it is + * a function or a non-`AnnotationValue` shorthand record; an + * `AnnotationValue` first arg is treated as the leading annotation. + */ async first( - filter?: + filterOrFirstAnnotation?: | ((model: ModelAccessor) => WhereArg) - | ShorthandWhereFilter, + | ShorthandWhereFilter + | AnnotationValue, + ...rest: readonly AnnotationValue[] ): Promise { + let filter: + | ((model: ModelAccessor) => WhereArg) + | ShorthandWhereFilter + | undefined; + let annotations: readonly AnnotationValue[]; + if (isAnnotationValue(filterOrFirstAnnotation)) { + filter = undefined; + annotations = [filterOrFirstAnnotation, ...rest]; + } else { + filter = filterOrFirstAnnotation; + annotations = rest; + } const scoped = filter === undefined ? this : typeof filter === 'function' ? this.where(filter) : this.where(filter); - const limited = scoped.take(1); + const limited = scoped.take(1).#withAnnotations(annotations, 'read', 'first'); const rows = await limited.#dispatch().toArray(); return rows[0] ?? null; } @@ -1293,4 +1350,41 @@ export class Collection< modelName: this.modelName, }); } + + /** + * Validates the annotation kinds against the terminal's operation + * kind and returns a clone whose `state.userAnnotations` carries the + * accumulated map. The runtime gate fails closed via + * `assertAnnotationsApplicable` when callers bypass the type gate + * through casts or `any`. + * + * Empty `annotations` returns the receiver unchanged. + */ + #withAnnotations( + annotations: readonly AnnotationValue[], + kind: OperationKind, + terminalName: string, + ): this { + if (annotations.length === 0) { + return this; + } + assertAnnotationsApplicable(annotations, kind, terminalName); + const next = new Map(this.state.userAnnotations); + for (const annotation of annotations) { + next.set(annotation.namespace, annotation); + } + return this.#clone({ userAnnotations: next }) as this; + } +} + +/** + * Type guard identifying branded `AnnotationValue` objects. Used by + * terminals like `first()` whose leading argument may be either a + * filter or an annotation. + */ +function isAnnotationValue(value: unknown): value is AnnotationValue { + if (value === null || typeof value !== 'object') { + return false; + } + return (value as { readonly __annotation?: unknown }).__annotation === true; } diff --git a/packages/3-extensions/sql-orm-client/src/query-plan-meta.ts b/packages/3-extensions/sql-orm-client/src/query-plan-meta.ts index e0bce45bf9..3ebddb6d64 100644 --- a/packages/3-extensions/sql-orm-client/src/query-plan-meta.ts +++ b/packages/3-extensions/sql-orm-client/src/query-plan-meta.ts @@ -1,7 +1,9 @@ import type { Contract, PlanMeta } from '@prisma-next/contract/types'; +import type { AnnotationValue, OperationKind } from '@prisma-next/framework-components/runtime'; import type { SqlStorage } from '@prisma-next/sql-contract/types'; import { type AnyQueryAst, collectOrderedParamRefs } from '@prisma-next/sql-relational-core/ast'; import type { SqlQueryPlan } from '@prisma-next/sql-relational-core/plan'; +import { ifDefined } from '@prisma-next/utils/defined'; export function deriveParamsFromAst(ast: AnyQueryAst): { params: unknown[]; @@ -19,12 +21,20 @@ export function resolveTableColumns(contract: Contract, tableName: s return Object.keys(table.columns); } -export function buildOrmPlanMeta(contract: Contract): PlanMeta { +export function buildOrmPlanMeta( + contract: Contract, + annotations?: ReadonlyMap>, +): PlanMeta { + const annotationRecord = + annotations !== undefined && annotations.size > 0 + ? Object.freeze(Object.fromEntries(annotations)) + : undefined; return { target: contract.target, targetFamily: contract.targetFamily, storageHash: contract.storage.storageHash, - ...(contract.profileHash !== undefined ? { profileHash: contract.profileHash } : {}), + ...ifDefined('profileHash', contract.profileHash), + ...ifDefined('annotations', annotationRecord), lane: 'orm-client', }; } @@ -33,10 +43,54 @@ export function buildOrmQueryPlan( contract: Contract, ast: AnyQueryAst, params: readonly unknown[], + annotations?: ReadonlyMap>, ): SqlQueryPlan { return Object.freeze({ ast, params: [...params], - meta: buildOrmPlanMeta(contract), + meta: buildOrmPlanMeta(contract, annotations), + }); +} + +/** + * Merges user annotations into an existing `SqlQueryPlan`'s + * `meta.annotations` and returns a new frozen plan. + * + * Used by the ORM dispatch path to attach terminal-call annotations to + * plans produced by mutation compile functions (which don't take user + * annotations as parameters). Reads compile through `compileSelect`- + * family functions that pass `state.userAnnotations` directly to + * `buildOrmQueryPlan`; this helper is the alternate path for write + * terminals where user annotations arrive at the call site, not via + * state. + * + * Returns the input plan unchanged when `userAnnotations` is undefined + * or empty. Reserved framework namespaces (`codecs`, `limit`) on the + * input plan win over user annotations under the same key — see the + * reserved-namespace policy on `defineAnnotation`. + */ +export function mergeUserAnnotations( + plan: SqlQueryPlan, + userAnnotations: ReadonlyMap> | undefined, +): SqlQueryPlan { + if (userAnnotations === undefined || userAnnotations.size === 0) { + return plan; + } + const userEntries: Record> = {}; + for (const [namespace, value] of userAnnotations) { + userEntries[namespace] = value; + } + // User annotations go first so framework-reserved keys on the existing + // plan (codecs, limit) override any user-supplied collision. + const mergedAnnotations = Object.freeze({ + ...userEntries, + ...(plan.meta.annotations ?? {}), + }); + return Object.freeze({ + ...plan, + meta: Object.freeze({ + ...plan.meta, + annotations: mergedAnnotations, + }), }); } diff --git a/packages/3-extensions/sql-orm-client/src/query-plan-select.ts b/packages/3-extensions/sql-orm-client/src/query-plan-select.ts index 2bec59a4e3..fb33abdf23 100644 --- a/packages/3-extensions/sql-orm-client/src/query-plan-select.ts +++ b/packages/3-extensions/sql-orm-client/src/query-plan-select.ts @@ -448,7 +448,7 @@ export function compileSelect( ); const { params } = deriveParamsFromAst(ast); - return buildOrmQueryPlan(contract, ast, params); + return buildOrmQueryPlan(contract, ast, params, state.userAnnotations); } export function compileRelationSelect( @@ -524,5 +524,5 @@ export function compileSelectWithIncludeStrategy( ); const { params } = deriveParamsFromAst(ast); - return buildOrmQueryPlan(contract, ast, params); + return buildOrmQueryPlan(contract, ast, params, state.userAnnotations); } diff --git a/packages/3-extensions/sql-orm-client/src/types.ts b/packages/3-extensions/sql-orm-client/src/types.ts index 5c0e553b14..5b88b07a8e 100644 --- a/packages/3-extensions/sql-orm-client/src/types.ts +++ b/packages/3-extensions/sql-orm-client/src/types.ts @@ -1,4 +1,5 @@ import type { Contract } from '@prisma-next/contract/types'; +import type { AnnotationValue, OperationKind } from '@prisma-next/framework-components/runtime'; import type { ExtractCodecTypes, ExtractQueryOperationTypes, @@ -79,6 +80,14 @@ export interface CollectionState { readonly limit: number | undefined; readonly offset: number | undefined; readonly variantName: string | undefined; + /** + * User annotations attached to this query at terminal-call time. + * Populated transiently by terminal methods (`first`, `all`, `create`, + * etc.) just before dispatch — `Collection` itself has no chainable + * `.annotate()`. Stored as a `Map` so + * duplicate namespaces last-write-win. Empty on a fresh state. + */ + readonly userAnnotations: ReadonlyMap>; } export function emptyState(): CollectionState { @@ -93,6 +102,7 @@ export function emptyState(): CollectionState { limit: undefined, offset: undefined, variantName: undefined, + userAnnotations: new Map(), }; } From 07b715d66a7e704caf62c6ea092a69f464ea6b03 Mon Sep 17 00:00:00 2001 From: Alexey Orlenko Date: Tue, 28 Apr 2026 15:34:46 +0200 Subject: [PATCH 07/21] test(sql-orm-client): cover .annotate() arg on read terminals (all, first) Two test files for the ORM Collection terminal annotations from the previous commit: annotations.test.ts (14 runtime tests): - Collection.all (8 tests): writes the applied annotation; round- trips via handle.read; absent annotation reads as undefined; multiple namespaces coexist; zero annotations is a no-op for user annotations; survives across .where / .take chaining; runtime gate rejects write-only via cast. - Collection.first (5 tests): writes annotation with no filter; with function filter; with shorthand filter; disambiguates a leading AnnotationValue from a shorthand (the leading arg is treated as the annotation, not as a where clause); runtime gate. - Reserved-namespace coexistence (1 test): user annotations live alongside the framework-internal 'codecs' map without collision. annotations.types.test-d.ts (19 type-level tests): - Collection.all: accepts read-only, both-kind, multi-compatible and empty variadic; rejects write-only and write-bearing mixes (negative); return type is not widened. - Collection.first: accepts read-only with no filter, after function filter, after shorthand filter; multi-compatible; accepts no-filter / no-annotation; accepts function filter only; rejects write-only with no filter and after shorthand (negative); return type is Promise. - Collection has no chainable .annotate (intentional scope cut). - Annotation handle types preserved through the lane. The negative tests for mixed-annotation arguments use a 'biome-ignore format: keep on one line so @ts-expect-error attaches to the call' directive: TypeScript reports the type error on the call line, but biome's line-length rule would split the call across multiple lines, breaking the @ts-expect-error attachment. The single-arg negatives don't need this. Refs: TML-2143 --- .../sql-orm-client/test/annotations.test.ts | 200 ++++++++++++++++++ .../test/annotations.types.test-d.ts | 161 ++++++++++++++ 2 files changed, 361 insertions(+) create mode 100644 packages/3-extensions/sql-orm-client/test/annotations.test.ts create mode 100644 packages/3-extensions/sql-orm-client/test/annotations.types.test-d.ts diff --git a/packages/3-extensions/sql-orm-client/test/annotations.test.ts b/packages/3-extensions/sql-orm-client/test/annotations.test.ts new file mode 100644 index 0000000000..1ff9d09f2d --- /dev/null +++ b/packages/3-extensions/sql-orm-client/test/annotations.test.ts @@ -0,0 +1,200 @@ +import { defineAnnotation } from '@prisma-next/framework-components/runtime'; +import { describe, expect, it } from 'vitest'; +import { createCollection } from './collection-fixtures'; + +const cacheAnnotation = defineAnnotation<{ ttl: number; skip?: boolean }, 'read'>({ + namespace: 'cache', + applicableTo: ['read'], +}); + +const otelAnnotation = defineAnnotation<{ traceId: string }, 'read' | 'write'>({ + namespace: 'otel', + applicableTo: ['read', 'write'], +}); + +const auditAnnotation = defineAnnotation<{ actor: string }, 'write'>({ + namespace: 'audit', + applicableTo: ['write'], +}); + +describe('Collection.all annotations', () => { + it('writes the applied annotation under its namespace on the executed plan', async () => { + const { collection, runtime } = createCollection(); + runtime.setNextResults([[]]); + + await collection.all(cacheAnnotation.apply({ ttl: 60 })).toArray(); + + expect(runtime.executions).toHaveLength(1); + const stored = runtime.executions[0]!.plan.meta.annotations?.['cache']; + expect(stored).toMatchObject({ + __annotation: true, + namespace: 'cache', + value: { ttl: 60 }, + }); + }); + + it('round-trips through the typed handle.read accessor', async () => { + const { collection, runtime } = createCollection(); + runtime.setNextResults([[]]); + + await collection.all(cacheAnnotation.apply({ ttl: 60 })).toArray(); + + const plan = runtime.executions[0]!.plan; + expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); + }); + + it('returns undefined from handle.read on a plan that was never annotated', async () => { + const { collection, runtime } = createCollection(); + runtime.setNextResults([[]]); + + await collection.all().toArray(); + + const plan = runtime.executions[0]!.plan; + expect(cacheAnnotation.read(plan)).toBeUndefined(); + }); + + it('multiple annotations under different namespaces coexist', async () => { + const { collection, runtime } = createCollection(); + runtime.setNextResults([[]]); + + await collection + .all(cacheAnnotation.apply({ ttl: 60 }), otelAnnotation.apply({ traceId: 't-1' })) + .toArray(); + + const plan = runtime.executions[0]!.plan; + expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); + expect(otelAnnotation.read(plan)).toEqual({ traceId: 't-1' }); + }); + + it('zero annotations is a no-op for user annotations (empty variadic)', async () => { + const { collection, runtime } = createCollection(); + runtime.setNextResults([[]]); + + await collection.all().toArray(); + + const plan = runtime.executions[0]!.plan; + expect(cacheAnnotation.read(plan)).toBeUndefined(); + expect(otelAnnotation.read(plan)).toBeUndefined(); + }); + + it('annotations survive across .where() and .take() chaining', async () => { + const { collection, runtime } = createCollection(); + runtime.setNextResults([[]]); + + await collection + .where((user) => user.name.eq('Alice')) + .take(10) + .all(cacheAnnotation.apply({ ttl: 60 })) + .toArray(); + + const plan = runtime.executions[0]!.plan; + expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); + }); + + it('runtime gate rejects a write-only annotation forced through a cast', () => { + const { collection } = createCollection(); + const allFn = collection.all as unknown as (annotation: unknown) => unknown; + expect(() => allFn.call(collection, auditAnnotation.apply({ actor: 'system' }))).toThrow( + expect.objectContaining({ + code: 'RUNTIME.ANNOTATION_INAPPLICABLE', + category: 'RUNTIME', + }), + ); + }); +}); + +describe('Collection.first annotations', () => { + it('writes the applied annotation under its namespace on the executed plan (no filter)', async () => { + const { collection, runtime } = createCollection(); + runtime.setNextResults([[]]); + + await collection.first(cacheAnnotation.apply({ ttl: 60 })); + + expect(runtime.executions).toHaveLength(1); + const plan = runtime.executions[0]!.plan; + expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); + }); + + it('writes the applied annotation when invoked with a function filter', async () => { + const { collection, runtime } = createCollection(); + runtime.setNextResults([[]]); + + await collection.first((user) => user.name.eq('Alice'), cacheAnnotation.apply({ ttl: 60 })); + + const plan = runtime.executions[0]!.plan; + expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); + }); + + it('writes the applied annotation when invoked with a shorthand filter', async () => { + const { collection, runtime } = createCollection(); + runtime.setNextResults([[]]); + + await collection.first({ name: 'Alice' }, cacheAnnotation.apply({ ttl: 60 })); + + const plan = runtime.executions[0]!.plan; + expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); + }); + + it('disambiguates a leading AnnotationValue from a shorthand filter', async () => { + const { collection, runtime } = createCollection(); + runtime.setNextResults([[]]); + + // The leading argument is an AnnotationValue, not a filter. The terminal + // must treat it as the leading annotation, not as a where shorthand. + await collection.first(cacheAnnotation.apply({ ttl: 60 })); + + expect(runtime.executions).toHaveLength(1); + const plan = runtime.executions[0]!.plan; + expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); + // No filter should have been derived from the annotation. + // (Verify indirectly: the executed plan should not contain a where + // clause derived from the annotation's payload.) + const annotationKeys = Object.keys(plan.meta.annotations ?? {}); + expect(annotationKeys).toContain('cache'); + }); + + it('multiple annotations coexist under different namespaces', async () => { + const { collection, runtime } = createCollection(); + runtime.setNextResults([[]]); + + await collection.first( + (user) => user.name.eq('Alice'), + cacheAnnotation.apply({ ttl: 60 }), + otelAnnotation.apply({ traceId: 't-1' }), + ); + + const plan = runtime.executions[0]!.plan; + expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); + expect(otelAnnotation.read(plan)).toEqual({ traceId: 't-1' }); + }); + + it('runtime gate rejects a write-only annotation forced through a cast', async () => { + const { collection } = createCollection(); + const firstFn = collection.first as unknown as (annotation: unknown) => Promise; + await expect( + firstFn.call(collection, auditAnnotation.apply({ actor: 'system' })), + ).rejects.toMatchObject({ + code: 'RUNTIME.ANNOTATION_INAPPLICABLE', + category: 'RUNTIME', + }); + }); +}); + +describe('Collection annotations alongside framework-internal codecs metadata', () => { + it('user annotations coexist with the framework-internal codecs map under its reserved namespace', async () => { + const { collection, runtime } = createCollection(); + runtime.setNextResults([[]]); + + await collection.all(cacheAnnotation.apply({ ttl: 60 })).toArray(); + + const plan = runtime.executions[0]!.plan; + // User annotation lives under its own namespace. + expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); + // Reserved framework namespace, when emitted, lives under 'codecs' and + // is not a branded AnnotationValue (so handle.read with namespace + // 'codecs' would return undefined; we check the raw shape here). + if (plan.meta.annotations?.['codecs'] !== undefined) { + expect(plan.meta.annotations['codecs']).toEqual(expect.any(Object)); + } + }); +}); diff --git a/packages/3-extensions/sql-orm-client/test/annotations.types.test-d.ts b/packages/3-extensions/sql-orm-client/test/annotations.types.test-d.ts new file mode 100644 index 0000000000..df6c9e73a5 --- /dev/null +++ b/packages/3-extensions/sql-orm-client/test/annotations.types.test-d.ts @@ -0,0 +1,161 @@ +import { + type AnnotationValue, + defineAnnotation, + type OperationKind, +} from '@prisma-next/framework-components/runtime'; +import { describe, expectTypeOf, test } from 'vitest'; +import type { Collection } from '../src/collection'; +import type { TestContract } from './helpers'; + +/** + * Type-level tests for the ORM `Collection` terminal annotations. + * + * Verifies: + * - Read terminals (`all`, `first`) accept read-typed and both-kind + * annotations and reject write-only ones via the + * `As & ValidAnnotations<'read', As>` gate. + * - The variadic position does not widen the terminal's return type. + * - `first(filter, ...annotations)` and `first(...annotations)` both + * typecheck (the leading argument disambiguates between filter and + * annotation). + */ + +declare const userCollection: Collection; + +const cacheAnnotation = defineAnnotation<{ ttl: number; skip?: boolean }, 'read'>({ + namespace: 'cache', + applicableTo: ['read'], +}); + +const auditAnnotation = defineAnnotation<{ actor: string }, 'write'>({ + namespace: 'audit', + applicableTo: ['write'], +}); + +const otelAnnotation = defineAnnotation<{ traceId: string }, 'read' | 'write'>({ + namespace: 'otel', + applicableTo: ['read', 'write'], +}); + +describe('Collection.all (read-typed)', () => { + test('accepts a read-only annotation', () => { + userCollection.all(cacheAnnotation.apply({ ttl: 60 })); + }); + + test('accepts a both-kind annotation', () => { + userCollection.all(otelAnnotation.apply({ traceId: 't' })); + }); + + test('accepts multiple compatible annotations in a single call', () => { + userCollection.all(cacheAnnotation.apply({ ttl: 60 }), otelAnnotation.apply({ traceId: 't' })); + }); + + test('accepts zero annotations (empty variadic)', () => { + userCollection.all(); + }); + + test('rejects a write-only annotation (negative)', () => { + // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' + userCollection.all(auditAnnotation.apply({ actor: 'system' })); + }); + + test('rejects a mix containing a write-only annotation (negative)', () => { + // biome-ignore format: keep on one line so @ts-expect-error attaches to the call + // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' + userCollection.all(cacheAnnotation.apply({ ttl: 60 }), auditAnnotation.apply({ actor: 'system' })); + }); + + test('the return type is not widened by the variadic argument', () => { + const result = userCollection.all(cacheAnnotation.apply({ ttl: 60 })); + // The return type is AsyncIterableResult regardless of annotations. + expectTypeOf(result).toHaveProperty('toArray'); + expectTypeOf(result.toArray).returns.toMatchTypeOf>(); + }); +}); + +describe('Collection.first (read-typed)', () => { + test('accepts a read-only annotation with no filter', () => { + userCollection.first(cacheAnnotation.apply({ ttl: 60 })); + }); + + test('accepts a read-only annotation after a function filter', () => { + userCollection.first((user) => user.name.eq('Alice'), cacheAnnotation.apply({ ttl: 60 })); + }); + + test('accepts a read-only annotation after a shorthand filter', () => { + userCollection.first({ name: 'Alice' }, cacheAnnotation.apply({ ttl: 60 })); + }); + + test('accepts multiple compatible annotations after a filter', () => { + userCollection.first( + (user) => user.name.eq('Alice'), + cacheAnnotation.apply({ ttl: 60 }), + otelAnnotation.apply({ traceId: 't' }), + ); + }); + + test('accepts zero annotations (empty variadic, no filter)', () => { + userCollection.first(); + }); + + test('accepts a function filter without annotations', () => { + userCollection.first((user) => user.name.eq('Alice')); + }); + + test('rejects a write-only annotation (negative)', () => { + // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' + userCollection.first(auditAnnotation.apply({ actor: 'system' })); + }); + + test('rejects a write-only annotation after a shorthand filter (negative)', () => { + // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' + userCollection.first({ name: 'Alice' }, auditAnnotation.apply({ actor: 'system' })); + }); + + test('the return type is Promise', () => { + const result = userCollection.first(cacheAnnotation.apply({ ttl: 60 })); + expectTypeOf(result).resolves.toMatchTypeOf | null>(); + }); +}); + +describe('Collection has no chainable .annotate (intentional scope cut)', () => { + // Annotations attach via terminal arguments only — there is no + // chainable `.annotate(...)` on Collection. This is the spec OQ 1 + // resolution: the applicability gate at the terminal makes a + // chainable form fight the per-terminal kind binding. + test('Collection does not expose an annotate method', () => { + type Keys = keyof Collection; + type HasAnnotate = 'annotate' extends Keys ? true : false; + expectTypeOf().toEqualTypeOf(); + }); +}); + +describe('annotation handle types are preserved through the lane', () => { + // The handle's payload type survives the gate — this is the same + // property exercised in the framework-components type-d tests, but + // verified here at the ORM lane to ensure no widening happens + // through the Collection.all/first signature. + test('cacheAnnotation.apply is assignable through to the terminal', () => { + const value = cacheAnnotation.apply({ ttl: 60 }); + expectTypeOf(value).toMatchTypeOf>(); + userCollection.all(value); + }); + + test('an inferred annotation tuple preserves per-element typing', () => { + function passthrough[]>( + ...annotations: As + ): As { + return annotations; + } + const tuple = passthrough( + cacheAnnotation.apply({ ttl: 60 }), + otelAnnotation.apply({ traceId: 't' }), + ); + expectTypeOf(tuple).toMatchTypeOf< + readonly [ + AnnotationValue<{ ttl: number; skip?: boolean }, 'read'>, + AnnotationValue<{ traceId: string }, 'read' | 'write'>, + ] + >(); + }); +}); From 93a44f1868b1297b9c1f8cd94620d3e36c87c70d Mon Sep 17 00:00:00 2001 From: Alexey Orlenko Date: Tue, 28 Apr 2026 16:18:27 +0200 Subject: [PATCH 08/21] feat(sql-orm-client): add .annotate() arg to write terminals (create, update, delete, upsert) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a variadic write-typed annotation argument to the ORM Collection's write terminals, mirroring M2.5 for read terminals. Annotations attach via terminal arguments only — no chainable .annotate() on Collection. Write terminals updated: create(data, ...annotations: As & ValidAnnotations<'write', As>): Promise createAll(data, ...annotations: As & ValidAnnotations<'write', As>): AsyncIterableResult createCount(data, ...annotations: As & ValidAnnotations<'write', As>): Promise update(data, ...annotations: As & ValidAnnotations<'write', As>): Promise updateAll(data, ...annotations: As & ValidAnnotations<'write', As>): AsyncIterableResult updateCount(data, ...annotations: As & ValidAnnotations<'write', As>): Promise delete(...annotations: As & ValidAnnotations<'write', As>): Promise deleteAll(...annotations: As & ValidAnnotations<'write', As>): AsyncIterableResult deleteCount(...annotations: As & ValidAnnotations<'write', As>): Promise upsert(input, ...annotations: As & ValidAnnotations<'write', As>): Promise The 'As & ValidAnnotations<'write', As>' intersection is load-bearing (see ValidAnnotations TSDoc) — without it, TypeScript's variadic-tuple inference would silently let read-only annotations through. Plumbing: - A new private helper #buildAnnotationsMap(annotations, kind, terminalName) validates kinds via assertAnnotationsApplicable and returns a ReadonlyMap ready for mergeUserAnnotations. Returns undefined for an empty variadic so callers can skip the rewrap entirely. Parameterized over OperationKind so it's also usable by the aggregate read terminal in the next commit. - Each write terminal calls mergeUserAnnotations(compiled, annotationsMap) on the compiled mutation plan(s) before dispatch. For terminals that produce multiple plans (createAll's compileInsertReturningSplit, createCount's compileInsertCountSplit), the map applies to every plan in the array. - update() and delete() preserve their this-typed receivers (they use TypeScript's 'this: State[hasWhere] extends true ? Collection<...> : never' trick to gate at the type level). The receiver is narrowed via cast just once inside the method body. - For terminals that issue both a 'matching' read and a write statement (updateCount, deleteCount), annotations attach only to the write — the matching read is internal and not user-facing. Two paths intentionally do not yet thread annotations into their constituent SQL statements: 1. Nested-mutation paths (executeNestedCreateMutation, executeNestedUpdateMutation): the operation runs as a graph of internal queries via withMutationScope. Annotations apply to the logical create()/update() call and the runtime gate fires at the terminal, but the per-statement mutations don't see them. 2. MTI variant create paths (#executeMtiCreate): same shape — a multi-statement transaction. Both are documented as follow-ups in projects/middleware-intercept-and-cache/follow-ups.md. Neither blocks the April stop condition: the cache middleware only intercepts reads (cacheAnnotation declares applicableTo: ['read']), and the type-level gate already prevents write-only annotations from reaching reads. Tests for these terminals land in the next commit alongside aggregate terminal tests. Refs: TML-2143 --- .../sql-orm-client/src/collection.ts | 282 ++++++++++++++---- .../sql-orm-client/src/query-plan.ts | 1 + 2 files changed, 231 insertions(+), 52 deletions(-) diff --git a/packages/3-extensions/sql-orm-client/src/collection.ts b/packages/3-extensions/sql-orm-client/src/collection.ts index 3569d8729f..00e510a87f 100644 --- a/packages/3-extensions/sql-orm-client/src/collection.ts +++ b/packages/3-extensions/sql-orm-client/src/collection.ts @@ -91,6 +91,7 @@ import { compileUpdateCount, compileUpdateReturning, compileUpsertReturning, + mergeUserAnnotations, } from './query-plan'; import { type AggregateBuilder, @@ -728,14 +729,37 @@ export class Collection< return normalizeAggregateResult(aggregateSpec, rows[0] ?? {}); } - async create(data: ResolvedCreateInput): Promise; - async create(data: MutationCreateInputWithRelations): Promise; + async create[]>( + data: ResolvedCreateInput, + ...annotations: As & ValidAnnotations<'write', As> + ): Promise; + async create[]>( + data: MutationCreateInputWithRelations, + ...annotations: As & ValidAnnotations<'write', As> + ): Promise; + /** + * Write terminal: insert one row and return it. + * + * Accepts an optional variadic of write-typed user annotations after + * the input. The `As & ValidAnnotations<'write', As>` gate rejects + * read-only annotations at the call site; the runtime check fails + * closed for callers that bypass the type gate. Annotations are + * merged into the compiled mutation plan's `meta.annotations`. + * + * Note: when the input contains nested-mutation callbacks, the + * operation is executed as a graph of internal queries via + * `withMutationScope`. In that path, annotations apply to the + * logical `create()` call but do not currently flow into each + * constituent SQL statement — see `projects/middleware-intercept-and-cache/follow-ups.md`. + */ async create( data: | ResolvedCreateInput | MutationCreateInputWithRelations, + ...annotations: readonly AnnotationValue[] ): Promise { assertReturningCapability(this.contract, 'create()'); + const annotationsMap = this.#buildAnnotationsMap(annotations, 'write', 'create'); if ( hasNestedMutationCallbacks(this.contract, this.modelName, data as Record) @@ -755,9 +779,10 @@ export class Collection< return reloaded; } - const rows = await this.createAll([ - data as ResolvedCreateInput, - ]); + const rows = await this.#createAllWithAnnotations( + [data as ResolvedCreateInput], + annotationsMap, + ); const created = rows[0]; if (created) { return created; @@ -766,8 +791,23 @@ export class Collection< throw new Error(`create() for model "${this.modelName}" did not return a row`); } - createAll( + createAll[]>( + data: readonly ResolvedCreateInput[], + ...annotations: As & ValidAnnotations<'write', As> + ): AsyncIterableResult { + return this.#createAllWithAnnotations( + data, + this.#buildAnnotationsMap( + annotations as readonly AnnotationValue[], + 'write', + 'createAll', + ), + ); + } + + #createAllWithAnnotations( data: readonly ResolvedCreateInput[], + annotationsMap: ReadonlyMap> | undefined, ): AsyncIterableResult { if (data.length === 0) { const generator = async function* (): AsyncGenerator {}; @@ -795,7 +835,7 @@ export class Collection< this.tableName, mappedRows, selectedForInsert, - ); + ).map((plan) => mergeUserAnnotations(plan, annotationsMap)); return dispatchSplitMutationRows({ contract: this.contract, runtime: this.ctx.runtime, @@ -807,11 +847,9 @@ export class Collection< }); } - const compiled = compileInsertReturning( - this.contract, - this.tableName, - mappedRows, - selectedForInsert, + const compiled = mergeUserAnnotations( + compileInsertReturning(this.contract, this.tableName, mappedRows, selectedForInsert), + annotationsMap, ); return dispatchMutationRows({ contract: this.contract, @@ -978,28 +1016,39 @@ export class Collection< }); } - async createCount( + async createCount[]>( data: readonly ResolvedCreateInput[], + ...annotations: As & ValidAnnotations<'write', As> ): Promise { if (data.length === 0) { return 0; } this.#assertNotMtiVariant('createCount()'); + const annotationsMap = this.#buildAnnotationsMap( + annotations as readonly AnnotationValue[], + 'write', + 'createCount', + ); const rows = data as readonly Record[]; const mappedRows = this.#mapCreateRows(rows); applyCreateDefaults(this.ctx, this.tableName, mappedRows); if (this.contract.capabilities?.['sql']?.['defaultInInsert'] !== true) { - const plans = compileInsertCountSplit(this.contract, this.tableName, mappedRows); + const plans = compileInsertCountSplit(this.contract, this.tableName, mappedRows).map((plan) => + mergeUserAnnotations(plan, annotationsMap), + ); for (const plan of plans) { await executeQueryPlan>(this.ctx.runtime, plan).toArray(); } return data.length; } - const compiled = compileInsertCount(this.contract, this.tableName, mappedRows); + const compiled = mergeUserAnnotations( + compileInsertCount(this.contract, this.tableName, mappedRows), + annotationsMap, + ); await executeQueryPlan>(this.ctx.runtime, compiled).toArray(); return data.length; } @@ -1009,13 +1058,21 @@ export class Collection< * On conflict, `ON CONFLICT DO NOTHING RETURNING ...` may return zero rows, * so this method may issue a follow-up reload query to return the existing row. */ - async upsert(input: { - create: ResolvedCreateInput; - update: Partial>; - conflictOn?: UniqueConstraintCriterion; - }): Promise { + async upsert[]>( + input: { + create: ResolvedCreateInput; + update: Partial>; + conflictOn?: UniqueConstraintCriterion; + }, + ...annotations: As & ValidAnnotations<'write', As> + ): Promise { assertReturningCapability(this.contract, 'upsert()'); this.#assertNotMtiVariant('upsert()'); + const annotationsMap = this.#buildAnnotationsMap( + annotations as readonly AnnotationValue[], + 'write', + 'upsert', + ); const mappedCreateRows = this.#mapCreateRows([input.create as Record]); const createValues = mappedCreateRows[0] ?? {}; @@ -1036,13 +1093,16 @@ export class Collection< this.state.selectedFields, parentJoinColumns, ); - const compiled = compileUpsertReturning( - this.contract, - this.tableName, - createValues, - updateValues, - conflictColumns, - selectedForUpsert, + const compiled = mergeUserAnnotations( + compileUpsertReturning( + this.contract, + this.tableName, + createValues, + updateValues, + conflictColumns, + selectedForUpsert, + ), + annotationsMap, ); const row = await executeMutationReturningSingleRow({ contract: this.contract, @@ -1072,10 +1132,31 @@ export class Collection< throw new Error(`upsert() for model "${this.modelName}" did not return a row`); } - async update( + /** + * Write terminal: update matching rows and return the first one (or + * null when no row matched). + * + * Accepts an optional variadic of write-typed user annotations after + * the input. The `As & ValidAnnotations<'write', As>` gate rejects + * read-only annotations at the call site; the runtime check fails + * closed for callers that bypass the type gate. + * + * Note: when the input contains nested-mutation callbacks, the + * operation is executed as a graph of internal queries via + * `withMutationScope`. In that path, annotations apply to the logical + * `update()` call but do not currently flow into each constituent SQL + * statement — see `projects/middleware-intercept-and-cache/follow-ups.md`. + */ + async update[]>( data: State['hasWhere'] extends true ? MutationUpdateInput : never, + ...annotations: As & ValidAnnotations<'write', As> ): Promise { assertReturningCapability(this.contract, 'update()'); + const annotationsMap = this.#buildAnnotationsMap( + annotations as readonly AnnotationValue[], + 'write', + 'update', + ); if ( hasNestedMutationCallbacks(this.contract, this.modelName, data as Record) @@ -1095,16 +1176,32 @@ export class Collection< return this.#reloadMutationRowByPrimaryKey(pkCriterion); } - const rows = await this.updateAll( + const rows = await this.#updateAllWithAnnotations( data as State['hasWhere'] extends true ? Partial> : never, + annotationsMap, ); return rows[0] ?? null; } - updateAll( + updateAll[]>( data: State['hasWhere'] extends true ? Partial> : never, + ...annotations: As & ValidAnnotations<'write', As> + ): AsyncIterableResult { + return this.#updateAllWithAnnotations( + data, + this.#buildAnnotationsMap( + annotations as readonly AnnotationValue[], + 'write', + 'updateAll', + ), + ); + } + + #updateAllWithAnnotations( + data: State['hasWhere'] extends true ? Partial> : never, + annotationsMap: ReadonlyMap> | undefined, ): AsyncIterableResult { assertReturningCapability(this.contract, 'updateAll()'); @@ -1119,12 +1216,15 @@ export class Collection< this.state.selectedFields, parentJoinColumns, ); - const compiled = compileUpdateReturning( - this.contract, - this.tableName, - mappedData, - this.state.filters, - selectedForUpdate, + const compiled = mergeUserAnnotations( + compileUpdateReturning( + this.contract, + this.tableName, + mappedData, + this.state.filters, + selectedForUpdate, + ), + annotationsMap, ); return dispatchMutationRows({ contract: this.contract, @@ -1137,14 +1237,22 @@ export class Collection< }); } - async updateCount( + async updateCount[]>( data: State['hasWhere'] extends true ? Partial> : never, + ...annotations: As & ValidAnnotations<'write', As> ): Promise { const mappedData = mapModelDataToStorageRow(this.contract, this.modelName, data); if (Object.keys(mappedData).length === 0) { return 0; } + // Annotations attach to the write, not the matching read. + const annotationsMap = this.#buildAnnotationsMap( + annotations as readonly AnnotationValue[], + 'write', + 'updateCount', + ); + const primaryKeyColumn = resolvePrimaryKeyColumn(this.contract, this.tableName); const countState: CollectionState = { ...emptyState(), @@ -1157,27 +1265,58 @@ export class Collection< countCompiled, ).toArray(); - const compiled = compileUpdateCount( - this.contract, - this.tableName, - mappedData, - this.state.filters, + const compiled = mergeUserAnnotations( + compileUpdateCount(this.contract, this.tableName, mappedData, this.state.filters), + annotationsMap, ); await executeQueryPlan>(this.ctx.runtime, compiled).toArray(); return matchingRows.length; } - async delete( + /** + * Write terminal: delete matching rows and return the first one (or + * null when no row matched). + * + * Accepts an optional variadic of write-typed user annotations. + * The `As & ValidAnnotations<'write', As>` gate rejects read-only + * annotations at the call site; the runtime check fails closed for + * callers that bypass the type gate. + */ + async delete[]>( this: State['hasWhere'] extends true ? Collection : never, + ...annotations: As & ValidAnnotations<'write', As> ): Promise { assertReturningCapability(this.contract, 'delete()'); - const rows = await this.deleteAll().toArray(); + // The `this`-typed receiver narrows when the `where()` gate is + // satisfied, so we can call `deleteAll()` on it directly. + const rows = await (this as Collection) + .#deleteAllWithAnnotations( + this.#buildAnnotationsMap( + annotations as readonly AnnotationValue[], + 'write', + 'delete', + ), + ) + .toArray(); return rows[0] ?? null; } - deleteAll( + deleteAll[]>( this: State['hasWhere'] extends true ? Collection : never, + ...annotations: As & ValidAnnotations<'write', As> + ): AsyncIterableResult { + return (this as Collection).#deleteAllWithAnnotations( + this.#buildAnnotationsMap( + annotations as readonly AnnotationValue[], + 'write', + 'deleteAll', + ), + ); + } + + #deleteAllWithAnnotations( + annotationsMap: ReadonlyMap> | undefined, ): AsyncIterableResult { assertReturningCapability(this.contract, 'deleteAll()'); @@ -1186,11 +1325,9 @@ export class Collection< this.state.selectedFields, parentJoinColumns, ); - const compiled = compileDeleteReturning( - this.contract, - this.tableName, - this.state.filters, - selectedForDelete, + const compiled = mergeUserAnnotations( + compileDeleteReturning(this.contract, this.tableName, this.state.filters, selectedForDelete), + annotationsMap, ); return dispatchMutationRows({ contract: this.contract, @@ -1203,9 +1340,17 @@ export class Collection< }); } - async deleteCount( + async deleteCount[]>( this: State['hasWhere'] extends true ? Collection : never, + ...annotations: As & ValidAnnotations<'write', As> ): Promise { + // Annotations attach to the write, not the matching read. + const annotationsMap = this.#buildAnnotationsMap( + annotations as readonly AnnotationValue[], + 'write', + 'deleteCount', + ); + const primaryKeyColumn = resolvePrimaryKeyColumn(this.contract, this.tableName); const countState: CollectionState = { ...emptyState(), @@ -1218,7 +1363,10 @@ export class Collection< countCompiled, ).toArray(); - const compiled = compileDeleteCount(this.contract, this.tableName, this.state.filters); + const compiled = mergeUserAnnotations( + compileDeleteCount(this.contract, this.tableName, this.state.filters), + annotationsMap, + ); await executeQueryPlan>(this.ctx.runtime, compiled).toArray(); return matchingRows.length; @@ -1375,6 +1523,36 @@ export class Collection< } return this.#clone({ userAnnotations: next }) as this; } + + /** + * Validates the annotation kinds against a terminal's operation kind + * and returns a `Map` ready to be passed + * to `mergeUserAnnotations`. Returns `undefined` for an empty variadic + * so callers can skip the rewrap entirely. + * + * Used by terminals where annotations don't flow through `state` — + * the compiled plan is post-wrapped with the annotations map + * instead. (Read terminals like `all` and `first` instead populate + * `state.userAnnotations` via `#withAnnotations`; aggregate is the + * one read terminal that uses the post-wrap path because its compile + * function doesn't take `state`.) The runtime gate fails closed via + * `assertAnnotationsApplicable`. + */ + #buildAnnotationsMap( + annotations: readonly AnnotationValue[], + kind: OperationKind, + terminalName: string, + ): ReadonlyMap> | undefined { + if (annotations.length === 0) { + return undefined; + } + assertAnnotationsApplicable(annotations, kind, terminalName); + const next = new Map>(); + for (const annotation of annotations) { + next.set(annotation.namespace, annotation); + } + return next; + } } /** diff --git a/packages/3-extensions/sql-orm-client/src/query-plan.ts b/packages/3-extensions/sql-orm-client/src/query-plan.ts index 0fa71fcac8..e9a08ea99f 100644 --- a/packages/3-extensions/sql-orm-client/src/query-plan.ts +++ b/packages/3-extensions/sql-orm-client/src/query-plan.ts @@ -2,6 +2,7 @@ export { compileAggregate, compileGroupedAggregate, } from './query-plan-aggregate'; +export { mergeUserAnnotations } from './query-plan-meta'; export { compileDeleteCount, compileDeleteReturning, From 2c2ef963b0315022a707a1e338dbb2978d814507 Mon Sep 17 00:00:00 2001 From: Alexey Orlenko Date: Tue, 28 Apr 2026 16:31:00 +0200 Subject: [PATCH 09/21] feat(sql-orm-client): add .annotate() arg to aggregate terminals plus tests for write+aggregate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the variadic write-typed annotation argument to two more terminals — Collection.aggregate and GroupedCollection.aggregate — completing the lane-level annotation surface called for in M2. Source changes: Collection.aggregate(fn, ...annotations: As & ValidAnnotations<'read', As>): Promise> GroupedCollection.aggregate(fn, ...annotations: As & ValidAnnotations<'read', As>): Promise>> Both are read terminals — aggregate queries are SELECTs. They use the post-wrap pattern via mergeUserAnnotations rather than threading through state, mirroring the write-terminal approach (compileAggregate and compileGroupedAggregate don't take state). The #buildAnnotationsMap helper added in the previous commit is parameterized over OperationKind, so aggregate uses the same code path with kind = 'read'. Test additions: annotations.test.ts (24 new runtime tests): - Write terminals (16): create, createAll, createCount, upsert, update, updateAll, updateCount, delete, deleteAll, deleteCount. Each tests the happy path and a runtime cast-bypass for at least one terminal in the family. updateCount and deleteCount verify that write annotations attach to the write statement, NOT to the matching read. - Collection.aggregate (4): writes annotation; both-kind; zero annotations; runtime gate. - GroupedCollection.aggregate (4): same shape as Collection version. annotations.types.test-d.ts (37 new type tests): - Write terminals (27): every terminal accepts write-only and both-kind annotations and rejects read-only ones at the type level. update/delete/their {All,Count} cousins use a hasWhere-gated Collection fixture (userCollectionWithWhere) to satisfy their 'this: State[hasWhere] extends true ? Collection : never' constraint, so the negative tests isolate the annotation gate from the receiver gate. - Collection.aggregate (6): accepts read-only, both-kind, empty variadic; rejects write-only and write-bearing mixes (negative); preserves the AggregateResult return type. - GroupedCollection.aggregate (4): same pattern. This commit conflates source and tests because the two were authored together when the helper rename happened. Future commits in the stack go back to separate source/test commits per the convention. Refs: TML-2143 --- .../sql-orm-client/src/collection.ts | 30 +- .../sql-orm-client/src/grouped-collection.ts | 51 ++- .../sql-orm-client/test/annotations.test.ts | 379 +++++++++++++++++- .../test/annotations.types.test-d.ts | 299 ++++++++++++++ 4 files changed, 743 insertions(+), 16 deletions(-) diff --git a/packages/3-extensions/sql-orm-client/src/collection.ts b/packages/3-extensions/sql-orm-client/src/collection.ts index 00e510a87f..05445abbb6 100644 --- a/packages/3-extensions/sql-orm-client/src/collection.ts +++ b/packages/3-extensions/sql-orm-client/src/collection.ts @@ -701,8 +701,22 @@ export class Collection< return rows[0] ?? null; } - async aggregate( + /** + * Read terminal: run an aggregate query (count, sum, avg, min, max) + * built via the `AggregateBuilder` callback. + * + * Accepts an optional variadic of read-typed user annotations after + * the builder callback. The `As & ValidAnnotations<'read', As>` gate + * rejects write-only annotations at the call site; the runtime check + * fails closed for callers that bypass the type gate. Annotations + * are merged into the compiled plan's `meta.annotations`. + */ + async aggregate< + Spec extends AggregateSpec, + As extends readonly AnnotationValue[], + >( fn: (aggregate: AggregateBuilder) => Spec, + ...annotations: As & ValidAnnotations<'read', As> ): Promise> { const aggregateSpec = fn(createAggregateBuilder(this.contract, this.modelName)); const entries = Object.entries(aggregateSpec); @@ -716,11 +730,15 @@ export class Collection< } } - const compiled = compileAggregate( - this.contract, - this.tableName, - this.state.filters, - aggregateSpec, + const annotationsMap = this.#buildAnnotationsMap( + annotations as readonly AnnotationValue[], + 'read', + 'aggregate', + ); + + const compiled = mergeUserAnnotations( + compileAggregate(this.contract, this.tableName, this.state.filters, aggregateSpec), + annotationsMap, ); const rows = await executeQueryPlan>( this.ctx.runtime, diff --git a/packages/3-extensions/sql-orm-client/src/grouped-collection.ts b/packages/3-extensions/sql-orm-client/src/grouped-collection.ts index d594ad6e8c..bda025f14c 100644 --- a/packages/3-extensions/sql-orm-client/src/grouped-collection.ts +++ b/packages/3-extensions/sql-orm-client/src/grouped-collection.ts @@ -1,4 +1,10 @@ import type { Contract } from '@prisma-next/contract/types'; +import type { + AnnotationValue, + OperationKind, + ValidAnnotations, +} from '@prisma-next/framework-components/runtime'; +import { assertAnnotationsApplicable } from '@prisma-next/framework-components/runtime'; import type { SqlStorage } from '@prisma-next/sql-contract/types'; import { AggregateExpr, @@ -13,7 +19,7 @@ import { createAggregateBuilder, isAggregateSelector } from './aggregate-builder import { getFieldToColumnMap } from './collection-contract'; import { mapStorageRowToModelFields } from './collection-runtime'; import { executeQueryPlan } from './execute-query-plan'; -import { compileGroupedAggregate } from './query-plan'; +import { compileGroupedAggregate, mergeUserAnnotations } from './query-plan'; import type { AggregateBuilder, AggregateResult, @@ -82,8 +88,21 @@ export class GroupedCollection< }) as GroupedCollection; } - async aggregate( + /** + * Read terminal: run a grouped aggregate query. + * + * Accepts an optional variadic of read-typed user annotations after + * the builder callback. The `As & ValidAnnotations<'read', As>` gate + * rejects write-only annotations at the call site; the runtime check + * fails closed for callers that bypass the type gate. Annotations + * are merged into the compiled plan's `meta.annotations`. + */ + async aggregate< + Spec extends AggregateSpec, + As extends readonly AnnotationValue[], + >( fn: (aggregate: AggregateBuilder) => Spec, + ...annotations: As & ValidAnnotations<'read', As> ): Promise< Array< SimplifyDeep< @@ -103,13 +122,27 @@ export class GroupedCollection< } } - const compiled = compileGroupedAggregate( - this.contract, - this.tableName, - this.baseFilters, - this.groupByColumns, - aggregateSpec, - combineWhereExprs(this.havingFilters), + const annotationsAsValues = annotations as readonly AnnotationValue[]; + let annotationsMap: ReadonlyMap> | undefined; + if (annotationsAsValues.length > 0) { + assertAnnotationsApplicable(annotationsAsValues, 'read', 'groupBy.aggregate'); + const next = new Map>(); + for (const annotation of annotationsAsValues) { + next.set(annotation.namespace, annotation); + } + annotationsMap = next; + } + + const compiled = mergeUserAnnotations( + compileGroupedAggregate( + this.contract, + this.tableName, + this.baseFilters, + this.groupByColumns, + aggregateSpec, + combineWhereExprs(this.havingFilters), + ), + annotationsMap, ); const rows = await executeQueryPlan>( this.ctx.runtime, diff --git a/packages/3-extensions/sql-orm-client/test/annotations.test.ts b/packages/3-extensions/sql-orm-client/test/annotations.test.ts index 1ff9d09f2d..6df82ac1df 100644 --- a/packages/3-extensions/sql-orm-client/test/annotations.test.ts +++ b/packages/3-extensions/sql-orm-client/test/annotations.test.ts @@ -1,6 +1,10 @@ import { defineAnnotation } from '@prisma-next/framework-components/runtime'; import { describe, expect, it } from 'vitest'; -import { createCollection } from './collection-fixtures'; +import { + createCollection, + createCollectionFor, + createReturningCollectionFor, +} from './collection-fixtures'; const cacheAnnotation = defineAnnotation<{ ttl: number; skip?: boolean }, 'read'>({ namespace: 'cache', @@ -198,3 +202,376 @@ describe('Collection annotations alongside framework-internal codecs metadata', } }); }); + +describe('Collection.create annotations', () => { + it('writes the applied write annotation under its namespace on the executed plan', async () => { + const { collection, runtime } = createReturningCollectionFor('User'); + runtime.setNextResults([[{ id: 1, name: 'Alice', email: 'a@b.com' }]]); + + await collection.create( + { id: 1, name: 'Alice', email: 'a@b.com' }, + auditAnnotation.apply({ actor: 'system' }), + ); + + expect(runtime.executions).toHaveLength(1); + const plan = runtime.executions[0]!.plan; + expect(auditAnnotation.read(plan)).toEqual({ actor: 'system' }); + }); + + it('accepts a both-kind annotation', async () => { + const { collection, runtime } = createReturningCollectionFor('User'); + runtime.setNextResults([[{ id: 1, name: 'Alice', email: 'a@b.com' }]]); + + await collection.create( + { id: 1, name: 'Alice', email: 'a@b.com' }, + otelAnnotation.apply({ traceId: 't-1' }), + ); + + const plan = runtime.executions[0]!.plan; + expect(otelAnnotation.read(plan)).toEqual({ traceId: 't-1' }); + }); + + it('zero annotations leaves the plan without user annotations', async () => { + const { collection, runtime } = createReturningCollectionFor('User'); + runtime.setNextResults([[{ id: 1, name: 'Alice', email: 'a@b.com' }]]); + + await collection.create({ id: 1, name: 'Alice', email: 'a@b.com' }); + + const plan = runtime.executions[0]!.plan; + expect(auditAnnotation.read(plan)).toBeUndefined(); + expect(otelAnnotation.read(plan)).toBeUndefined(); + }); + + it('runtime gate rejects a read-only annotation forced through a cast', async () => { + const { collection } = createReturningCollectionFor('User'); + const createFn = collection.create as unknown as ( + data: unknown, + annotation: unknown, + ) => Promise; + await expect( + createFn.call( + collection, + { id: 1, name: 'Alice', email: 'a@b.com' }, + cacheAnnotation.apply({ ttl: 60 }), + ), + ).rejects.toMatchObject({ + code: 'RUNTIME.ANNOTATION_INAPPLICABLE', + category: 'RUNTIME', + }); + }); +}); + +describe('Collection.createAll annotations', () => { + it('writes the applied annotation onto every plan emitted by the split path', async () => { + const { collection, runtime } = createReturningCollectionFor('User'); + runtime.setNextResults([ + [{ id: 1, name: 'A', email: 'a@b.com' }], + [{ id: 2, name: 'B', email: 'b@b.com' }], + ]); + + await collection + .createAll( + [ + { id: 1, name: 'A', email: 'a@b.com' }, + { id: 2, name: 'B', email: 'b@b.com' }, + ], + auditAnnotation.apply({ actor: 'system' }), + ) + .toArray(); + + expect(runtime.executions.length).toBeGreaterThan(0); + for (const execution of runtime.executions) { + expect(auditAnnotation.read(execution.plan)).toEqual({ actor: 'system' }); + } + }); +}); + +describe('Collection.createCount annotations', () => { + it('writes the applied annotation onto the executed plan', async () => { + const { collection, runtime } = createReturningCollectionFor('User'); + runtime.setNextResults([[]]); + + await collection.createCount( + [{ id: 1, name: 'A', email: 'a@b.com' }], + auditAnnotation.apply({ actor: 'system' }), + ); + + expect(runtime.executions.length).toBeGreaterThan(0); + for (const execution of runtime.executions) { + expect(auditAnnotation.read(execution.plan)).toEqual({ actor: 'system' }); + } + }); +}); + +describe('Collection.upsert annotations', () => { + it('writes the applied annotation under its namespace on the executed plan', async () => { + const { collection, runtime } = createReturningCollectionFor('User'); + runtime.setNextResults([[{ id: 1, name: 'Alice', email: 'a@b.com' }]]); + + await collection.upsert( + { + create: { id: 1, name: 'Alice', email: 'a@b.com' }, + update: { name: 'Alice' }, + conflictOn: { id: 1 }, + }, + auditAnnotation.apply({ actor: 'system' }), + ); + + const plan = runtime.executions[0]!.plan; + expect(auditAnnotation.read(plan)).toEqual({ actor: 'system' }); + }); + + it('runtime gate rejects a read-only annotation forced through a cast', async () => { + const { collection } = createReturningCollectionFor('User'); + const upsertFn = collection.upsert as unknown as ( + input: unknown, + annotation: unknown, + ) => Promise; + await expect( + upsertFn.call( + collection, + { + create: { id: 1, name: 'Alice', email: 'a@b.com' }, + update: { name: 'Alice' }, + conflictOn: { id: 1 }, + }, + cacheAnnotation.apply({ ttl: 60 }), + ), + ).rejects.toMatchObject({ + code: 'RUNTIME.ANNOTATION_INAPPLICABLE', + }); + }); +}); + +describe('Collection.update annotations', () => { + it('writes the applied annotation under its namespace on the executed plan', async () => { + const { collection, runtime } = createReturningCollectionFor('User'); + runtime.setNextResults([[{ id: 1, name: 'Alice', email: 'a@b.com' }]]); + + await collection + .where({ id: 1 }) + .update({ name: 'Alice' }, auditAnnotation.apply({ actor: 'system' })); + + const plan = runtime.executions[0]!.plan; + expect(auditAnnotation.read(plan)).toEqual({ actor: 'system' }); + }); + + it('runtime gate rejects a read-only annotation forced through a cast', async () => { + const { collection } = createReturningCollectionFor('User'); + const filtered = collection.where({ id: 1 }); + const updateFn = filtered.update as unknown as ( + data: unknown, + annotation: unknown, + ) => Promise; + await expect( + updateFn.call(filtered, { name: 'Alice' }, cacheAnnotation.apply({ ttl: 60 })), + ).rejects.toMatchObject({ + code: 'RUNTIME.ANNOTATION_INAPPLICABLE', + }); + }); +}); + +describe('Collection.updateAll annotations', () => { + it('writes the applied annotation under its namespace on the executed plan', async () => { + const { collection, runtime } = createReturningCollectionFor('User'); + runtime.setNextResults([[{ id: 1, name: 'Alice', email: 'a@b.com' }]]); + + await collection + .where({ id: 1 }) + .updateAll({ name: 'Alice' }, auditAnnotation.apply({ actor: 'system' })) + .toArray(); + + const plan = runtime.executions[0]!.plan; + expect(auditAnnotation.read(plan)).toEqual({ actor: 'system' }); + }); +}); + +describe('Collection.updateCount annotations', () => { + it('writes the applied annotation onto the update statement (not the matching read)', async () => { + const { collection, runtime } = createReturningCollectionFor('User'); + // Two execute calls: matching select first, then the update. + runtime.setNextResults([[{ id: 1 }], []]); + + await collection + .where({ id: 1 }) + .updateCount({ name: 'Alice' }, auditAnnotation.apply({ actor: 'system' })); + + expect(runtime.executions).toHaveLength(2); + const matchingPlan = runtime.executions[0]!.plan; + const updatePlan = runtime.executions[1]!.plan; + // The matching read does NOT carry the write annotation. + expect(auditAnnotation.read(matchingPlan)).toBeUndefined(); + // The update statement DOES. + expect(auditAnnotation.read(updatePlan)).toEqual({ actor: 'system' }); + }); +}); + +describe('Collection.delete annotations', () => { + it('writes the applied annotation under its namespace on the executed plan', async () => { + const { collection, runtime } = createReturningCollectionFor('User'); + runtime.setNextResults([[{ id: 1, name: 'Alice', email: 'a@b.com' }]]); + + await collection.where({ id: 1 }).delete(auditAnnotation.apply({ actor: 'system' })); + + const plan = runtime.executions[0]!.plan; + expect(auditAnnotation.read(plan)).toEqual({ actor: 'system' }); + }); + + it('runtime gate rejects a read-only annotation forced through a cast', async () => { + const { collection } = createReturningCollectionFor('User'); + const filtered = collection.where({ id: 1 }); + const deleteFn = filtered.delete as unknown as (annotation: unknown) => Promise; + await expect(deleteFn.call(filtered, cacheAnnotation.apply({ ttl: 60 }))).rejects.toMatchObject( + { + code: 'RUNTIME.ANNOTATION_INAPPLICABLE', + }, + ); + }); +}); + +describe('Collection.deleteAll annotations', () => { + it('writes the applied annotation under its namespace on the executed plan', async () => { + const { collection, runtime } = createReturningCollectionFor('User'); + runtime.setNextResults([[{ id: 1, name: 'Alice', email: 'a@b.com' }]]); + + await collection + .where({ id: 1 }) + .deleteAll(auditAnnotation.apply({ actor: 'system' })) + .toArray(); + + const plan = runtime.executions[0]!.plan; + expect(auditAnnotation.read(plan)).toEqual({ actor: 'system' }); + }); +}); + +describe('Collection.deleteCount annotations', () => { + it('writes the applied annotation onto the delete statement (not the matching read)', async () => { + const { collection, runtime } = createReturningCollectionFor('User'); + // Two execute calls: matching select first, then the delete. + runtime.setNextResults([[{ id: 1 }], []]); + + await collection.where({ id: 1 }).deleteCount(auditAnnotation.apply({ actor: 'system' })); + + expect(runtime.executions).toHaveLength(2); + const matchingPlan = runtime.executions[0]!.plan; + const deletePlan = runtime.executions[1]!.plan; + // The matching read does NOT carry the write annotation. + expect(auditAnnotation.read(matchingPlan)).toBeUndefined(); + // The delete statement DOES. + expect(auditAnnotation.read(deletePlan)).toEqual({ actor: 'system' }); + }); +}); + +describe('Collection.aggregate annotations', () => { + it('writes the applied read annotation under its namespace on the executed plan', async () => { + const { collection, runtime } = createCollectionFor('Post'); + runtime.setNextResults([[{ count: '5' }]]); + + await collection.aggregate( + (aggregate) => ({ count: aggregate.count() }), + cacheAnnotation.apply({ ttl: 60 }), + ); + + expect(runtime.executions).toHaveLength(1); + const plan = runtime.executions[0]!.plan; + expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); + }); + + it('accepts a both-kind annotation', async () => { + const { collection, runtime } = createCollectionFor('Post'); + runtime.setNextResults([[{ count: '5' }]]); + + await collection.aggregate( + (aggregate) => ({ count: aggregate.count() }), + otelAnnotation.apply({ traceId: 't-1' }), + ); + + const plan = runtime.executions[0]!.plan; + expect(otelAnnotation.read(plan)).toEqual({ traceId: 't-1' }); + }); + + it('zero annotations leaves the plan without user annotations', async () => { + const { collection, runtime } = createCollectionFor('Post'); + runtime.setNextResults([[{ count: '5' }]]); + + await collection.aggregate((aggregate) => ({ count: aggregate.count() })); + + const plan = runtime.executions[0]!.plan; + expect(cacheAnnotation.read(plan)).toBeUndefined(); + expect(otelAnnotation.read(plan)).toBeUndefined(); + }); + + it('runtime gate rejects a write-only annotation forced through a cast', async () => { + const { collection } = createCollectionFor('Post'); + const aggregateFn = collection.aggregate as unknown as ( + fn: unknown, + annotation: unknown, + ) => Promise; + await expect( + aggregateFn.call( + collection, + (aggregate: { count: () => unknown }) => ({ count: aggregate.count() }), + auditAnnotation.apply({ actor: 'system' }), + ), + ).rejects.toMatchObject({ + code: 'RUNTIME.ANNOTATION_INAPPLICABLE', + category: 'RUNTIME', + }); + }); +}); + +describe('GroupedCollection.aggregate annotations', () => { + it('writes the applied read annotation under its namespace on the executed plan', async () => { + const { collection, runtime } = createCollectionFor('Post'); + runtime.setNextResults([[{ user_id: 1, count: '2' }]]); + + await collection + .groupBy('userId') + .aggregate((aggregate) => ({ count: aggregate.count() }), cacheAnnotation.apply({ ttl: 60 })); + + expect(runtime.executions).toHaveLength(1); + const plan = runtime.executions[0]!.plan; + expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); + }); + + it('accepts a both-kind annotation', async () => { + const { collection, runtime } = createCollectionFor('Post'); + runtime.setNextResults([[{ user_id: 1, count: '2' }]]); + + await collection + .groupBy('userId') + .aggregate( + (aggregate) => ({ count: aggregate.count() }), + otelAnnotation.apply({ traceId: 't-1' }), + ); + + const plan = runtime.executions[0]!.plan; + expect(otelAnnotation.read(plan)).toEqual({ traceId: 't-1' }); + }); + + it('zero annotations leaves the plan without user annotations', async () => { + const { collection, runtime } = createCollectionFor('Post'); + runtime.setNextResults([[{ user_id: 1, count: '2' }]]); + + await collection.groupBy('userId').aggregate((aggregate) => ({ count: aggregate.count() })); + + const plan = runtime.executions[0]!.plan; + expect(cacheAnnotation.read(plan)).toBeUndefined(); + expect(otelAnnotation.read(plan)).toBeUndefined(); + }); + + it('runtime gate rejects a write-only annotation forced through a cast', async () => { + const { collection } = createCollectionFor('Post'); + const grouped = collection.groupBy('userId') as unknown as { + aggregate(fn: unknown, annotation: unknown): Promise; + }; + await expect( + grouped.aggregate( + (aggregate: { count: () => unknown }) => ({ count: aggregate.count() }), + auditAnnotation.apply({ actor: 'system' }), + ), + ).rejects.toMatchObject({ + code: 'RUNTIME.ANNOTATION_INAPPLICABLE', + }); + }); +}); diff --git a/packages/3-extensions/sql-orm-client/test/annotations.types.test-d.ts b/packages/3-extensions/sql-orm-client/test/annotations.types.test-d.ts index df6c9e73a5..b6edaccb14 100644 --- a/packages/3-extensions/sql-orm-client/test/annotations.types.test-d.ts +++ b/packages/3-extensions/sql-orm-client/test/annotations.types.test-d.ts @@ -5,6 +5,7 @@ import { } from '@prisma-next/framework-components/runtime'; import { describe, expectTypeOf, test } from 'vitest'; import type { Collection } from '../src/collection'; +import type { GroupedCollection } from '../src/grouped-collection'; import type { TestContract } from './helpers'; /** @@ -159,3 +160,301 @@ describe('annotation handle types are preserved through the lane', () => { >(); }); }); + +// --------------------------------------------------------------------------- +// Write terminals +// +// The contract is symmetrical to the read terminals: each write terminal +// accepts write-only and both-kind annotations, rejects read-only ones at +// the type level, preserves its return type, and accepts an empty variadic. +// --------------------------------------------------------------------------- + +declare const userCollectionWithWhere: Collection< + TestContract, + 'User', + Record, + { + readonly hasOrderBy: false; + readonly hasWhere: true; + readonly hasUniqueFilter: false; + readonly variantName: undefined; + } +>; + +describe('Collection.create (write-typed)', () => { + test('accepts a write-only annotation', () => { + userCollection.create( + { id: 1, name: 'Alice', email: 'a@b.com' }, + auditAnnotation.apply({ actor: 'system' }), + ); + }); + + test('accepts a both-kind annotation', () => { + userCollection.create( + { id: 1, name: 'Alice', email: 'a@b.com' }, + otelAnnotation.apply({ traceId: 't' }), + ); + }); + + test('accepts zero annotations (empty variadic)', () => { + userCollection.create({ id: 1, name: 'Alice', email: 'a@b.com' }); + }); + + test('rejects a read-only annotation (negative)', () => { + userCollection.create( + { id: 1, name: 'Alice', email: 'a@b.com' }, + // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' + cacheAnnotation.apply({ ttl: 60 }), + ); + }); + + test('rejects a mix containing a read-only annotation (negative)', () => { + // biome-ignore format: keep on one line so @ts-expect-error attaches to the call + // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' + userCollection.create({ id: 1, name: 'Alice', email: 'a@b.com' }, auditAnnotation.apply({ actor: 'system' }), cacheAnnotation.apply({ ttl: 60 })); + }); + + test('the return type is Promise', () => { + const result = userCollection.create( + { id: 1, name: 'Alice', email: 'a@b.com' }, + auditAnnotation.apply({ actor: 'system' }), + ); + expectTypeOf(result).resolves.toMatchTypeOf>(); + }); +}); + +describe('Collection.createAll (write-typed)', () => { + test('accepts a write-only annotation', () => { + userCollection.createAll( + [{ id: 1, name: 'Alice', email: 'a@b.com' }], + auditAnnotation.apply({ actor: 'system' }), + ); + }); + + test('accepts zero annotations (empty variadic)', () => { + userCollection.createAll([{ id: 1, name: 'Alice', email: 'a@b.com' }]); + }); + + test('rejects a read-only annotation (negative)', () => { + userCollection.createAll( + [{ id: 1, name: 'Alice', email: 'a@b.com' }], + // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' + cacheAnnotation.apply({ ttl: 60 }), + ); + }); +}); + +describe('Collection.createCount (write-typed)', () => { + test('accepts a write-only annotation', () => { + userCollection.createCount( + [{ id: 1, name: 'Alice', email: 'a@b.com' }], + auditAnnotation.apply({ actor: 'system' }), + ); + }); + + test('rejects a read-only annotation (negative)', () => { + userCollection.createCount( + [{ id: 1, name: 'Alice', email: 'a@b.com' }], + // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' + cacheAnnotation.apply({ ttl: 60 }), + ); + }); + + test('the return type is Promise', () => { + const result = userCollection.createCount( + [{ id: 1, name: 'Alice', email: 'a@b.com' }], + auditAnnotation.apply({ actor: 'system' }), + ); + expectTypeOf(result).resolves.toBeNumber(); + }); +}); + +describe('Collection.upsert (write-typed)', () => { + test('accepts a write-only annotation', () => { + userCollection.upsert( + { + create: { id: 1, name: 'Alice', email: 'a@b.com' }, + update: { name: 'Alice' }, + conflictOn: { id: 1 }, + }, + auditAnnotation.apply({ actor: 'system' }), + ); + }); + + test('rejects a read-only annotation (negative)', () => { + userCollection.upsert( + { + create: { id: 1, name: 'Alice', email: 'a@b.com' }, + update: { name: 'Alice' }, + conflictOn: { id: 1 }, + }, + // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' + cacheAnnotation.apply({ ttl: 60 }), + ); + }); +}); + +describe('Collection.update / .updateAll / .updateCount (write-typed)', () => { + // Update terminals require the receiver to satisfy the + // `State['hasWhere'] extends true` gate, so we use a separately- + // declared `userCollectionWithWhere` whose State is post-where. + test('update accepts a write-only annotation', () => { + userCollectionWithWhere.update({ name: 'Alice' }, auditAnnotation.apply({ actor: 'system' })); + }); + + test('update rejects a read-only annotation (negative)', () => { + userCollectionWithWhere.update( + { name: 'Alice' }, + // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' + cacheAnnotation.apply({ ttl: 60 }), + ); + }); + + test('updateAll accepts a write-only annotation', () => { + userCollectionWithWhere.updateAll( + { name: 'Alice' }, + auditAnnotation.apply({ actor: 'system' }), + ); + }); + + test('updateAll rejects a read-only annotation (negative)', () => { + userCollectionWithWhere.updateAll( + { name: 'Alice' }, + // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' + cacheAnnotation.apply({ ttl: 60 }), + ); + }); + + test('updateCount accepts a write-only annotation', () => { + userCollectionWithWhere.updateCount( + { name: 'Alice' }, + auditAnnotation.apply({ actor: 'system' }), + ); + }); + + test('updateCount rejects a read-only annotation (negative)', () => { + userCollectionWithWhere.updateCount( + { name: 'Alice' }, + // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' + cacheAnnotation.apply({ ttl: 60 }), + ); + }); + + test('updateCount returns Promise', () => { + const result = userCollectionWithWhere.updateCount( + { name: 'Alice' }, + auditAnnotation.apply({ actor: 'system' }), + ); + expectTypeOf(result).resolves.toBeNumber(); + }); +}); + +describe('Collection.delete / .deleteAll / .deleteCount (write-typed)', () => { + test('delete accepts a write-only annotation', () => { + userCollectionWithWhere.delete(auditAnnotation.apply({ actor: 'system' })); + }); + + test('delete rejects a read-only annotation (negative)', () => { + // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' + userCollectionWithWhere.delete(cacheAnnotation.apply({ ttl: 60 })); + }); + + test('deleteAll accepts a write-only annotation', () => { + userCollectionWithWhere.deleteAll(auditAnnotation.apply({ actor: 'system' })); + }); + + test('deleteAll rejects a read-only annotation (negative)', () => { + // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' + userCollectionWithWhere.deleteAll(cacheAnnotation.apply({ ttl: 60 })); + }); + + test('deleteCount accepts a write-only annotation', () => { + userCollectionWithWhere.deleteCount(auditAnnotation.apply({ actor: 'system' })); + }); + + test('deleteCount rejects a read-only annotation (negative)', () => { + // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' + userCollectionWithWhere.deleteCount(cacheAnnotation.apply({ ttl: 60 })); + }); +}); + +// --------------------------------------------------------------------------- +// Aggregate terminals (read-typed) +// +// Both `Collection.aggregate(fn, ...annotations)` and +// `GroupedCollection.aggregate(fn, ...annotations)` are read terminals that +// run a single SQL aggregation query and accept user annotations after the +// builder callback. +// --------------------------------------------------------------------------- + +describe('Collection.aggregate (read-typed)', () => { + test('accepts a read-only annotation', () => { + userCollection.aggregate( + (aggregate) => ({ count: aggregate.count() }), + cacheAnnotation.apply({ ttl: 60 }), + ); + }); + + test('accepts a both-kind annotation', () => { + userCollection.aggregate( + (aggregate) => ({ count: aggregate.count() }), + otelAnnotation.apply({ traceId: 't' }), + ); + }); + + test('accepts zero annotations (empty variadic)', () => { + userCollection.aggregate((aggregate) => ({ count: aggregate.count() })); + }); + + test('rejects a write-only annotation (negative)', () => { + userCollection.aggregate( + (aggregate) => ({ count: aggregate.count() }), + // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' + auditAnnotation.apply({ actor: 'system' }), + ); + }); + + test('rejects a mix containing a write-only annotation (negative)', () => { + // biome-ignore format: keep on one line so @ts-expect-error attaches to the call + // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' + userCollection.aggregate((aggregate) => ({ count: aggregate.count() }), cacheAnnotation.apply({ ttl: 60 }), auditAnnotation.apply({ actor: 'system' })); + }); + + test('the aggregation spec type is preserved through the gate', () => { + const result = userCollection.aggregate( + (aggregate) => ({ count: aggregate.count() }), + cacheAnnotation.apply({ ttl: 60 }), + ); + expectTypeOf(result).resolves.toMatchTypeOf<{ count: number }>(); + }); +}); + +declare const userGroupedCollection: GroupedCollection; + +describe('GroupedCollection.aggregate (read-typed)', () => { + test('accepts a read-only annotation', () => { + userGroupedCollection.aggregate( + (aggregate) => ({ count: aggregate.count() }), + cacheAnnotation.apply({ ttl: 60 }), + ); + }); + + test('accepts a both-kind annotation', () => { + userGroupedCollection.aggregate( + (aggregate) => ({ count: aggregate.count() }), + otelAnnotation.apply({ traceId: 't' }), + ); + }); + + test('accepts zero annotations (empty variadic)', () => { + userGroupedCollection.aggregate((aggregate) => ({ count: aggregate.count() })); + }); + + test('rejects a write-only annotation (negative)', () => { + userGroupedCollection.aggregate( + (aggregate) => ({ count: aggregate.count() }), + // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' + auditAnnotation.apply({ actor: 'system' }), + ); + }); +}); From cfbd3245b74da1693b3873b305a2f225ff301ba7 Mon Sep 17 00:00:00 2001 From: Alexey Orlenko Date: Tue, 28 Apr 2026 17:10:48 +0200 Subject: [PATCH 10/21] feat(middleware-cache): scaffold @prisma-next/middleware-cache package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creates the new package mirroring middleware-telemetry's layout: package.json, tsconfig.json + tsconfig.prod.json, tsdown.config.ts, vitest.config.ts, biome.jsonc, src/exports/index.ts placeholder, and the README documenting opt-in behavior, cache-key composition, CacheStore pluggability, transaction-scope guard, TTL/LRU semantics, and the package's caveats. Registers the package in architecture.config.json under extensions/integrations/runtime so pnpm lint:deps picks it up. The new package depends only on @prisma-next/framework-components — no SQL or Mongo runtime dependency, which is the cross-family constraint enforced by ADR 204. Closes M3.3 and M3.20 of the cache-middleware project. --- architecture.config.json | 6 + .../3-extensions/middleware-cache/README.md | 151 ++++++++++++++++++ .../3-extensions/middleware-cache/biome.jsonc | 4 + .../middleware-cache/package.json | 43 +++++ .../middleware-cache/src/exports/index.ts | 1 + .../middleware-cache/tsconfig.json | 9 ++ .../middleware-cache/tsconfig.prod.json | 4 + .../middleware-cache/tsdown.config.ts | 5 + .../middleware-cache/vitest.config.ts | 21 +++ pnpm-lock.yaml | 22 +++ 10 files changed, 266 insertions(+) create mode 100644 packages/3-extensions/middleware-cache/README.md create mode 100644 packages/3-extensions/middleware-cache/biome.jsonc create mode 100644 packages/3-extensions/middleware-cache/package.json create mode 100644 packages/3-extensions/middleware-cache/src/exports/index.ts create mode 100644 packages/3-extensions/middleware-cache/tsconfig.json create mode 100644 packages/3-extensions/middleware-cache/tsconfig.prod.json create mode 100644 packages/3-extensions/middleware-cache/tsdown.config.ts create mode 100644 packages/3-extensions/middleware-cache/vitest.config.ts diff --git a/architecture.config.json b/architecture.config.json index bd56395967..d36c291939 100644 --- a/architecture.config.json +++ b/architecture.config.json @@ -186,6 +186,12 @@ "layer": "integrations", "plane": "runtime" }, + { + "glob": "packages/3-extensions/middleware-cache/**", + "domain": "extensions", + "layer": "integrations", + "plane": "runtime" + }, { "glob": "packages/3-extensions/sql-orm-client/**", "domain": "extensions", diff --git a/packages/3-extensions/middleware-cache/README.md b/packages/3-extensions/middleware-cache/README.md new file mode 100644 index 0000000000..71885f9104 --- /dev/null +++ b/packages/3-extensions/middleware-cache/README.md @@ -0,0 +1,151 @@ +# @prisma-next/middleware-cache + +A family-agnostic, opt-in caching middleware for Prisma Next runtimes. + +Built on the `intercept` hook on `RuntimeMiddleware` (added in TML-2143 M1): on a cache hit, the middleware short-circuits execution and returns the cached rows; the driver is never invoked. On a cache miss, the middleware buffers rows from the driver and commits them to the store on successful completion. + +The package depends only on `@prisma-next/framework-components/runtime` — no SQL or Mongo runtime dependency. Cache keys come from `RuntimeMiddlewareContext.contentHash(exec)`, which the family runtime populates, so SQL and Mongo runtimes both work out of the box. + +## Responsibilities + +- Provide an opt-in caching `RuntimeMiddleware` that short-circuits repeated reads via the `intercept` hook. +- Define the `cacheAnnotation` handle (read-only) that lane terminals (SQL DSL `.annotate(...)`, ORM read terminals) use to attach per-query cache parameters (`ttl`, `skip`, `key`). +- Resolve the cache key per execution: per-query `cacheAnnotation.apply({ key })` override, otherwise `RuntimeMiddlewareContext.contentHash(exec)` from the family runtime. +- Buffer driver rows on a miss and commit to the `CacheStore` only on successful completion (`completed: true && source: 'driver'`). +- Bypass the cache when `RuntimeMiddlewareContext.scope` is `'connection'` or `'transaction'`. +- Ship a default in-memory LRU-with-TTL `CacheStore` and expose the `CacheStore` interface for pluggable backends (Redis, Memcached, etc.). + +## Dependencies + +- `@prisma-next/framework-components/runtime` — the only production dependency. Provides `RuntimeMiddleware`, `RuntimeMiddlewareContext` (with `contentHash` and `scope`), `defineAnnotation`, `AfterExecuteResult`, and the orchestrator integration via `runWithMiddleware`. + +The package does **not** depend on `@prisma-next/sql-runtime`, `@prisma-next/mongo-runtime`, or any target adapter. It does not import `node:crypto` — hashing the canonical execution identity is the family runtime's responsibility (via `@prisma-next/utils/hash-identity` in the SQL and Mongo runtimes today). + + +## Quick start + +```typescript +import postgres from '@prisma-next/postgres/runtime'; +import { + cacheAnnotation, + createCacheMiddleware, +} from '@prisma-next/middleware-cache'; +import type { Contract } from './contract.d'; +import contractJson from './contract.json' with { type: 'json' }; + +const db = postgres({ + contractJson, + url: process.env['DATABASE_URL']!, + middleware: [createCacheMiddleware({ maxEntries: 1000 })], +}); + +// First call: hits the database, caches the raw rows. +const first = await db.orm.User.first( + { id: 1 }, + cacheAnnotation.apply({ ttl: 60_000 }), +); + +// Second call with the identical plan: served from cache, driver +// not invoked. +const second = await db.orm.User.first( + { id: 1 }, + cacheAnnotation.apply({ ttl: 60_000 }), +); + +// Un-annotated queries are never cached — caching is strictly opt-in. +const fresh = await db.orm.User.first({ id: 1 }); // always hits the DB +``` + +## Opt-in by annotation + +The cache middleware acts only on plans that carry a `cacheAnnotation` payload with a `ttl` set: + +| Annotation state | Behavior | +|---|---| +| No `cacheAnnotation` on the plan | Pass through; never cached. | +| `cacheAnnotation.apply({ })` (no `ttl`) | Pass through; never cached. | +| `cacheAnnotation.apply({ skip: true })` | Pass through; never cached. | +| `cacheAnnotation.apply({ ttl })` | Cache lookup; commit on miss + success. | +| `cacheAnnotation.apply({ ttl, key })` | As above, but use the supplied key verbatim. | + +The annotation is **read-only**: it declares `applicableTo: ['read']`, so the lane gate (TML-2143 M2) rejects passing it to write terminals at both type and runtime levels. "Cache a mutation" is structurally impossible without an `as any` cast bypass at both the type and runtime levels — the cache middleware itself ships without any mutation classifier. + +```typescript +// ✓ ORM read terminal accepts the read-only annotation. +await db.orm.User.first({ id }, cacheAnnotation.apply({ ttl: 60_000 })); + +// ✗ Type error: write terminal rejects read-only annotation. +await db.orm.User.create(input, cacheAnnotation.apply({ ttl: 60_000 })); + +// ✓ SQL DSL: chainable on select / grouped builders. +const plan = db.sql + .from(tables.user) + .select({ id: tables.user.columns.id }) + .annotate(cacheAnnotation.apply({ ttl: 60_000 })) + .build(); +``` + +## Cache key composition + +Two-tier resolution: + +1. **Per-query override.** `cacheAnnotation.apply({ key })` — the supplied string is used verbatim. The cache middleware does **not** rehash user-supplied keys; the caller is responsible for keeping the string bounded in size and free of sensitive data they do not want flowing into debug logs, Redis `KEYS` output, persistence dumps, or any user-supplied `CacheStore`. +2. **Default.** `RuntimeMiddlewareContext.contentHash(exec)` — the family runtime owns this. The SQL and Mongo runtimes today compose `meta.storageHash + '|' + …` and pipe the result through `hashContent` (SHA-512), producing a bounded, opaque digest of the form `sha512:HEXDIGEST`. The cache middleware uses the returned string directly as the `Map` key. + +Two consequences worth pinning: + +- **Storage-hash discrimination.** A schema migration changes `meta.storageHash`, which changes `contentHash`, which invalidates cached entries automatically. Stale-schema reads cannot leak across migrations. +- **AST rewrites are part of the key.** Middleware that rewrite the plan via `beforeCompile` (e.g. soft-delete) run **upstream** of the cache. The cache sees the post-lowering plan, so the rewritten SQL is part of the content hash. Adding or removing a `beforeCompile` middleware changes which entries hit. + +## `CacheStore` pluggability + +The default in-memory store is per-process and **not** coherent across replicas. For shared caching, supply a custom `CacheStore`: + +```typescript +import type { CacheStore, CachedEntry } from '@prisma-next/middleware-cache'; + +const redis: CacheStore = { + async get(key) { + const raw = await redisClient.get(key); + return raw ? (JSON.parse(raw) as CachedEntry) : undefined; + }, + async set(key, entry, ttlMs) { + await redisClient.set(key, JSON.stringify(entry), 'PX', ttlMs); + }, +}; + +const middleware = createCacheMiddleware({ store: redis }); +``` + +The interface is intentionally minimal — `get` returns the entry if present and not expired (implementations gating on TTL should treat expired as absent), `set` writes the entry under the key with the per-call `ttlMs`. Both are async to leave room for I/O-backed stores; the default in-memory store completes synchronously and wraps results in `Promise.resolve` for type conformance. + +## Transaction-scope guard + +The middleware bypasses the cache entirely when `RuntimeMiddlewareContext.scope` is `'connection'` or `'transaction'`. Only top-level `runtime.execute` (`scope === 'runtime'`) consults the store. + +This avoids two surprises: + +- Inside a transaction, the caller expects read-after-write coherence with their own writes — the cache cannot meaningfully serve those reads without tracking the transaction's pending writes, which is out of scope for this milestone. +- On a checked-out connection (`runtime.connection().execute(...)`), the caller has explicitly stepped outside the shared runtime surface and likely does not expect the global cache to inject results. + +## TTL and LRU semantics + +The default `createInMemoryCacheStore({ maxEntries, clock? })`: + +- **TTL.** Each entry is committed with the per-query `ttl` (in milliseconds). The store evaluates expiry against its injected clock (defaults to `Date.now`); reads of expired entries return `undefined` and drop the entry as a side effect. +- **LRU.** Iteration order is the LRU order. Reads and writes both bump recency. When the live count would exceed `maxEntries`, the oldest entry is evicted. +- **Failure handling.** The middleware commits to the store only when `afterExecute` reports `completed: true && source: 'driver'`. Driver errors mid-stream and middleware-served executions never populate the cache. + +## Caveats + +- **Default store is not coherent across replicas.** Multiple processes / pods do not share state. Use a custom `CacheStore` (Redis, etc.) for cross-process coherence. +- **Concurrent misses both populate the store.** Two concurrent first-time reads of the same key both run the driver and both commit; last writer wins. Single-flight / coalescing semantics are deferred to a follow-up. +- **Reads of stale-on-arrival entries.** With a custom replicated store, a follower may serve a stale entry for a brief window after the writer commits. Use the storage-hash discrimination plus a sensible TTL. +- **No invalidation beyond TTL.** Entries are not invalidated by writes; tag-based or event-based invalidation is out of scope for this milestone. If a write invalidates a cached read, choose a TTL short enough to bound the staleness window, or pass `cacheAnnotation.apply({ skip: true })` on the read that needs to be authoritative. + +## See also + +- [Spec](../../../projects/middleware-intercept-and-cache/spec.md) and [plan](../../../projects/middleware-intercept-and-cache/plan.md) for the design rationale and milestone-by-milestone rollout. +- [Runtime & Middleware Framework](../../../docs/architecture%20docs/subsystems/4.%20Runtime%20&%20Middleware%20Framework.md) for the SPI and middleware lifecycle (including the `intercept` hook the cache uses). +- [ADR 204 — Single-tier runtime](../../../docs/architecture%20docs/adrs/ADR%20204%20-%20Single-tier%20runtime.md) for why the cache middleware is family-agnostic by construction. +- `@prisma-next/middleware-telemetry` — companion observability middleware that observes `source: 'driver' | 'middleware'` on `afterExecute` and can be composed alongside the cache. diff --git a/packages/3-extensions/middleware-cache/biome.jsonc b/packages/3-extensions/middleware-cache/biome.jsonc new file mode 100644 index 0000000000..b8994a7330 --- /dev/null +++ b/packages/3-extensions/middleware-cache/biome.jsonc @@ -0,0 +1,4 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json", + "extends": "//" +} diff --git a/packages/3-extensions/middleware-cache/package.json b/packages/3-extensions/middleware-cache/package.json new file mode 100644 index 0000000000..ff8eb60851 --- /dev/null +++ b/packages/3-extensions/middleware-cache/package.json @@ -0,0 +1,43 @@ +{ + "name": "@prisma-next/middleware-cache", + "version": "0.0.1", + "type": "module", + "sideEffects": false, + "description": "Family-agnostic caching middleware for Prisma Next runtimes", + "scripts": { + "build": "tsdown", + "test": "vitest run", + "test:coverage": "vitest run --coverage", + "typecheck": "tsc --project tsconfig.json --noEmit", + "lint": "biome check . --error-on-warnings", + "lint:fix": "biome check --write .", + "lint:fix:unsafe": "biome check --write --unsafe .", + "clean": "rm -rf dist dist-tsc dist-tsc-prod coverage .tmp-output" + }, + "dependencies": { + "@prisma-next/framework-components": "workspace:*" + }, + "devDependencies": { + "@prisma-next/tsconfig": "workspace:*", + "@prisma-next/tsdown": "workspace:*", + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "files": [ + "dist", + "src" + ], + "exports": { + ".": "./dist/index.mjs", + "./package.json": "./package.json" + }, + "main": "./dist/index.mjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "repository": { + "type": "git", + "url": "https://github.com/prisma/prisma-next.git", + "directory": "packages/3-extensions/middleware-cache" + } +} diff --git a/packages/3-extensions/middleware-cache/src/exports/index.ts b/packages/3-extensions/middleware-cache/src/exports/index.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/3-extensions/middleware-cache/src/exports/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/3-extensions/middleware-cache/tsconfig.json b/packages/3-extensions/middleware-cache/tsconfig.json new file mode 100644 index 0000000000..7afa587436 --- /dev/null +++ b/packages/3-extensions/middleware-cache/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": ["@prisma-next/tsconfig/base"], + "compilerOptions": { + "rootDir": ".", + "outDir": "dist" + }, + "include": ["src/**/*.ts", "test/**/*.ts"], + "exclude": ["dist"] +} diff --git a/packages/3-extensions/middleware-cache/tsconfig.prod.json b/packages/3-extensions/middleware-cache/tsconfig.prod.json new file mode 100644 index 0000000000..b08d4c908a --- /dev/null +++ b/packages/3-extensions/middleware-cache/tsconfig.prod.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": ["@prisma-next/tsconfig/prod"] +} diff --git a/packages/3-extensions/middleware-cache/tsdown.config.ts b/packages/3-extensions/middleware-cache/tsdown.config.ts new file mode 100644 index 0000000000..696fb96853 --- /dev/null +++ b/packages/3-extensions/middleware-cache/tsdown.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from '@prisma-next/tsdown'; + +export default defineConfig({ + entry: ['src/exports/index.ts'], +}); diff --git a/packages/3-extensions/middleware-cache/vitest.config.ts b/packages/3-extensions/middleware-cache/vitest.config.ts new file mode 100644 index 0000000000..bb99ddc5b6 --- /dev/null +++ b/packages/3-extensions/middleware-cache/vitest.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + include: ['src/**/*.ts'], + exclude: [ + 'dist/**', + 'test/**', + '**/*.test.ts', + '**/*.test-d.ts', + '**/*.config.ts', + '**/exports/**', + ], + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3dc9f3d7c0..42814fd762 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2395,6 +2395,28 @@ importers: specifier: 'catalog:' version: 4.0.17(@types/node@24.10.4)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(tsx@4.20.6)(yaml@2.8.1) + packages/3-extensions/middleware-cache: + dependencies: + '@prisma-next/framework-components': + specifier: workspace:* + version: link:../../1-framework/1-core/framework-components + devDependencies: + '@prisma-next/tsconfig': + specifier: workspace:* + version: link:../../0-config/tsconfig + '@prisma-next/tsdown': + specifier: workspace:* + version: link:../../0-config/tsdown + tsdown: + specifier: 'catalog:' + version: 0.18.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(typescript@5.9.3) + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.0.17(@types/node@24.10.4)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(tsx@4.20.6)(yaml@2.8.1) + packages/3-extensions/middleware-telemetry: dependencies: '@prisma-next/contract': From f8f1a65f3d123cd4918279806a46cef06694d6ab Mon Sep 17 00:00:00 2001 From: Alexey Orlenko Date: Tue, 28 Apr 2026 17:13:32 +0200 Subject: [PATCH 11/21] feat(middleware-cache): add cacheAnnotation handle and CachePayload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cacheAnnotation = defineAnnotation({ namespace: 'cache', applicableTo: ['read'], }). CachePayload carries optional ttl, skip, and key fields. Declared with applicableTo: ['read'] so the lane gate (M2) rejects passing it to write terminals at both type and runtime levels — cache-mutation is structurally impossible without an 'as any' cast bypass. The cache middleware reads the payload via cacheAnnotation.read(plan). Closes M3.4 of the cache-middleware project. --- .../middleware-cache/package.json | 1 + .../middleware-cache/src/cache-annotation.ts | 61 ++++++++++++++ .../middleware-cache/src/exports/index.ts | 3 +- .../test/cache-annotation.test.ts | 65 +++++++++++++++ .../test/cache-annotation.types.test-d.ts | 83 +++++++++++++++++++ pnpm-lock.yaml | 3 + 6 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 packages/3-extensions/middleware-cache/src/cache-annotation.ts create mode 100644 packages/3-extensions/middleware-cache/test/cache-annotation.test.ts create mode 100644 packages/3-extensions/middleware-cache/test/cache-annotation.types.test-d.ts diff --git a/packages/3-extensions/middleware-cache/package.json b/packages/3-extensions/middleware-cache/package.json index ff8eb60851..b587f0026e 100644 --- a/packages/3-extensions/middleware-cache/package.json +++ b/packages/3-extensions/middleware-cache/package.json @@ -18,6 +18,7 @@ "@prisma-next/framework-components": "workspace:*" }, "devDependencies": { + "@prisma-next/contract": "workspace:*", "@prisma-next/tsconfig": "workspace:*", "@prisma-next/tsdown": "workspace:*", "tsdown": "catalog:", diff --git a/packages/3-extensions/middleware-cache/src/cache-annotation.ts b/packages/3-extensions/middleware-cache/src/cache-annotation.ts new file mode 100644 index 0000000000..b5367d1fea --- /dev/null +++ b/packages/3-extensions/middleware-cache/src/cache-annotation.ts @@ -0,0 +1,61 @@ +import { defineAnnotation } from '@prisma-next/framework-components/runtime'; + +/** + * Payload accepted by `cacheAnnotation.apply(...)`. + * + * - `ttl` — Time-to-live for the cached entry, in milliseconds. When + * omitted, the cache middleware passes the query through untouched — + * presence of the annotation alone is not sufficient to enable caching. + * This makes the cache strictly opt-in per query. + * - `skip` — When `true`, the cache middleware passes the query through + * untouched even if a `ttl` is set. Useful for selectively bypassing + * the cache on a per-call basis without removing the annotation + * entirely (e.g. a "force refresh" knob in user code). + * - `key` — Per-query override of the cache key. When supplied, replaces + * the default `RuntimeMiddlewareContext.contentHash(exec)` digest. + * The supplied string is stored as-is — the cache middleware does + * **not** rehash it, so the caller is responsible for ensuring the + * string is bounded in size and free of sensitive data they do not + * want flowing into logs / Redis `KEYS` / persistence dumps. + */ +export interface CachePayload { + readonly ttl?: number; + readonly skip?: boolean; + readonly key?: string; +} + +/** + * Read-only annotation handle for the cache middleware. + * + * Declared with `applicableTo: ['read']`, which gates the type-level + * `ValidAnnotations<'write', As>` and the runtime + * `assertAnnotationsApplicable(annotations, 'write', ...)` checks at every + * lane write terminal — making "cache a mutation" structurally + * impossible without a `as any` cast bypass at both type *and* runtime + * levels. + * + * Stored under namespace `'cache'` in `plan.meta.annotations`. The cache + * middleware reads it via `cacheAnnotation.read(plan)`. + * + * @example + * ```typescript + * import { cacheAnnotation } from '@prisma-next/middleware-cache'; + * + * // ORM read terminal — accepts the read-only annotation. + * const user = await db.User.first( + * { id }, + * cacheAnnotation.apply({ ttl: 60_000 }), + * ); + * + * // SQL DSL select builder — chainable. + * const plan = db.sql + * .from(tables.user) + * .annotate(cacheAnnotation.apply({ ttl: 60_000 })) + * .select({ id: tables.user.columns.id }) + * .build(); + * ``` + */ +export const cacheAnnotation = defineAnnotation({ + namespace: 'cache', + applicableTo: ['read'], +}); diff --git a/packages/3-extensions/middleware-cache/src/exports/index.ts b/packages/3-extensions/middleware-cache/src/exports/index.ts index cb0ff5c3b5..e9653123fd 100644 --- a/packages/3-extensions/middleware-cache/src/exports/index.ts +++ b/packages/3-extensions/middleware-cache/src/exports/index.ts @@ -1 +1,2 @@ -export {}; +export type { CachePayload } from '../cache-annotation'; +export { cacheAnnotation } from '../cache-annotation'; diff --git a/packages/3-extensions/middleware-cache/test/cache-annotation.test.ts b/packages/3-extensions/middleware-cache/test/cache-annotation.test.ts new file mode 100644 index 0000000000..98d9a2d019 --- /dev/null +++ b/packages/3-extensions/middleware-cache/test/cache-annotation.test.ts @@ -0,0 +1,65 @@ +import type { PlanMeta } from '@prisma-next/contract/types'; +import { describe, expect, it } from 'vitest'; +import { type CachePayload, cacheAnnotation } from '../src/cache-annotation'; + +const baseMeta: PlanMeta = { + target: 'postgres', + targetFamily: 'sql', + storageHash: 'sha256:test', + lane: 'orm', +}; + +function planWith(annotations: Record): { readonly meta: PlanMeta } { + return { meta: { ...baseMeta, annotations } }; +} + +describe('cacheAnnotation handle', () => { + it('declares namespace "cache"', () => { + expect(cacheAnnotation.namespace).toBe('cache'); + }); + + it('declares applicableTo = ["read"]', () => { + expect(Array.from(cacheAnnotation.applicableTo)).toEqual(['read']); + }); + + it('produces an applied annotation under namespace "cache" carrying the payload', () => { + const applied = cacheAnnotation.apply({ ttl: 60 }); + + expect(applied.namespace).toBe('cache'); + expect(applied.value).toEqual({ ttl: 60 }); + expect(Array.from(applied.applicableTo)).toEqual(['read']); + }); + + it('round-trips a payload via apply -> read on a plan', () => { + const applied = cacheAnnotation.apply({ ttl: 60 }); + const plan = planWith({ cache: applied }); + + const recovered = cacheAnnotation.read(plan); + expect(recovered).toEqual({ ttl: 60 }); + }); + + it('returns undefined when reading a plan without a cache annotation', () => { + const plan = planWith({}); + expect(cacheAnnotation.read(plan)).toBeUndefined(); + }); + + it('returns undefined when the plan has no annotations bag at all', () => { + const plan: { readonly meta: PlanMeta } = { meta: baseMeta }; + expect(cacheAnnotation.read(plan)).toBeUndefined(); + }); + + it('preserves all CachePayload fields (ttl, skip, key)', () => { + const payload: CachePayload = { ttl: 120, skip: false, key: 'custom-key' }; + const applied = cacheAnnotation.apply(payload); + const plan = planWith({ cache: applied }); + + expect(cacheAnnotation.read(plan)).toEqual(payload); + }); + + it('accepts an empty payload', () => { + const applied = cacheAnnotation.apply({}); + const plan = planWith({ cache: applied }); + + expect(cacheAnnotation.read(plan)).toEqual({}); + }); +}); diff --git a/packages/3-extensions/middleware-cache/test/cache-annotation.types.test-d.ts b/packages/3-extensions/middleware-cache/test/cache-annotation.types.test-d.ts new file mode 100644 index 0000000000..63d683bc2d --- /dev/null +++ b/packages/3-extensions/middleware-cache/test/cache-annotation.types.test-d.ts @@ -0,0 +1,83 @@ +import type { AnnotationValue, OperationKind } from '@prisma-next/framework-components/runtime'; +import { assertType, expectTypeOf, test } from 'vitest'; +import { type CachePayload, cacheAnnotation } from '../src/cache-annotation'; + +test('cacheAnnotation.apply preserves the CachePayload type', () => { + const applied = cacheAnnotation.apply({ ttl: 60 }); + expectTypeOf(applied).toEqualTypeOf>(); +}); + +test('cacheAnnotation.apply rejects non-CachePayload arguments', () => { + // @ts-expect-error - unknown field on payload + cacheAnnotation.apply({ ttl: 60, nonsense: true }); + + // @ts-expect-error - wrong field type + cacheAnnotation.apply({ ttl: '60' }); + + // @ts-expect-error - wrong field type + cacheAnnotation.apply({ skip: 'yes' }); +}); + +test('cacheAnnotation.read returns CachePayload | undefined', () => { + const plan = { + meta: { + target: 'postgres', + targetFamily: 'sql' as const, + storageHash: 'sha256:test', + lane: 'orm', + paramDescriptors: [], + annotations: {} as Record, + }, + }; + const result = cacheAnnotation.read(plan); + expectTypeOf(result).toEqualTypeOf(); +}); + +test('cacheAnnotation declares applicableTo = "read" only', () => { + // The handle's Kinds parameter is the literal type 'read', not the wider + // OperationKind union. This is what gates write terminals from accepting + // it via ValidAnnotations<'write', As>. + expectTypeOf(cacheAnnotation.applicableTo).toEqualTypeOf>(); +}); + +test('CachePayload has optional ttl, skip, and key', () => { + const empty: CachePayload = {}; + void empty; + + const ttlOnly: CachePayload = { ttl: 60 }; + void ttlOnly; + + const skipOnly: CachePayload = { skip: true }; + void skipOnly; + + const keyOnly: CachePayload = { key: 'k' }; + void keyOnly; + + const all: CachePayload = { ttl: 60, skip: false, key: 'k' }; + void all; +}); + +test('cacheAnnotation is not applicable to write operations at the type level', () => { + // The handle's literal Kinds = 'read'. The applicableTo set type is + // `ReadonlySet<'read'>`, not `ReadonlySet` — so a + // consumer asking whether 'write' is in the kind set sees `false`. + type Kinds = typeof cacheAnnotation extends { + readonly applicableTo: ReadonlySet; + } + ? K + : never; + type WriteApplies = 'write' extends Kinds ? true : false; + assertType(false as WriteApplies); + + // And the AnnotationValue produced by apply() carries 'read' specifically, + // so ValidAnnotations<'write', [typeof applied]> resolves to [never]. + const applied = cacheAnnotation.apply({ ttl: 60 }); + expectTypeOf(applied).toExtend>(); + // The applied value is NOT assignable to AnnotationValue. + expectTypeOf(applied).not.toExtend>(); +}); + +test('OperationKind import is not accidentally widened by cacheAnnotation', () => { + // Sanity: the framework's OperationKind union is unchanged. + expectTypeOf().toEqualTypeOf<'read' | 'write'>(); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 42814fd762..2ad7fc225f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2401,6 +2401,9 @@ importers: specifier: workspace:* version: link:../../1-framework/1-core/framework-components devDependencies: + '@prisma-next/contract': + specifier: workspace:* + version: link:../../1-framework/0-foundation/contract '@prisma-next/tsconfig': specifier: workspace:* version: link:../../0-config/tsconfig From 8b6be5ada6cdccebe7a704625d802f2534ed27e2 Mon Sep 17 00:00:00 2001 From: Alexey Orlenko Date: Tue, 28 Apr 2026 17:16:22 +0200 Subject: [PATCH 12/21] feat(middleware-cache): add CacheStore interface and in-memory LRU default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defines: - CacheStore — pluggable interface (get, set) for the cache backend. - CachedEntry — { rows, storedAt } shape stored under a key. - createInMemoryCacheStore({ maxEntries, clock? }) — default in-process LRU-with-TTL store backed by a Map. Iteration order is the LRU order; re-set and get both bump recency. Expired entries are dropped on access without counting against maxEntries on the next write. The clock is injectable so tests can verify TTL expiry without real-time waits. The default in-memory store is per-process and not coherent across replicas — users who need a shared cache provide a custom CacheStore (Redis, Memcached, etc.). Closes M3.5 of the cache-middleware project. --- .../middleware-cache/src/cache-store.ts | 139 +++++++++++ .../middleware-cache/src/exports/index.ts | 2 + .../middleware-cache/test/cache-store.test.ts | 227 ++++++++++++++++++ 3 files changed, 368 insertions(+) create mode 100644 packages/3-extensions/middleware-cache/src/cache-store.ts create mode 100644 packages/3-extensions/middleware-cache/test/cache-store.test.ts diff --git a/packages/3-extensions/middleware-cache/src/cache-store.ts b/packages/3-extensions/middleware-cache/src/cache-store.ts new file mode 100644 index 0000000000..c9e2c865c2 --- /dev/null +++ b/packages/3-extensions/middleware-cache/src/cache-store.ts @@ -0,0 +1,139 @@ +/** + * A cached set of rows produced by a single execution. + * + * - `rows` are stored raw (undecoded). The SQL runtime's `decodeRow` pass + * wraps the orchestrator output, so intercepted rows go through the + * same codec decoding as driver rows on the way to the consumer. The + * cache stores wire-format values; decoding happens once per consumer + * read regardless of where the rows came from. + * - `storedAt` is the clock value at the moment the entry was committed + * to the store. It is informational metadata for callers (debugging, + * telemetry) and is **not** used by the in-memory store itself for + * expiry — TTL is driven by the store's own clock plus the `ttlMs` + * passed to `set`. Custom stores may use it differently. + */ +export interface CachedEntry { + readonly rows: readonly Record[]; + readonly storedAt: number; +} + +/** + * Pluggable cache backend used by the cache middleware. + * + * The default implementation is an in-memory LRU with TTL produced by + * `createInMemoryCacheStore`. Users can supply Redis, Memcached, or any + * other backend by implementing this interface. + * + * The interface is intentionally minimal: + * + * - `get` returns the entry if it exists and has not expired, or + * `undefined` otherwise. Implementations that gate on TTL should + * treat an expired entry as absent (return `undefined`) and may + * evict it as a side effect. + * - `set` writes the entry under the key with an associated TTL in + * milliseconds. Implementations may evict other entries to make + * room (LRU, LFU, etc.) and may treat the operation as fire-and- + * forget at scale; the cache middleware does not rely on `set` + * completing before subsequent `get`s. + * + * Both methods are async to leave the door open for I/O-backed stores + * (Redis, S3, etc.). The default in-memory store completes + * synchronously and wraps the result in `Promise.resolve` for type + * conformance. + */ +export interface CacheStore { + get(key: string): Promise; + set(key: string, entry: CachedEntry, ttlMs: number): Promise; +} + +/** + * Options accepted by `createInMemoryCacheStore`. + * + * - `maxEntries` — hard cap on the number of live entries. Once the cap + * is exceeded, the least recently used entry is evicted. Reads and + * writes both count as "uses" for ordering purposes. + * - `clock` — injectable time source for TTL math. Defaults to + * `Date.now`. Tests inject a controlled clock to verify expiry without + * real-time waits. + */ +export interface InMemoryCacheStoreOptions { + readonly maxEntries: number; + readonly clock?: () => number; +} + +interface StoredRecord { + readonly entry: CachedEntry; + readonly expiresAt: number; +} + +/** + * Default cache backend. An LRU with per-entry TTL, backed by a `Map`. + * + * Eviction policy: + * + * - On `set` of a fresh key whose insertion would push the live count + * above `maxEntries`, the least recently used entry is evicted. + * Setting an existing key updates the entry in place and refreshes its + * recency without changing the live count. + * - On `get` of an existing key, recency is bumped (so the entry is no + * longer the LRU candidate). + * - On `get` of an expired entry, the entry is removed from the map and + * `undefined` is returned. The slot becomes available for new writes + * without counting against `maxEntries`. + * + * `Map` insertion order is the LRU order: the first key is the LRU + * candidate; the last key is the most recently used. Bumping recency is + * a delete-then-set on the underlying map. + * + * The default store is **not** coherent across processes or replicas — + * each process holds its own Map. Users who need a shared cache supply + * their own `CacheStore` (Redis, Memcached, etc.). + */ +export function createInMemoryCacheStore(options: InMemoryCacheStoreOptions): CacheStore { + const maxEntries = options.maxEntries; + const clock = options.clock ?? Date.now; + const map = new Map(); + + function get(key: string): Promise { + const record = map.get(key); + if (record === undefined) { + return Promise.resolve(undefined); + } + if (clock() >= record.expiresAt) { + map.delete(key); + return Promise.resolve(undefined); + } + // Bump recency: re-insert at the end of the iteration order. + map.delete(key); + map.set(key, record); + return Promise.resolve(record.entry); + } + + function set(key: string, entry: CachedEntry, ttlMs: number): Promise { + const expiresAt = clock() + ttlMs; + // Re-set semantics: if the key is already present, deleting first + // ensures the new value lands at the end of the iteration order + // (most recently used) rather than retaining the old slot's + // position. This matters for LRU correctness when the same key is + // re-cached after a refresh. + if (map.has(key)) { + map.delete(key); + } + map.set(key, { entry, expiresAt }); + + // Evict LRU entries until the live count is within bounds. The + // iterator yields keys in insertion order; the first one is the + // oldest (LRU). + while (map.size > maxEntries) { + const oldest = map.keys().next(); + if (oldest.done) { + break; + } + map.delete(oldest.value); + } + + return Promise.resolve(); + } + + return { get, set }; +} diff --git a/packages/3-extensions/middleware-cache/src/exports/index.ts b/packages/3-extensions/middleware-cache/src/exports/index.ts index e9653123fd..7a27edc6b8 100644 --- a/packages/3-extensions/middleware-cache/src/exports/index.ts +++ b/packages/3-extensions/middleware-cache/src/exports/index.ts @@ -1,2 +1,4 @@ export type { CachePayload } from '../cache-annotation'; export { cacheAnnotation } from '../cache-annotation'; +export type { CachedEntry, CacheStore, InMemoryCacheStoreOptions } from '../cache-store'; +export { createInMemoryCacheStore } from '../cache-store'; diff --git a/packages/3-extensions/middleware-cache/test/cache-store.test.ts b/packages/3-extensions/middleware-cache/test/cache-store.test.ts new file mode 100644 index 0000000000..f27fbda0b0 --- /dev/null +++ b/packages/3-extensions/middleware-cache/test/cache-store.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from 'vitest'; +import { type CachedEntry, type CacheStore, createInMemoryCacheStore } from '../src/cache-store'; + +function entry(rows: ReadonlyArray>, storedAt = 0): CachedEntry { + return { rows, storedAt }; +} + +describe('createInMemoryCacheStore', () => { + describe('basic get/set', () => { + it('returns undefined for a missing key', async () => { + const store = createInMemoryCacheStore({ maxEntries: 10 }); + expect(await store.get('absent')).toBeUndefined(); + }); + + it('round-trips a stored entry by key', async () => { + const store = createInMemoryCacheStore({ maxEntries: 10 }); + const stored = entry([{ id: 1 }, { id: 2 }], 0); + await store.set('k', stored, 60_000); + const got = await store.get('k'); + expect(got).toEqual(stored); + }); + + it('overwrites an existing entry on repeated set with the same key', async () => { + const store = createInMemoryCacheStore({ maxEntries: 10 }); + await store.set('k', entry([{ v: 1 }]), 60_000); + await store.set('k', entry([{ v: 2 }]), 60_000); + const got = await store.get('k'); + expect(got?.rows).toEqual([{ v: 2 }]); + }); + + it('keeps distinct entries for distinct keys', async () => { + const store = createInMemoryCacheStore({ maxEntries: 10 }); + await store.set('a', entry([{ v: 'A' }]), 60_000); + await store.set('b', entry([{ v: 'B' }]), 60_000); + expect((await store.get('a'))?.rows).toEqual([{ v: 'A' }]); + expect((await store.get('b'))?.rows).toEqual([{ v: 'B' }]); + }); + + it('satisfies the CacheStore interface', () => { + const store: CacheStore = createInMemoryCacheStore({ maxEntries: 10 }); + expect(typeof store.get).toBe('function'); + expect(typeof store.set).toBe('function'); + }); + }); + + describe('LRU eviction at maxEntries', () => { + it('evicts the least recently used entry once maxEntries is exceeded', async () => { + const store = createInMemoryCacheStore({ maxEntries: 2 }); + await store.set('a', entry([{ v: 'A' }]), 60_000); + await store.set('b', entry([{ v: 'B' }]), 60_000); + await store.set('c', entry([{ v: 'C' }]), 60_000); + + expect(await store.get('a')).toBeUndefined(); + expect((await store.get('b'))?.rows).toEqual([{ v: 'B' }]); + expect((await store.get('c'))?.rows).toEqual([{ v: 'C' }]); + }); + + it('treats a get on an existing entry as a "use" for LRU ordering', async () => { + const store = createInMemoryCacheStore({ maxEntries: 2 }); + await store.set('a', entry([{ v: 'A' }]), 60_000); + await store.set('b', entry([{ v: 'B' }]), 60_000); + + // Touch 'a' so it becomes most recently used; 'b' is now LRU. + await store.get('a'); + + // Inserting 'c' should evict 'b' (LRU), not 'a' (most recent). + await store.set('c', entry([{ v: 'C' }]), 60_000); + + expect((await store.get('a'))?.rows).toEqual([{ v: 'A' }]); + expect(await store.get('b')).toBeUndefined(); + expect((await store.get('c'))?.rows).toEqual([{ v: 'C' }]); + }); + + it('treats overwriting an existing entry as a "use" for LRU ordering', async () => { + const store = createInMemoryCacheStore({ maxEntries: 2 }); + await store.set('a', entry([{ v: 'A' }]), 60_000); + await store.set('b', entry([{ v: 'B' }]), 60_000); + + // Re-set 'a' so it becomes most recently used. + await store.set('a', entry([{ v: 'A2' }]), 60_000); + + // Inserting 'c' should now evict 'b'. + await store.set('c', entry([{ v: 'C' }]), 60_000); + + expect((await store.get('a'))?.rows).toEqual([{ v: 'A2' }]); + expect(await store.get('b')).toBeUndefined(); + expect((await store.get('c'))?.rows).toEqual([{ v: 'C' }]); + }); + + it('caps the live entry count at maxEntries', async () => { + const store = createInMemoryCacheStore({ maxEntries: 3 }); + for (let i = 0; i < 10; i++) { + await store.set(`k${i}`, entry([{ i }]), 60_000); + } + // Only the most recent 3 keys (k7, k8, k9) survive. + expect(await store.get('k0')).toBeUndefined(); + expect(await store.get('k6')).toBeUndefined(); + expect((await store.get('k7'))?.rows).toEqual([{ i: 7 }]); + expect((await store.get('k8'))?.rows).toEqual([{ i: 8 }]); + expect((await store.get('k9'))?.rows).toEqual([{ i: 9 }]); + }); + }); + + describe('TTL expiry', () => { + it('returns undefined for an entry whose TTL has elapsed (relative to clock)', async () => { + let now = 0; + const store = createInMemoryCacheStore({ + maxEntries: 10, + clock: () => now, + }); + + await store.set('k', entry([{ v: 1 }], now), 1_000); + // Within TTL. + now = 999; + expect((await store.get('k'))?.rows).toEqual([{ v: 1 }]); + + // At TTL — boundary inclusive (treat reached TTL as expired). + now = 1_000; + expect(await store.get('k')).toBeUndefined(); + }); + + it('returns undefined for an entry whose TTL is well past', async () => { + let now = 0; + const store = createInMemoryCacheStore({ + maxEntries: 10, + clock: () => now, + }); + + await store.set('k', entry([{ v: 1 }], now), 100); + now = 100_000; + expect(await store.get('k')).toBeUndefined(); + }); + + it('does not expire entries before their TTL', async () => { + let now = 0; + const store = createInMemoryCacheStore({ + maxEntries: 10, + clock: () => now, + }); + + await store.set('k', entry([{ v: 1 }], now), 60_000); + now = 30_000; + expect((await store.get('k'))?.rows).toEqual([{ v: 1 }]); + }); + + it('uses the current clock time at set() as the TTL reference', async () => { + let now = 1_000; + const store = createInMemoryCacheStore({ + maxEntries: 10, + clock: () => now, + }); + + // storedAt embedded in the entry by the caller; the store decides + // expiry based on its own clock + the ttlMs passed to set. + await store.set('k', entry([{ v: 1 }], now), 500); + + now = 1_499; + expect(await store.get('k')).toBeDefined(); + + now = 1_500; + expect(await store.get('k')).toBeUndefined(); + }); + + it('drops an expired entry on access (does not retain it for future re-set)', async () => { + let now = 0; + const store = createInMemoryCacheStore({ + maxEntries: 10, + clock: () => now, + }); + + await store.set('k', entry([{ v: 1 }], now), 100); + now = 200; + expect(await store.get('k')).toBeUndefined(); + + // After expiry, the slot is free; setting again works without + // counting the expired entry against maxEntries. + await store.set('k', entry([{ v: 2 }], now), 100); + expect((await store.get('k'))?.rows).toEqual([{ v: 2 }]); + }); + }); + + describe('clock injection', () => { + it('defaults to Date.now() when no clock is supplied', async () => { + const store = createInMemoryCacheStore({ maxEntries: 10 }); + const before = Date.now(); + await store.set('k', entry([{ v: 1 }]), 60_000); + const got = await store.get('k'); + const after = Date.now(); + + expect(got).toBeDefined(); + // The wall-clock check is sufficient to confirm the default clock is + // wired without flakiness — TTL of 60s vs a sub-millisecond test. + expect(after - before).toBeLessThan(60_000); + }); + + it('drives expiry purely from the injected clock', async () => { + let tick = 0; + const store = createInMemoryCacheStore({ + maxEntries: 10, + clock: () => tick, + }); + + await store.set('k', entry([{ v: 1 }]), 10); + tick = 5; + expect(await store.get('k')).toBeDefined(); + tick = 10; + expect(await store.get('k')).toBeUndefined(); + }); + }); + + describe('row immutability', () => { + it('does not lose information across get() round-trips', async () => { + const store = createInMemoryCacheStore({ maxEntries: 10 }); + const original = entry( + [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ], + 42, + ); + await store.set('k', original, 60_000); + const recovered = await store.get('k'); + expect(recovered).toEqual(original); + expect(recovered?.storedAt).toBe(42); + }); + }); +}); From 362a34dbcf4807261772368f2dbd70dfaea7fae0 Mon Sep 17 00:00:00 2001 From: Alexey Orlenko Date: Tue, 28 Apr 2026 17:18:05 +0200 Subject: [PATCH 13/21] feat(middleware-cache): implement createCacheMiddleware with intercept/onRow/afterExecute Wires the three RuntimeMiddleware hooks for opt-in caching: - intercept: bypass when ctx.scope !== 'runtime', when no cacheAnnotation is present, when skip: true, or when ttl is missing. Otherwise resolve the key (per-query 'key' override > ctx.identityKey(exec)) and probe the store. On a hit, log 'middleware.cache.hit' and return the cached rows. On a miss, record a per-exec PendingMiss in a private WeakMap and log 'middleware.cache.miss'. - onRow: append each driver row to the pending buffer for that exec. - afterExecute: commit the buffer to the store iff completed === true AND source === 'driver'. Cleans up the WeakMap entry in all branches so a failed or middleware-served execution leaves no residue. Cross-family by construction: no familyId/targetId, depends only on @prisma-next/framework-components, reads keys from ctx.identityKey (populated by SqlRuntimeImpl and MongoRuntimeImpl). Defaults to a built-in in-memory LRU store with maxEntries=1000; options accept a custom CacheStore for Redis / Memcached / etc. Closes M3.6, M3.7, M3.9, M3.10, M3.11 of the cache-middleware project. --- .../middleware-cache/src/cache-middleware.ts | 235 +++++++++ .../middleware-cache/src/exports/index.ts | 2 + .../middleware-cache/test/cache-key.test.ts | 296 +++++++++++ .../test/cache-middleware.test.ts | 491 ++++++++++++++++++ 4 files changed, 1024 insertions(+) create mode 100644 packages/3-extensions/middleware-cache/src/cache-middleware.ts create mode 100644 packages/3-extensions/middleware-cache/test/cache-key.test.ts create mode 100644 packages/3-extensions/middleware-cache/test/cache-middleware.test.ts diff --git a/packages/3-extensions/middleware-cache/src/cache-middleware.ts b/packages/3-extensions/middleware-cache/src/cache-middleware.ts new file mode 100644 index 0000000000..95f05e3637 --- /dev/null +++ b/packages/3-extensions/middleware-cache/src/cache-middleware.ts @@ -0,0 +1,235 @@ +import type { + AfterExecuteResult, + ExecutionPlan, + RuntimeMiddleware, + RuntimeMiddlewareContext, +} from '@prisma-next/framework-components/runtime'; +import { type CachePayload, cacheAnnotation } from './cache-annotation'; +import { type CacheStore, createInMemoryCacheStore } from './cache-store'; + +/** + * Options accepted by `createCacheMiddleware`. + * + * - `store` — pluggable cache backend. Defaults to an in-process LRU + * produced by `createInMemoryCacheStore`. Users supply Redis, + * Memcached, or any other backend by implementing the `CacheStore` + * interface. + * - `maxEntries` — only consulted when `store` is omitted. Sets the + * `maxEntries` cap on the default in-memory store. Defaults to 1000. + * - `clock` — injectable time source for `storedAt` stamping on + * committed entries. Defaults to `Date.now`. Tests inject a controlled + * clock to make commit-time observable. Note: TTL math lives inside + * the store, not the middleware — supplying a clock here only affects + * the `storedAt` field on committed `CachedEntry` values. + */ +export interface CacheMiddlewareOptions { + readonly store?: CacheStore; + readonly maxEntries?: number; + readonly clock?: () => number; +} + +/** + * Per-execution buffer correlated with the post-lowering `exec` object + * via a private `WeakMap`. Each in-flight cache miss owns one of these. + * + * The plan-identity invariant required by this `WeakMap` correlation is + * documented in the runtime subsystem doc and pinned by a regression + * test: family runtimes produce a fresh, frozen `exec` per call (SQL + * `executeAgainstQueryable` constructs `Object.freeze({...lowered, ...})` + * on each invocation; Mongo lowers fresh per call). If a future plan- + * memoization change ever recycles `exec` objects across calls, this + * correlation would silently leak rows between concurrent executions + * — which is exactly what the regression test catches. + */ +interface PendingMiss { + readonly key: string; + readonly ttlMs: number; + readonly buffer: Record[]; +} + +/** + * Default `maxEntries` for the built-in in-memory store. Bounded so a + * runaway producer cannot exhaust process memory; users who need + * different bounds supply a custom `CacheStore`. + */ +const DEFAULT_MAX_ENTRIES = 1000; + +/** + * Reads the cache payload from the plan, if present and branded. + * + * Returns `undefined` when: + * - the plan has no `meta.annotations`, or + * - the `cache` namespace key is absent, or + * - the value under `cache` is not a branded `AnnotationValue` (the + * `cacheAnnotation.read` defensive check covers this). + */ +function readCachePayload(plan: ExecutionPlan): CachePayload | undefined { + return cacheAnnotation.read(plan); +} + +/** + * Computes the cache key for an execution. + * + * Two-tier resolution: + * + * 1. Per-query override: `cacheAnnotation.apply({ key })` — the supplied + * string is used verbatim. Not rehashed; the user is responsible for + * keeping the string bounded and free of sensitive data. + * 2. Default: `ctx.contentHash(exec)` — the family runtime owns this and + * returns an opaque, bounded digest (SHA-512 in the SQL and Mongo + * runtimes today). + * + * The returned string is consumed directly as the `Map` key + * by the underlying `CacheStore`; the cache middleware does not perform + * any further transformation. + */ +async function resolveCacheKey( + payload: CachePayload, + exec: ExecutionPlan, + ctx: RuntimeMiddlewareContext, +): Promise { + if (payload.key !== undefined) { + return payload.key; + } + return ctx.contentHash(exec); +} + +/** + * Creates a family-agnostic caching middleware. + * + * The middleware uses three hooks: + * + * - `intercept` — on each execution, checks the cache. On a hit, returns + * the cached raw rows; the runtime skips `beforeExecute`, `runDriver`, + * and `onRow`, and yields the cached rows to the consumer (which, in + * the SQL runtime, sees them after the standard `decodeRow` pass — + * i.e. the cache stores wire-format values). On a miss, records a + * pending buffer keyed on the `exec` object identity and returns + * `undefined` (passthrough). + * - `onRow` — on the miss path, appends each row yielded by the driver + * to the pending buffer. + * - `afterExecute` — on the miss path, commits the buffer to the store + * if and only if `result.completed === true && result.source === 'driver'`. + * Failed executions and middleware-served executions never populate + * the cache. The pending buffer is cleared in all branches so a stale + * `WeakMap` entry cannot leak between executions sharing an `exec`. + * + * The middleware bypasses the cache entirely when: + * - the plan has no `cache` annotation, or + * - the annotation has `skip: true`, or + * - the annotation has no `ttl`, or + * - `ctx.scope !== 'runtime'` (connection / transaction scopes opt out). + * + * Returns a cross-family `RuntimeMiddleware` (no `familyId` / + * `targetId`). The package depends only on + * `@prisma-next/framework-components/runtime`; cache keys come from + * `ctx.contentHash(exec)`, populated by the family runtime, so SQL and + * Mongo runtimes both work out of the box. + * + * @example + * ```typescript + * import { createCacheMiddleware, cacheAnnotation } from '@prisma-next/middleware-cache'; + * + * const db = postgres({ + * contractJson, + * url: process.env['DATABASE_URL']!, + * middleware: [createCacheMiddleware({ maxEntries: 1000 })], + * }); + * + * const users = await db.User.first( + * { id }, + * cacheAnnotation.apply({ ttl: 60_000 }), + * ); + * ``` + */ +export function createCacheMiddleware( + options?: CacheMiddlewareOptions, +): RuntimeMiddleware & { readonly familyId?: undefined; readonly targetId?: undefined } { + const store = + options?.store ?? + createInMemoryCacheStore({ + maxEntries: options?.maxEntries ?? DEFAULT_MAX_ENTRIES, + }); + const clock = options?.clock ?? Date.now; + + // Per-execution scratch space, keyed on the post-lowering `exec` + // object identity. WeakMap keeps cleanup automatic: if an execution is + // dropped without `afterExecute` firing (e.g. an early throw before + // `runWithMiddleware` even starts), the entry is GC'd alongside the + // exec object. + const pending = new WeakMap(); + + async function intercept( + exec: ExecutionPlan, + ctx: RuntimeMiddlewareContext, + ): Promise<{ readonly rows: Iterable> } | undefined> { + if (ctx.scope !== 'runtime') { + return undefined; + } + + const payload = readCachePayload(exec); + if (payload === undefined) { + return undefined; + } + if (payload.skip === true) { + return undefined; + } + if (payload.ttl === undefined) { + return undefined; + } + + const key = await resolveCacheKey(payload, exec, ctx); + const hit = await store.get(key); + if (hit !== undefined) { + ctx.log.debug?.({ event: 'middleware.cache.hit', middleware: 'cache', key }); + return { rows: hit.rows }; + } + + // Miss: record the pending buffer so onRow / afterExecute can + // commit on success. The TTL is captured here so a later mutation + // of the annotation (defensive) cannot change the commit window. + pending.set(exec, { key, ttlMs: payload.ttl, buffer: [] }); + ctx.log.debug?.({ event: 'middleware.cache.miss', middleware: 'cache', key }); + return undefined; + } + + async function onRow( + row: Record, + exec: ExecutionPlan, + _ctx: RuntimeMiddlewareContext, + ): Promise { + const slot = pending.get(exec); + if (slot === undefined) { + return; + } + slot.buffer.push(row); + } + + async function afterExecute( + exec: ExecutionPlan, + result: AfterExecuteResult, + ctx: RuntimeMiddlewareContext, + ): Promise { + const slot = pending.get(exec); + if (slot === undefined) { + return; + } + // Always release the WeakMap entry — the exec is single-use and + // any state we leave behind is dead weight on the GC. + pending.delete(exec); + + if (!result.completed || result.source !== 'driver') { + return; + } + + await store.set(slot.key, { rows: slot.buffer, storedAt: clock() }, slot.ttlMs); + ctx.log.debug?.({ event: 'middleware.cache.store', middleware: 'cache', key: slot.key }); + } + + return { + name: 'cache', + intercept, + onRow, + afterExecute, + }; +} diff --git a/packages/3-extensions/middleware-cache/src/exports/index.ts b/packages/3-extensions/middleware-cache/src/exports/index.ts index 7a27edc6b8..9abc87a6c3 100644 --- a/packages/3-extensions/middleware-cache/src/exports/index.ts +++ b/packages/3-extensions/middleware-cache/src/exports/index.ts @@ -1,4 +1,6 @@ export type { CachePayload } from '../cache-annotation'; export { cacheAnnotation } from '../cache-annotation'; +export type { CacheMiddlewareOptions } from '../cache-middleware'; +export { createCacheMiddleware } from '../cache-middleware'; export type { CachedEntry, CacheStore, InMemoryCacheStoreOptions } from '../cache-store'; export { createInMemoryCacheStore } from '../cache-store'; diff --git a/packages/3-extensions/middleware-cache/test/cache-key.test.ts b/packages/3-extensions/middleware-cache/test/cache-key.test.ts new file mode 100644 index 0000000000..e480ca8adf --- /dev/null +++ b/packages/3-extensions/middleware-cache/test/cache-key.test.ts @@ -0,0 +1,296 @@ +import type { PlanMeta } from '@prisma-next/contract/types'; +import type { + ExecutionPlan, + RuntimeMiddlewareContext, +} from '@prisma-next/framework-components/runtime'; +import { describe, expect, it, vi } from 'vitest'; +import { cacheAnnotation } from '../src/cache-annotation'; +import { createCacheMiddleware } from '../src/cache-middleware'; +import type { CachedEntry, CacheStore } from '../src/cache-store'; + +interface MockExec extends ExecutionPlan { + readonly statement: string; +} + +const baseMeta: PlanMeta = { + target: 'postgres', + targetFamily: 'sql', + storageHash: 'sha256:test', + lane: 'orm', +}; + +function makeExec(statement: string, annotations?: Record): MockExec { + return Object.freeze({ + statement, + meta: annotations ? { ...baseMeta, annotations } : baseMeta, + }); +} + +function makeCtx(overrides?: Partial): RuntimeMiddlewareContext { + return { + contract: {}, + mode: 'strict', + now: () => Date.now(), + log: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }, + contentHash: async (exec) => `id:${(exec as MockExec).statement}`, + scope: 'runtime', + ...overrides, + }; +} + +function spyStore(): CacheStore & { + readonly getSpy: ReturnType; + readonly setSpy: ReturnType; + readonly inner: Map; +} { + const inner = new Map(); + const getSpy = vi.fn(async (key: string) => inner.get(key)); + const setSpy = vi.fn(async (key: string, entry: CachedEntry, _ttlMs: number) => { + inner.set(key, entry); + }); + return { get: getSpy, set: setSpy, getSpy, setSpy, inner }; +} + +async function drain(iter: AsyncIterable): Promise { + const out: T[] = []; + for await (const x of iter) out.push(x); + return out; +} + +describe('cache key resolution', () => { + describe('default path: ctx.contentHash(exec)', () => { + it('uses the contentHash return value as the cache map key (no rehashing)', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ store }); + const exec = makeExec('select 1', { + cache: cacheAnnotation.apply({ ttl: 60_000 }), + }); + const ctx = makeCtx(); + + // Miss → store.get and store.set both called with the contentHash. + await mw.intercept!(exec, ctx); + await mw.onRow!({ id: 1 }, exec, ctx); + await mw.afterExecute!( + exec, + { rowCount: 1, latencyMs: 0, completed: true, source: 'driver' }, + ctx, + ); + + expect(store.getSpy).toHaveBeenCalledWith('id:select 1'); + expect(store.setSpy).toHaveBeenCalledWith('id:select 1', expect.anything(), 60_000); + }); + + it('invokes ctx.contentHash when no per-query key annotation is supplied', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ store }); + const exec = makeExec('select 1', { + cache: cacheAnnotation.apply({ ttl: 60_000 }), + }); + const contentHash = vi.fn(async (e: ExecutionPlan) => `derived:${(e as MockExec).statement}`); + const ctx = makeCtx({ contentHash }); + + await mw.intercept!(exec, ctx); + + expect(contentHash).toHaveBeenCalledTimes(1); + expect(contentHash).toHaveBeenCalledWith(exec); + expect(store.getSpy).toHaveBeenCalledWith('derived:select 1'); + }); + + it('produces distinct cache entries for two execs with distinct contentHash returns', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ store, clock: () => 0 }); + const execA = makeExec('A', { + cache: cacheAnnotation.apply({ ttl: 60_000 }), + }); + const execB = makeExec('B', { + cache: cacheAnnotation.apply({ ttl: 60_000 }), + }); + const ctx = makeCtx(); + + // Miss + commit for A. + await mw.intercept!(execA, ctx); + await mw.onRow!({ from: 'A' }, execA, ctx); + await mw.afterExecute!( + execA, + { rowCount: 1, latencyMs: 0, completed: true, source: 'driver' }, + ctx, + ); + + // Miss + commit for B. + await mw.intercept!(execB, ctx); + await mw.onRow!({ from: 'B' }, execB, ctx); + await mw.afterExecute!( + execB, + { rowCount: 1, latencyMs: 0, completed: true, source: 'driver' }, + ctx, + ); + + expect(store.inner.size).toBe(2); + expect(store.inner.get('id:A')?.rows).toEqual([{ from: 'A' }]); + expect(store.inner.get('id:B')?.rows).toEqual([{ from: 'B' }]); + }); + }); + + describe('per-query override: cacheAnnotation.apply({ key })', () => { + it('uses the user-supplied key in place of ctx.contentHash', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ store }); + const exec = makeExec('select 1', { + cache: cacheAnnotation.apply({ ttl: 60_000, key: 'custom-key' }), + }); + const ctx = makeCtx(); + + await mw.intercept!(exec, ctx); + await mw.onRow!({ id: 1 }, exec, ctx); + await mw.afterExecute!( + exec, + { rowCount: 1, latencyMs: 0, completed: true, source: 'driver' }, + ctx, + ); + + expect(store.getSpy).toHaveBeenCalledWith('custom-key'); + expect(store.setSpy).toHaveBeenCalledWith('custom-key', expect.anything(), 60_000); + }); + + it('does not invoke ctx.contentHash when an override key is supplied', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ store }); + const exec = makeExec('select 1', { + cache: cacheAnnotation.apply({ ttl: 60_000, key: 'custom-key' }), + }); + const contentHash = vi.fn(async () => 'should-not-be-used'); + const ctx = makeCtx({ contentHash }); + + await mw.intercept!(exec, ctx); + + expect(contentHash).not.toHaveBeenCalled(); + }); + + it('stores user-supplied keys verbatim (no rehashing)', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ store }); + // A long, structured user key — verify the middleware does not + // mangle, hash, or otherwise transform it. + const userKey = 'tenant=acme|user=alice|page=42'; + const exec = makeExec('select 1', { + cache: cacheAnnotation.apply({ ttl: 60_000, key: userKey }), + }); + const ctx = makeCtx(); + + await mw.intercept!(exec, ctx); + await mw.onRow!({ id: 1 }, exec, ctx); + await mw.afterExecute!( + exec, + { rowCount: 1, latencyMs: 0, completed: true, source: 'driver' }, + ctx, + ); + + expect(store.inner.has(userKey)).toBe(true); + }); + + it('produces a hit using the user-supplied key when previously committed under it', async () => { + const store = spyStore(); + store.inner.set('shared-key', { + rows: [{ id: 'pre-cached' }], + storedAt: 0, + }); + + const mw = createCacheMiddleware({ store }); + const exec = makeExec('select anything', { + cache: cacheAnnotation.apply({ ttl: 60_000, key: 'shared-key' }), + }); + const ctx = makeCtx(); + + const result = await mw.intercept!(exec, ctx); + expect(result).toBeDefined(); + expect(await drain(result!.rows as AsyncIterable>)).toEqual([ + { id: 'pre-cached' }, + ]); + }); + }); + + describe('cross-family parity', () => { + it('works with a Mongo-style contentHash return value (no SQL fields read)', async () => { + // The cache middleware must not read exec.sql, exec.command, or any + // family-specific field. Use a "Mongo-shaped" mock plan and a + // Mongo-style contentHash to demonstrate the package is genuinely + // family-agnostic. + interface MongoLikeExec extends ExecutionPlan { + readonly command: { readonly kind: string; readonly filter: unknown }; + } + + const store = spyStore(); + const mw = createCacheMiddleware({ store }); + + const exec: MongoLikeExec = Object.freeze({ + command: { kind: 'find', filter: { active: true } }, + meta: { + ...baseMeta, + target: 'mongo', + targetFamily: 'mongo', + annotations: { + cache: cacheAnnotation.apply({ ttl: 60_000 }), + }, + }, + }); + + const ctx = makeCtx({ + contentHash: async () => 'mongo:users:find:{active:true}', + }); + + // Miss + commit. + await mw.intercept!(exec, ctx); + await mw.onRow!({ _id: 'a', active: true }, exec, ctx); + await mw.afterExecute!( + exec, + { rowCount: 1, latencyMs: 0, completed: true, source: 'driver' }, + ctx, + ); + + // Hit on the second call. + const second = await mw.intercept!(exec, ctx); + expect(second).toBeDefined(); + expect(await drain(second!.rows as AsyncIterable>)).toEqual([ + { _id: 'a', active: true }, + ]); + expect(store.inner.has('mongo:users:find:{active:true}')).toBe(true); + }); + + it('two distinct contentHash returns produce two distinct cache entries', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ store, clock: () => 0 }); + const exec = makeExec('shared statement', { + cache: cacheAnnotation.apply({ ttl: 60_000 }), + }); + + // Same exec object but two different ctx.contentHash returns — + // simulating two calls where the family runtime computed different + // canonical keys (e.g. a parameter changed but the AST/command is + // structurally identical at this view). + const ctxA = makeCtx({ contentHash: async () => 'key-A' }); + const ctxB = makeCtx({ contentHash: async () => 'key-B' }); + + // Commit under key-A. + await mw.intercept!(exec, ctxA); + await mw.onRow!({ from: 'A' }, exec, ctxA); + await mw.afterExecute!( + exec, + { rowCount: 1, latencyMs: 0, completed: true, source: 'driver' }, + ctxA, + ); + + // Commit under key-B. + await mw.intercept!(exec, ctxB); + await mw.onRow!({ from: 'B' }, exec, ctxB); + await mw.afterExecute!( + exec, + { rowCount: 1, latencyMs: 0, completed: true, source: 'driver' }, + ctxB, + ); + + expect(store.inner.size).toBe(2); + expect(store.inner.get('key-A')?.rows).toEqual([{ from: 'A' }]); + expect(store.inner.get('key-B')?.rows).toEqual([{ from: 'B' }]); + }); + }); +}); diff --git a/packages/3-extensions/middleware-cache/test/cache-middleware.test.ts b/packages/3-extensions/middleware-cache/test/cache-middleware.test.ts new file mode 100644 index 0000000000..f455c71457 --- /dev/null +++ b/packages/3-extensions/middleware-cache/test/cache-middleware.test.ts @@ -0,0 +1,491 @@ +import type { PlanMeta } from '@prisma-next/contract/types'; +import type { + AfterExecuteResult, + ExecutionPlan, + RuntimeMiddlewareContext, +} from '@prisma-next/framework-components/runtime'; +import { describe, expect, it, vi } from 'vitest'; +import { cacheAnnotation } from '../src/cache-annotation'; +import { createCacheMiddleware } from '../src/cache-middleware'; +import { type CachedEntry, type CacheStore, createInMemoryCacheStore } from '../src/cache-store'; + +interface MockExec extends ExecutionPlan { + readonly statement: string; +} + +const baseMeta: PlanMeta = { + target: 'postgres', + targetFamily: 'sql', + storageHash: 'sha256:test', + lane: 'orm', +}; + +function makeExec(statement: string, annotations?: Record): MockExec { + return Object.freeze({ + statement, + meta: annotations ? { ...baseMeta, annotations } : baseMeta, + }); +} + +function makeCtx(overrides?: Partial): RuntimeMiddlewareContext { + return { + contract: {}, + mode: 'strict', + now: () => Date.now(), + log: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }, + contentHash: async (exec) => `key:${(exec as MockExec).statement}`, + scope: 'runtime', + ...overrides, + }; +} + +function spyStore(): CacheStore & { + readonly getSpy: ReturnType; + readonly setSpy: ReturnType; + readonly inner: Map; +} { + const inner = new Map(); + const getSpy = vi.fn(async (key: string) => inner.get(key)); + const setSpy = vi.fn(async (key: string, entry: CachedEntry, _ttlMs: number) => { + inner.set(key, entry); + }); + return { + get: getSpy, + set: setSpy, + getSpy, + setSpy, + inner, + }; +} + +async function drain(iter: AsyncIterable): Promise { + const out: T[] = []; + for await (const x of iter) out.push(x); + return out; +} + +describe('createCacheMiddleware — opt-in semantics', () => { + it('passes through (no store interaction) when the plan has no cache annotation', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ store }); + const exec = makeExec('select 1'); // no annotations + + const result = await mw.intercept!(exec, makeCtx()); + expect(result).toBeUndefined(); + expect(store.getSpy).not.toHaveBeenCalled(); + expect(store.setSpy).not.toHaveBeenCalled(); + }); + + it('passes through when the cache annotation has skip: true', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ store }); + const exec = makeExec('select 1', { + cache: cacheAnnotation.apply({ ttl: 60_000, skip: true }), + }); + + const result = await mw.intercept!(exec, makeCtx()); + expect(result).toBeUndefined(); + expect(store.getSpy).not.toHaveBeenCalled(); + expect(store.setSpy).not.toHaveBeenCalled(); + }); + + it('passes through when no ttl is supplied (presence alone is not sufficient)', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ store }); + const exec = makeExec('select 1', { + cache: cacheAnnotation.apply({}), + }); + + const result = await mw.intercept!(exec, makeCtx()); + expect(result).toBeUndefined(); + expect(store.getSpy).not.toHaveBeenCalled(); + expect(store.setSpy).not.toHaveBeenCalled(); + }); + + it('does not store rows for an un-annotated plan even when onRow/afterExecute fire (driver path)', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ store }); + const exec = makeExec('select 1'); + const ctx = makeCtx(); + + await mw.intercept!(exec, ctx); // passthrough + await mw.onRow!({ id: 1 }, exec, ctx); + await mw.afterExecute!( + exec, + { rowCount: 1, latencyMs: 0, completed: true, source: 'driver' }, + ctx, + ); + + expect(store.setSpy).not.toHaveBeenCalled(); + }); +}); + +describe('createCacheMiddleware — hit path', () => { + it('returns cached rows from intercept when the store has a non-expired entry', async () => { + const store = spyStore(); + store.inner.set('key:select 1', { + rows: [{ id: 1 }, { id: 2 }], + storedAt: 0, + }); + + const mw = createCacheMiddleware({ store }); + const exec = makeExec('select 1', { + cache: cacheAnnotation.apply({ ttl: 60_000 }), + }); + + const result = await mw.intercept!(exec, makeCtx()); + expect(result).toBeDefined(); + expect(await drain(result!.rows as AsyncIterable>)).toEqual([ + { id: 1 }, + { id: 2 }, + ]); + }); + + it('logs a middleware.cache.hit event via ctx.log.debug on a hit', async () => { + const store = spyStore(); + store.inner.set('key:select 1', { rows: [{ id: 1 }], storedAt: 0 }); + const mw = createCacheMiddleware({ store }); + const exec = makeExec('select 1', { + cache: cacheAnnotation.apply({ ttl: 60_000 }), + }); + const debug = vi.fn(); + const ctx = makeCtx({ + log: { info: () => {}, warn: () => {}, error: () => {}, debug }, + }); + + await mw.intercept!(exec, ctx); + + expect(debug).toHaveBeenCalledWith(expect.objectContaining({ event: 'middleware.cache.hit' })); + }); + + it('does not call store.set on the hit path', async () => { + const store = spyStore(); + store.inner.set('key:select 1', { rows: [{ id: 1 }], storedAt: 0 }); + const mw = createCacheMiddleware({ store }); + const exec = makeExec('select 1', { + cache: cacheAnnotation.apply({ ttl: 60_000 }), + }); + const ctx = makeCtx(); + + const result = await mw.intercept!(exec, ctx); + await drain(result!.rows as AsyncIterable>); + + // afterExecute fires with source: 'middleware' on a hit; the cache + // middleware should not write back to the store. + await mw.afterExecute!( + exec, + { rowCount: 1, latencyMs: 0, completed: true, source: 'middleware' }, + ctx, + ); + + expect(store.setSpy).not.toHaveBeenCalled(); + }); + + it('survives the absence of ctx.log.debug (it is optional on RuntimeLog)', async () => { + const store = spyStore(); + store.inner.set('key:select 1', { rows: [{ id: 1 }], storedAt: 0 }); + const mw = createCacheMiddleware({ store }); + const exec = makeExec('select 1', { + cache: cacheAnnotation.apply({ ttl: 60_000 }), + }); + const ctx = makeCtx({ + // No debug field. + log: { info: () => {}, warn: () => {}, error: () => {} }, + }); + + await expect(mw.intercept!(exec, ctx)).resolves.toBeDefined(); + }); +}); + +describe('createCacheMiddleware — miss path', () => { + it('returns undefined from intercept on a miss', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ store }); + const exec = makeExec('select 1', { + cache: cacheAnnotation.apply({ ttl: 60_000 }), + }); + + const result = await mw.intercept!(exec, makeCtx()); + expect(result).toBeUndefined(); + }); + + it('logs a middleware.cache.miss event via ctx.log.debug on a miss', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ store }); + const exec = makeExec('select 1', { + cache: cacheAnnotation.apply({ ttl: 60_000 }), + }); + const debug = vi.fn(); + const ctx = makeCtx({ + log: { info: () => {}, warn: () => {}, error: () => {}, debug }, + }); + + await mw.intercept!(exec, ctx); + + expect(debug).toHaveBeenCalledWith(expect.objectContaining({ event: 'middleware.cache.miss' })); + }); + + it('buffers rows via onRow and commits on a successful afterExecute (source: driver)', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ store, clock: () => 1_234 }); + const exec = makeExec('select 1', { + cache: cacheAnnotation.apply({ ttl: 60_000 }), + }); + const ctx = makeCtx(); + + await mw.intercept!(exec, ctx); // miss + await mw.onRow!({ id: 1 }, exec, ctx); + await mw.onRow!({ id: 2 }, exec, ctx); + await mw.afterExecute!( + exec, + { rowCount: 2, latencyMs: 5, completed: true, source: 'driver' }, + ctx, + ); + + expect(store.setSpy).toHaveBeenCalledTimes(1); + expect(store.setSpy).toHaveBeenCalledWith( + 'key:select 1', + expect.objectContaining({ + rows: [{ id: 1 }, { id: 2 }], + storedAt: 1_234, + }), + 60_000, + ); + }); + + it('does not commit when completed = false (driver threw mid-stream)', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ store }); + const exec = makeExec('select 1', { + cache: cacheAnnotation.apply({ ttl: 60_000 }), + }); + const ctx = makeCtx(); + + await mw.intercept!(exec, ctx); + await mw.onRow!({ id: 1 }, exec, ctx); + await mw.afterExecute!( + exec, + { rowCount: 1, latencyMs: 5, completed: false, source: 'driver' }, + ctx, + ); + + expect(store.setSpy).not.toHaveBeenCalled(); + }); + + it('does not commit when source = "middleware" (a different interceptor produced the rows)', async () => { + // If another middleware wins the intercept chain, our intercept did + // not fire — we never called set up a buffer. afterExecute would see + // source === 'middleware' and we should not store anything. + const store = spyStore(); + const mw = createCacheMiddleware({ store }); + const exec = makeExec('select 1', { + cache: cacheAnnotation.apply({ ttl: 60_000 }), + }); + const ctx = makeCtx(); + + // Note: skipping intercept and onRow simulates the case where a + // different interceptor short-circuited execution upstream. + await mw.afterExecute!( + exec, + { rowCount: 1, latencyMs: 5, completed: true, source: 'middleware' }, + ctx, + ); + + expect(store.setSpy).not.toHaveBeenCalled(); + }); + + it('cleans up its WeakMap entry on afterExecute even when no commit happens', async () => { + // The buffer is a WeakMap keyed on the exec object — testing this + // directly would be brittle; instead, verify behavior: re-running + // afterExecute without an intercept call should be a no-op even if + // the previous run did not commit. + const store = spyStore(); + const mw = createCacheMiddleware({ store }); + const exec = makeExec('select 1', { + cache: cacheAnnotation.apply({ ttl: 60_000 }), + }); + const ctx = makeCtx(); + + await mw.intercept!(exec, ctx); + await mw.onRow!({ id: 1 }, exec, ctx); + // Mid-stream failure. + await mw.afterExecute!( + exec, + { rowCount: 1, latencyMs: 5, completed: false, source: 'driver' }, + ctx, + ); + + // A second afterExecute (defensive — should never happen in + // practice, but verify cleanup didn't leave residue). + await mw.afterExecute!( + exec, + { rowCount: 1, latencyMs: 5, completed: true, source: 'driver' }, + ctx, + ); + + expect(store.setSpy).not.toHaveBeenCalled(); + }); + + it('keeps per-execution buffers isolated across two concurrent execs', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ store, clock: () => 0 }); + const execA = makeExec('select A', { + cache: cacheAnnotation.apply({ ttl: 60_000 }), + }); + const execB = makeExec('select B', { + cache: cacheAnnotation.apply({ ttl: 60_000 }), + }); + const ctx = makeCtx(); + + // Interleave the two executions to stress per-exec buffer isolation. + await mw.intercept!(execA, ctx); + await mw.intercept!(execB, ctx); + await mw.onRow!({ from: 'A', n: 1 }, execA, ctx); + await mw.onRow!({ from: 'B', n: 1 }, execB, ctx); + await mw.onRow!({ from: 'A', n: 2 }, execA, ctx); + await mw.onRow!({ from: 'B', n: 2 }, execB, ctx); + + const result: AfterExecuteResult = { + rowCount: 2, + latencyMs: 0, + completed: true, + source: 'driver', + }; + await mw.afterExecute!(execA, result, ctx); + await mw.afterExecute!(execB, result, ctx); + + expect(store.inner.get('key:select A')?.rows).toEqual([ + { from: 'A', n: 1 }, + { from: 'A', n: 2 }, + ]); + expect(store.inner.get('key:select B')?.rows).toEqual([ + { from: 'B', n: 1 }, + { from: 'B', n: 2 }, + ]); + }); +}); + +describe('createCacheMiddleware — scope guard', () => { + it('passes through when ctx.scope = "connection"', async () => { + const store = spyStore(); + store.inner.set('key:select 1', { rows: [{ id: 1 }], storedAt: 0 }); + const mw = createCacheMiddleware({ store }); + const exec = makeExec('select 1', { + cache: cacheAnnotation.apply({ ttl: 60_000 }), + }); + + const result = await mw.intercept!(exec, makeCtx({ scope: 'connection' })); + expect(result).toBeUndefined(); + expect(store.getSpy).not.toHaveBeenCalled(); + }); + + it('passes through when ctx.scope = "transaction"', async () => { + const store = spyStore(); + store.inner.set('key:select 1', { rows: [{ id: 1 }], storedAt: 0 }); + const mw = createCacheMiddleware({ store }); + const exec = makeExec('select 1', { + cache: cacheAnnotation.apply({ ttl: 60_000 }), + }); + + const result = await mw.intercept!(exec, makeCtx({ scope: 'transaction' })); + expect(result).toBeUndefined(); + expect(store.getSpy).not.toHaveBeenCalled(); + }); + + it('does not store rows on connection-scope writes either', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ store }); + const exec = makeExec('select 1', { + cache: cacheAnnotation.apply({ ttl: 60_000 }), + }); + const ctx = makeCtx({ scope: 'connection' }); + + await mw.intercept!(exec, ctx); + await mw.onRow!({ id: 1 }, exec, ctx); + await mw.afterExecute!( + exec, + { rowCount: 1, latencyMs: 0, completed: true, source: 'driver' }, + ctx, + ); + + expect(store.setSpy).not.toHaveBeenCalled(); + }); +}); + +describe('createCacheMiddleware — middleware shape', () => { + it('is a cross-family middleware (no familyId)', () => { + const mw = createCacheMiddleware({ store: spyStore() }); + expect(mw.familyId).toBeUndefined(); + expect(mw.targetId).toBeUndefined(); + }); + + it('exposes a stable name', () => { + const mw = createCacheMiddleware({ store: spyStore() }); + expect(mw.name).toBe('cache'); + }); + + it('wires intercept, onRow, and afterExecute (only)', () => { + const mw = createCacheMiddleware({ store: spyStore() }); + expect(mw.intercept).toBeDefined(); + expect(mw.onRow).toBeDefined(); + expect(mw.afterExecute).toBeDefined(); + // No beforeExecute — the cache middleware doesn't observe the pre- + // execute event. + expect(mw.beforeExecute).toBeUndefined(); + }); + + it('defaults to an in-memory LRU store when none is supplied', () => { + // Smoke: the constructor accepts no store and produces a working + // middleware. Behavior is exercised by the roundtrip test below. + const mw = createCacheMiddleware(); + expect(mw.intercept).toBeDefined(); + }); + + it('roundtrips a miss-then-hit through the default in-memory store', async () => { + const mw = createCacheMiddleware({ maxEntries: 10 }); + const exec = makeExec('select roundtrip', { + cache: cacheAnnotation.apply({ ttl: 60_000 }), + }); + const ctx = makeCtx(); + + // Miss. + expect(await mw.intercept!(exec, ctx)).toBeUndefined(); + await mw.onRow!({ id: 1 }, exec, ctx); + await mw.onRow!({ id: 2 }, exec, ctx); + await mw.afterExecute!( + exec, + { rowCount: 2, latencyMs: 0, completed: true, source: 'driver' }, + ctx, + ); + + // Hit on the next call. + const second = await mw.intercept!(exec, ctx); + expect(second).toBeDefined(); + expect(await drain(second!.rows as AsyncIterable>)).toEqual([ + { id: 1 }, + { id: 2 }, + ]); + }); + + it('respects a user-supplied custom CacheStore', async () => { + const store = createInMemoryCacheStore({ maxEntries: 5 }); + const mw = createCacheMiddleware({ store }); + const exec = makeExec('select custom', { + cache: cacheAnnotation.apply({ ttl: 60_000 }), + }); + const ctx = makeCtx(); + + await mw.intercept!(exec, ctx); + await mw.onRow!({ id: 7 }, exec, ctx); + await mw.afterExecute!( + exec, + { rowCount: 1, latencyMs: 0, completed: true, source: 'driver' }, + ctx, + ); + + const stored = await store.get('key:custom-not-this'); + expect(stored).toBeUndefined(); + const real = await store.get('key:select custom'); + expect(real?.rows).toEqual([{ id: 7 }]); + }); +}); From bcd770654678ad2565359f92f32bd1e44caa294f Mon Sep 17 00:00:00 2001 From: Alexey Orlenko Date: Tue, 28 Apr 2026 17:22:28 +0200 Subject: [PATCH 14/21] test(middleware-cache): integration tests against real Postgres Adds test/integration/test/cross-package/middleware-cache.test.ts covering the four integration scenarios from the M3 plan: - M3.16 Stop condition. A repeated annotated SQL DSL query is served from cache without invoking the driver. The April milestone exit criterion: spy on driver.execute and assert call count does not increase between miss and hit. - M3.17 Composition with a beforeCompile rewriter. An 'active users only' rewriter prepends a predicate; the cache key reflects the rewritten lowered SQL because the cache middleware sees the post-lowering plan. Two runtimes (with vs. without the rewriter) sharing one CacheStore land in distinct cache slots. - M3.18 Composition with telemetry. afterExecute fires on both miss and hit; source rounds-trips as 'driver' on miss and 'middleware' on hit. beforeExecute is suppressed on the intercepted hit path. - M3.19 Concurrency regression. Two parallel executes of the same plan produce correct, identical results without cross-talk via the per-exec WeakMap buffer; a third sequential call hits the cache. Plus opt-in regression tests: un-annotated queries always hit the driver; skip: true bypasses the cache. Closes M3.16, M3.17, M3.18, M3.19 of the cache-middleware project. --- pnpm-lock.yaml | 3 + test/integration/package.json | 1 + .../cross-package/middleware-cache.test.ts | 494 ++++++++++++++++++ 3 files changed, 498 insertions(+) create mode 100644 test/integration/test/cross-package/middleware-cache.test.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ad7fc225f..90476f455a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3613,6 +3613,9 @@ importers: '@prisma-next/framework-components': specifier: workspace:* version: link:../../packages/1-framework/1-core/framework-components + '@prisma-next/middleware-cache': + specifier: workspace:* + version: link:../../packages/3-extensions/middleware-cache '@prisma-next/middleware-telemetry': specifier: workspace:* version: link:../../packages/3-extensions/middleware-telemetry diff --git a/test/integration/package.json b/test/integration/package.json index 2cb9e8a692..1a927454da 100644 --- a/test/integration/package.json +++ b/test/integration/package.json @@ -60,6 +60,7 @@ }, "devDependencies": { "@prisma-next/framework-components": "workspace:*", + "@prisma-next/middleware-cache": "workspace:*", "@prisma-next/middleware-telemetry": "workspace:*", "@prisma-next/mongo-lowering": "workspace:*", "@prisma-next/test-utils": "workspace:*", diff --git a/test/integration/test/cross-package/middleware-cache.test.ts b/test/integration/test/cross-package/middleware-cache.test.ts new file mode 100644 index 0000000000..666679d7e3 --- /dev/null +++ b/test/integration/test/cross-package/middleware-cache.test.ts @@ -0,0 +1,494 @@ +import postgresAdapter from '@prisma-next/adapter-postgres/runtime'; +import postgresDriver from '@prisma-next/driver-postgres/runtime'; +import pgvector from '@prisma-next/extension-pgvector/runtime'; +import { emptyCodecLookup } from '@prisma-next/framework-components/codec'; +import { + type ExecutionStackInstance, + instantiateExecutionStack, + type RuntimeDriverInstance, +} from '@prisma-next/framework-components/execution'; +import { cacheAnnotation, createCacheMiddleware } from '@prisma-next/middleware-cache'; +import { createTelemetryMiddleware, type TelemetryEvent } from '@prisma-next/middleware-telemetry'; +import { sql } from '@prisma-next/sql-builder/runtime'; +import { validateContract } from '@prisma-next/sql-contract/validate'; +import { + AndExpr, + BinaryExpr, + ColumnRef, + ParamRef, + type SelectAst, +} from '@prisma-next/sql-relational-core/ast'; +import type { ExecutionContext } from '@prisma-next/sql-relational-core/query-lane-context'; +import { + createExecutionContext, + createRuntime, + createSqlExecutionStack, + type Runtime, + type SqlMiddleware, + type SqlRuntimeAdapterInstance, + type SqlRuntimeDriverInstance, + type SqlRuntimeExtensionInstance, +} from '@prisma-next/sql-runtime'; +import { setupTestDatabase } from '@prisma-next/sql-runtime/test/utils'; +import postgresTarget from '@prisma-next/target-postgres/runtime'; +import { createDevDatabase, timeouts } from '@prisma-next/test-utils'; +import { Client } from 'pg'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { contract } from '../sql-builder/fixtures/contract'; +import type { Contract } from '../sql-builder/fixtures/generated/contract'; + +/** + * Integration tests for `@prisma-next/middleware-cache` against real + * Postgres. Exercises the **April stop condition** for TML-2143 / VP4: + * a repeated query is served from cache without hitting the database. + * + * Tests in this file: + * - 3.16 Stop condition — second annotated query skips the driver. + * - 3.17 Composition with a `beforeCompile`-style rewriter (soft-delete). + * - 3.18 Composition with telemetry — `source` field round-trips. + * - 3.19 Concurrency regression — two parallel calls of the same plan + * don't cross-talk through the per-exec WeakMap buffer. + */ + +const sqlContract = validateContract(contract, emptyCodecLookup); + +type TestStackInstance = ExecutionStackInstance< + 'sql', + 'postgres', + SqlRuntimeAdapterInstance<'postgres'>, + RuntimeDriverInstance<'sql', 'postgres'>, + SqlRuntimeExtensionInstance<'postgres'> +>; + +/** + * A `beforeCompile` middleware that injects a predicate filtering out + * rows where `users.invited_by_id IS NULL` — used as a stand-in for a + * "soft delete" rewriter to exercise composition with the cache. The + * cache key reflects the rewritten SQL because the cache middleware + * sees the post-lowering plan. + */ +function activeUsersOnly(): SqlMiddleware { + return { + name: 'active-users-only', + familyId: 'sql', + async beforeCompile(draft) { + if (draft.ast.kind !== 'select') return undefined; + if (draft.ast.from.kind !== 'table-source') return undefined; + if (draft.ast.from.name !== 'users') return undefined; + const invitedByPresent = BinaryExpr.gte( + ColumnRef.of('users', 'id'), + ParamRef.of(2, { name: 'soft_delete_min_id', codecId: 'pg/int4@1' }), + ); + const newAst: SelectAst = draft.ast.withWhere( + draft.ast.where ? AndExpr.of([draft.ast.where, invitedByPresent]) : invitedByPresent, + ); + return { ...draft, ast: newAst }; + }, + }; +} + +describe( + 'integration: middleware-cache against real Postgres', + { timeout: timeouts.databaseOperation }, + () => { + let context: ExecutionContext; + let driver: SqlRuntimeDriverInstance<'postgres'>; + let stackInstance: TestStackInstance; + let driverExecuteSpy: ReturnType; + const closeFns: Array<() => Promise> = []; + + beforeAll(async () => { + const database = await createDevDatabase(); + const client = new Client({ connectionString: database.connectionString }); + await client.connect(); + + await setupTestDatabase(client, sqlContract, async (c) => { + await c.query(` + CREATE TABLE users ( + id int4 PRIMARY KEY, + name text NOT NULL, + email text NOT NULL, + invited_by_id int4 + ) + `); + await c.query('CREATE EXTENSION IF NOT EXISTS vector'); + await c.query(` + CREATE TABLE posts ( + id int4 PRIMARY KEY, + title text NOT NULL, + user_id int4 NOT NULL, + views int4 NOT NULL, + embedding vector(3) + ) + `); + await c.query(` + CREATE TABLE comments ( + id int4 PRIMARY KEY, + body text NOT NULL, + post_id int4 NOT NULL + ) + `); + await c.query(` + CREATE TABLE profiles ( + id int4 PRIMARY KEY, + user_id int4 NOT NULL, + bio text NOT NULL + ) + `); + await c.query(` + CREATE TABLE articles ( + id uuid PRIMARY KEY, + title text NOT NULL + ) + `); + + await c.query(` + INSERT INTO users (id, name, email, invited_by_id) VALUES + (1, 'Alice', 'alice@example.com', NULL), + (2, 'Bob', 'bob@example.com', 1), + (3, 'Charlie', 'charlie@example.com', 1), + (4, 'Diana', 'diana@example.com', 2) + `); + }); + + const stack = createSqlExecutionStack({ + target: postgresTarget, + adapter: postgresAdapter, + driver: { + ...postgresDriver, + create() { + return postgresDriver.create({ cursor: { disabled: true } }); + }, + }, + extensionPacks: [pgvector], + }); + + stackInstance = instantiateExecutionStack(stack) as TestStackInstance; + context = createExecutionContext({ contract: sqlContract, stack }); + const resolvedDriver = stackInstance.driver; + if (!resolvedDriver) throw new Error('Driver missing'); + driver = resolvedDriver as SqlRuntimeDriverInstance<'postgres'>; + await driver.connect({ kind: 'pgClient', client }); + + // Spy on the driver's execute so we can count round-trips. The + // cache middleware short-circuits via `intercept` upstream of + // `runDriver`, so a hit shows up here as zero invocations. + driverExecuteSpy = vi.spyOn(driver, 'execute'); + + closeFns.push( + () => driver.close(), + () => client.end(), + () => database.close(), + ); + }, timeouts.spinUpPpgDev); + + afterAll(async () => { + for (const fn of closeFns) { + try { + await fn(); + } catch { + // ignore cleanup errors + } + } + }); + + function buildRuntime(middleware: SqlMiddleware[]): Runtime { + return createRuntime({ + stackInstance, + context, + driver, + verify: { mode: 'onFirstUse', requireMarker: false }, + middleware, + }); + } + + describe('stop condition (M3.16)', () => { + it('serves a repeated annotated read from cache without hitting the driver', async () => { + const cache = createCacheMiddleware({ maxEntries: 100 }); + const runtime = buildRuntime([cache]); + const db = sql({ context }); + + const buildPlan = () => + db.users + .select('id', 'name') + .annotate(cacheAnnotation.apply({ ttl: 60_000 })) + .build(); + + driverExecuteSpy.mockClear(); + + // First call — cache miss; driver invoked. + const first = await runtime.execute(buildPlan()).toArray(); + const driverCallsAfterFirst = driverExecuteSpy.mock.calls.length; + expect(driverCallsAfterFirst).toBeGreaterThan(0); + + // Second call — cache hit; driver not invoked again. + const second = await runtime.execute(buildPlan()).toArray(); + expect(driverExecuteSpy.mock.calls.length).toBe(driverCallsAfterFirst); + + // Both calls produce equivalent decoded rows. + expect(second).toEqual(first); + // Sanity — the table has 4 users so we got real data back. + expect(first.length).toBe(4); + }); + + it('still hits the driver for an un-annotated query (cache is opt-in)', async () => { + const cache = createCacheMiddleware({ maxEntries: 100 }); + const runtime = buildRuntime([cache]); + const db = sql({ context }); + + driverExecuteSpy.mockClear(); + + await runtime.execute(db.users.select('id').build()).toArray(); + const callsAfterFirst = driverExecuteSpy.mock.calls.length; + + await runtime.execute(db.users.select('id').build()).toArray(); + // Second un-annotated call hits the driver again. + expect(driverExecuteSpy.mock.calls.length).toBeGreaterThan(callsAfterFirst); + }); + + it('does not cache a query when its cacheAnnotation has skip: true', async () => { + const cache = createCacheMiddleware({ maxEntries: 100 }); + const runtime = buildRuntime([cache]); + const db = sql({ context }); + + const buildPlan = () => + db.users + .select('id') + .annotate(cacheAnnotation.apply({ ttl: 60_000, skip: true })) + .build(); + + driverExecuteSpy.mockClear(); + + await runtime.execute(buildPlan()).toArray(); + const callsAfterFirst = driverExecuteSpy.mock.calls.length; + + await runtime.execute(buildPlan()).toArray(); + // Both calls hit the driver — skip: true bypasses the cache. + expect(driverExecuteSpy.mock.calls.length).toBeGreaterThan(callsAfterFirst); + }); + }); + + describe('composition with beforeCompile rewriter (M3.17)', () => { + it('cache key reflects the rewritten SQL; rewritten predicate is preserved on the hit path', async () => { + // Order: rewriter first, then cache. The cache sees the + // post-lowering exec, so the rewritten predicate is part of + // the cache key by construction. + const rewriter = activeUsersOnly(); + const cache = createCacheMiddleware({ maxEntries: 100 }); + const runtime = buildRuntime([rewriter, cache]); + const db = sql({ context }); + + const buildPlan = () => + db.users + .select('id', 'name') + .annotate(cacheAnnotation.apply({ ttl: 60_000 })) + .build(); + + driverExecuteSpy.mockClear(); + + // First call: rewriter prepends `id >= 2`, driver executes. + const first = await runtime.execute(buildPlan()).toArray(); + const callsAfterFirst = driverExecuteSpy.mock.calls.length; + expect(callsAfterFirst).toBeGreaterThan(0); + + // The rewriter's `id >= 2` predicate filtered out user 1 + // (Alice), so the cached results don't include her. + expect(first.map((r) => r['id']).sort()).toEqual([2, 3, 4]); + + // Second call: cache hit, driver skipped, but the consumer + // still sees the rewritten (filtered) result set. + const second = await runtime.execute(buildPlan()).toArray(); + expect(driverExecuteSpy.mock.calls.length).toBe(callsAfterFirst); + expect(second).toEqual(first); + expect(second.map((r) => r['id']).sort()).toEqual([2, 3, 4]); + }); + + it('cache key for the same query differs when registered with vs. without the rewriter', async () => { + // Two runtimes share the same custom CacheStore so we can + // observe whether the rewriter changes the key. + const { createInMemoryCacheStore } = await import('@prisma-next/middleware-cache'); + const sharedStore = createInMemoryCacheStore({ maxEntries: 100 }); + + const cacheNoRewrite = createCacheMiddleware({ store: sharedStore }); + const runtimeNoRewrite = buildRuntime([cacheNoRewrite]); + + const cacheWithRewrite = createCacheMiddleware({ store: sharedStore }); + const runtimeWithRewrite = buildRuntime([activeUsersOnly(), cacheWithRewrite]); + + const db = sql({ context }); + const buildPlan = () => + db.users + .select('id') + .annotate(cacheAnnotation.apply({ ttl: 60_000 })) + .build(); + + driverExecuteSpy.mockClear(); + + // First runtime (no rewriter) populates the cache under one key. + const noRewrite = await runtimeNoRewrite.execute(buildPlan()).toArray(); + const callsAfterNoRewrite = driverExecuteSpy.mock.calls.length; + expect(noRewrite.map((r) => r['id']).sort()).toEqual([1, 2, 3, 4]); + + // Second runtime (with rewriter) sees a *different* lowered SQL + // and therefore a different contentHash — it must miss and + // hit the driver again. + const withRewrite = await runtimeWithRewrite.execute(buildPlan()).toArray(); + expect(driverExecuteSpy.mock.calls.length).toBeGreaterThan(callsAfterNoRewrite); + expect(withRewrite.map((r) => r['id']).sort()).toEqual([2, 3, 4]); + }); + }); + + describe('composition with telemetry (M3.18)', () => { + it('telemetry observes source: "driver" on miss and source: "middleware" on hit', async () => { + const events: TelemetryEvent[] = []; + const telemetry = createTelemetryMiddleware({ onEvent: (e) => events.push(e) }); + const cache = createCacheMiddleware({ maxEntries: 100 }); + // Cache first so its `intercept` runs upstream of telemetry's + // `beforeExecute`. Telemetry's `afterExecute` still fires on + // both paths and observes the `source` field. + const runtime = buildRuntime([cache, telemetry]); + const db = sql({ context }); + + const buildPlan = () => + db.users + .select('id') + .annotate(cacheAnnotation.apply({ ttl: 60_000 })) + .build(); + + driverExecuteSpy.mockClear(); + events.length = 0; + + // Miss path. + await runtime.execute(buildPlan()).toArray(); + + const missEvents = events.slice(); + // A `beforeExecute` event fires only when the cache misses + // (cache's intercept returned undefined → driver path runs). + expect(missEvents.find((e) => e.phase === 'beforeExecute')).toBeDefined(); + const missAfter = missEvents.find((e) => e.phase === 'afterExecute'); + expect(missAfter).toBeDefined(); + expect(missAfter!.source).toBe('driver'); + + events.length = 0; + driverExecuteSpy.mockClear(); + + // Hit path. + await runtime.execute(buildPlan()).toArray(); + + // beforeExecute is suppressed on the intercepted hit path. + expect(events.find((e) => e.phase === 'beforeExecute')).toBeUndefined(); + const hitAfter = events.find((e) => e.phase === 'afterExecute'); + expect(hitAfter).toBeDefined(); + expect(hitAfter!.source).toBe('middleware'); + expect(driverExecuteSpy.mock.calls.length).toBe(0); + }); + + it('telemetry rowCount and latencyMs populate correctly on both paths', async () => { + const events: TelemetryEvent[] = []; + const telemetry = createTelemetryMiddleware({ onEvent: (e) => events.push(e) }); + const cache = createCacheMiddleware({ maxEntries: 100 }); + const runtime = buildRuntime([cache, telemetry]); + const db = sql({ context }); + + const buildPlan = () => + db.users + .select('id', 'name') + .annotate(cacheAnnotation.apply({ ttl: 60_000 })) + .build(); + + // Miss → commit. + await runtime.execute(buildPlan()).toArray(); + // Hit. + events.length = 0; + await runtime.execute(buildPlan()).toArray(); + + const after = events.find((e) => e.phase === 'afterExecute'); + expect(after).toBeDefined(); + expect(after!.rowCount).toBe(4); + expect(typeof after!.latencyMs).toBe('number'); + expect(after!.completed).toBe(true); + }); + }); + + describe('concurrency regression (M3.19)', () => { + it('two parallel executes of the same plan do not cross-talk via the per-exec buffer', async () => { + const cache = createCacheMiddleware({ maxEntries: 100 }); + const runtime = buildRuntime([cache]); + const db = sql({ context }); + + const buildPlan = () => + db.users + .select('id', 'name') + .annotate(cacheAnnotation.apply({ ttl: 60_000, key: 'concurrency-test' })) + .build(); + + driverExecuteSpy.mockClear(); + + // Two parallel executions of the same logical plan. Each + // produces its own frozen `exec` object inside the runtime + // (executeAgainstQueryable freezes per-call), and the cache + // middleware keys its WeakMap on that identity — so the two + // calls' miss buffers must not interfere. + const [a, b] = await Promise.all([ + runtime.execute(buildPlan()).toArray(), + runtime.execute(buildPlan()).toArray(), + ]); + + // Both calls produce correct, identical results. + expect(a).toEqual(b); + expect(a.length).toBe(4); + + // After both finish, the cache holds a single entry (one of + // the misses commits last; same key, same data). + const third = await runtime.execute(buildPlan()).toArray(); + expect(third).toEqual(a); + }); + + it('parallel executes of two different plans land in distinct cache slots', async () => { + const cache = createCacheMiddleware({ maxEntries: 100 }); + const runtime = buildRuntime([cache]); + const db = sql({ context }); + + driverExecuteSpy.mockClear(); + + const planA = db.users + .select('id') + .annotate(cacheAnnotation.apply({ ttl: 60_000, key: 'parallel-A' })) + .build(); + const planB = db.posts + .select('id', 'title') + .annotate(cacheAnnotation.apply({ ttl: 60_000, key: 'parallel-B' })) + .build(); + + const [a, b] = await Promise.all([ + runtime.execute(planA).toArray(), + runtime.execute(planB).toArray(), + ]); + + expect(a.every((r) => 'id' in r && !('title' in r))).toBe(true); + expect(b.every((r) => 'id' in r && 'title' in r)).toBe(true); + + // The next call to each lands on the cache (driver count + // unchanged after these reads). + const callsAfterParallel = driverExecuteSpy.mock.calls.length; + await runtime + .execute( + db.users + .select('id') + .annotate(cacheAnnotation.apply({ ttl: 60_000, key: 'parallel-A' })) + .build(), + ) + .toArray(); + await runtime + .execute( + db.posts + .select('id', 'title') + .annotate(cacheAnnotation.apply({ ttl: 60_000, key: 'parallel-B' })) + .build(), + ) + .toArray(); + expect(driverExecuteSpy.mock.calls.length).toBe(callsAfterParallel); + }); + }); + }, +); From f055ec22d37a0d4ab21d3f2d6f616eb61ad97520 Mon Sep 17 00:00:00 2001 From: Alexey Orlenko Date: Tue, 28 Apr 2026 17:44:27 +0200 Subject: [PATCH 15/21] docs(runtime-subsystem): document intercept hook, annotations, and cache integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates docs/architecture docs/subsystems/4. Runtime & Middleware Framework.md to reflect the M1 + M2 + M3 work that landed under TML-2143: - Updated the runWithMiddleware lifecycle to start with the intercept step, with hit-path semantics for skipping beforeExecute / runDriver / onRow. - Added the source field to the AfterExecuteResult shape shown in the Middleware API section. - New 'Intercepting Execution' section documenting RuntimeMiddleware.intercept: chain semantics, hit path, row shape, verification ordering, error path, the WeakMap-correlated plan-identity invariant (with a forward reference to ADR 025), and family-agnostic construction with the cache middleware as the canonical example. - New 'Annotations' subsection covering OperationKind, defineAnnotation, ValidAnnotations, lane integration on SQL DSL + ORM Collection, the runtime applicability check, plan.meta.annotations storage, and reserved namespaces (codecs, target keys). - Documented the new RuntimeMiddlewareContext fields: identityKey(exec) (BLAKE2b-512 digest of the execution identity, populated by family runtimes) and scope ('runtime' | 'connection' | 'transaction'). - Updated the lifecycle sequence diagram to branch on the intercept hit vs. driver path and show source: 'middleware' / 'driver' on afterExecute. - Updated the Execution Pipeline and Connection Lifecycle text snippets to reflect the intercept step. - Updated the Rewriting ASTs scope note: short-circuiting via intercept and user-authored annotations are no longer 'deferred to later milestones' — they are documented above. - Extended the Testing Strategy with the intercept matrix, annotation surface, family identityKey impls, cache middleware unit + integration coverage, and SQL runtime scope plumbing. Closes C.2 of the cache-middleware project. --- .../4. Runtime & Middleware Framework.md | 58 ++++++++++++++++++- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/docs/architecture docs/subsystems/4. Runtime & Middleware Framework.md b/docs/architecture docs/subsystems/4. Runtime & Middleware Framework.md index 05bf57184b..96d4c8cee3 100644 --- a/docs/architecture docs/subsystems/4. Runtime & Middleware Framework.md +++ b/docs/architecture docs/subsystems/4. Runtime & Middleware Framework.md @@ -231,7 +231,56 @@ export interface InterceptResult { **Plan-identity invariant.** Interceptors that need per-execution scratch space (the cache middleware's miss buffer, request-coalescing pending sets, etc.) commonly key a private `WeakMap` on the post-lowering plan object. This relies on each call producing a fresh `exec` identity. Both family runtimes satisfy this today: SQL `executeAgainstQueryable` constructs a fresh `Object.freeze({...lowered, params: ...})` on every call; Mongo lowers fresh per call. If a future plan-memoization change ever recycles `exec` objects across calls, these middleware would silently leak rows between concurrent executions — which is what the cache middleware's concurrency regression test catches. See [ADR 025 — Plan caching & memoization](../adrs/ADR%20025%20-%20Plan%20Caching%20Memoization.md). -**Family-agnostic by construction.** `intercept` lives on `RuntimeMiddleware` in `framework-components`, not on `SqlMiddleware` or `MongoMiddleware`. Both family runtimes inherit it via `RuntimeCore.execute → runWithMiddleware`; no per-family wiring is required. A first-party cache middleware exercising this hook against both SQL and Mongo runtimes is planned as a follow-up. +**Family-agnostic by construction.** `intercept` lives on `RuntimeMiddleware` in `framework-components`, not on `SqlMiddleware` or `MongoMiddleware`. Both family runtimes inherit it via `RuntimeCore.execute → runWithMiddleware`; no per-family wiring is required. The first-party cache middleware ([`@prisma-next/middleware-cache`](../../../packages/3-extensions/middleware-cache/)) is the canonical interceptor example and works against both SQL and Mongo runtimes day one — see its README for the full design (opt-in via annotations, transaction-scope guard, pluggable `CacheStore`, TTL/LRU semantics). + +## Annotations + +Annotations are a typed, namespaced way to attach per-query metadata that middleware can read. They are the opt-in mechanism the cache middleware uses ("cache this read for 60 seconds"), the audit middleware would use ("attribute this write to this actor"), and any future per-query policy hook will reuse. + +The framework provides three pieces: + +- `OperationKind = 'read' | 'write'` — the binary discriminator that gates which terminals an annotation can attach to. Finer-grained kinds (`'select' | 'insert' | …`) are deferred; widening is additive. +- `defineAnnotation({ namespace, applicableTo })` — produces a typed handle with `apply(value)` and `read(plan)` methods. The handle's `applicableTo` is a frozen `ReadonlySet` consumed by both type-level and runtime applicability gates. +- `ValidAnnotations` — mapped tuple type consumed by lane terminals. Resolves any tuple element whose declared `Kinds` does not include the terminal's `K` to `never`, surfacing the mismatch as a type error at the call site. + +```typescript +// packages/1-framework/1-core/framework-components/src/annotations.ts +import { defineAnnotation } from '@prisma-next/framework-components/runtime' + +// Read-only annotation. +export const cacheAnnotation = defineAnnotation<{ ttl?: number }, 'read'>({ + namespace: 'cache', + applicableTo: ['read'], +}) + +// Write-only annotation (illustrative). +export const auditAnnotation = defineAnnotation<{ actor: string }, 'write'>({ + namespace: 'audit', + applicableTo: ['write'], +}) + +// Applicable to both kinds (e.g. tracing). +export const otelAnnotation = defineAnnotation<{ traceId: string }, 'read' | 'write'>({ + namespace: 'otel', + applicableTo: ['read', 'write'], +}) +``` + +**Lane integration.** Each lane terminal accepts a variadic `...annotations` parameter constrained by `As & ValidAnnotations` where `K` is the terminal's operation kind: + +- SQL DSL — `.annotate(...)` is chainable on every builder kind: `SelectQueryImpl` / `GroupedQueryImpl` accept `'read'`-applicable annotations; `InsertQueryImpl` / `UpdateQueryImpl` / `DeleteQueryImpl` accept `'write'`-applicable. The intersection `As & ValidAnnotations` is load-bearing — TypeScript's variadic-tuple inference is too forgiving with `ValidAnnotations` alone, but the intersection pins `As` to the call-site tuple AND requires assignability to the gated form. +- ORM `Collection` — terminal methods (`first`, `find`, `all`, `count`, aggregates; `create`, `update`, `delete`, `upsert`) accept the variadic argument with the same gated shape. There is no chainable `Collection.annotate()` — annotations attach via the terminal call only. This is an intentional scope cut: chainable form would have forced an in-middleware mutation classifier. + +**Runtime applicability check.** Every lane terminal calls `assertAnnotationsApplicable(annotations, kind, terminalName)` before plan construction. The helper iterates the array and, on any annotation whose `applicableTo` set lacks `kind`, throws `RUNTIME.ANNOTATION_INAPPLICABLE` naming the namespace and terminal. This is belt-and-suspenders: the type system fails closed for type-aware callers, and the runtime check fails closed for casts / `any` / dynamic invocations. + +**Storage.** Applied annotations land under `plan.meta.annotations[namespace]` as branded `AnnotationValue` objects. Multiple `.annotate()` calls or terminal arguments compose; duplicate namespaces use last-write-wins. Reading via `handle.read(plan)` defensively checks the `__annotation` brand so framework-internal metadata (e.g. `meta.annotations.codecs` written by the SQL emitter) is not mistaken for a user annotation. + +**Reserved namespaces.** `codecs` is consumed by the SQL emitter (`meta.annotations.codecs[alias] = 'pg/text@1'`) and read by the SQL runtime's `decodeRow`; it must not be used by user handles. Target-specific keys such as `pg` are similarly reserved. `defineAnnotation` does not structurally prevent a user from naming a reserved namespace — handles are namespaced strings, not branded types — but the framework makes no compatibility guarantees about handles that do. + +**Runtime context: `contentHash` and `scope`.** Two `RuntimeMiddlewareContext` fields support the annotation-driven middleware ecosystem: + +- `contentHash(exec): Promise` — the family runtime returns an opaque, bounded digest identifying the `(storage, statement, params)` tuple of an execution. SQL composes `meta.storageHash + '|' + exec.sql + '|' + canonicalStringify(exec.params)` and pipes through `hashContent` (SHA-512); Mongo composes `meta.storageHash + '|' + canonicalStringify(exec.command)` and pipes the same way. Two semantically equivalent executions return the same digest. Used by middleware that need per-execution identity (caching, request coalescing). The cache middleware uses the returned string directly as a `Map` key — it is not (and should not be) further hashed by callers. +- `scope: 'runtime' | 'connection' | 'transaction'` — discriminates the queryable surface the execution is running under. Top-level `runtime.execute` populates `'runtime'`; `connection.execute` and `transaction.execute` derive a context with `'connection'` / `'transaction'`. Middleware that should only act at the top level (e.g. the cache middleware) read this field to bypass non-runtime scopes. ## ORM Client Integration @@ -476,6 +525,7 @@ export interface RuntimeMiddleware { - `intercept` is optional and lets a middleware short-circuit execution and supply rows directly. See "Intercepting Execution" above. - `beforeExecute` runs on the driver path only and is where verification and guardrails live. On the intercepted hit path it is skipped. - `onRow` is optional and called for each streamed row on the driver path; intercepted rows skip `onRow`. `afterExecute` receives aggregates including the `completed` flag (success vs error path) and the `source` field (`'driver'` vs `'middleware'`). +- `RuntimeMiddlewareContext` carries `contentHash(exec)` (a bounded opaque digest of the execution identity) and `scope` (`'runtime' | 'connection' | 'transaction'`) for middleware that need per-execution identity or scope-aware behavior. ### Family-Specific Middleware Interfaces @@ -537,7 +587,7 @@ export const softDelete: SqlMiddleware = { **Raw SQL lanes.** `beforeCompile` only runs when the lane produces a `SqlQueryPlan` (AST present). Raw SQL plans — which arrive as fully-lowered `SqlExecutionPlan` — bypass the hook entirely (the SQL runtime's `executeAgainstQueryable` branches on plan shape). -**Scope.** `beforeCompile` is the current AST-rewrite surface for SQL. Mongo has no typed `beforeCompile` chain today; the abstract base's `runBeforeCompile` defaults to identity for it. Short-circuiting the query with a static result is provided by the cross-family `intercept` hook (see "Intercepting Execution" above) — it lives on `RuntimeMiddleware` rather than in the SQL `beforeCompile` chain. User-authored query annotations are deferred to later milestones; their API is not yet defined. +**Scope.** `beforeCompile` is the current AST-rewrite surface for SQL. Mongo has no typed `beforeCompile` chain today; the abstract base's `runBeforeCompile` defaults to identity for it. Short-circuiting the query with a static result is provided by the cross-family `intercept` hook (see "Intercepting Execution" above); user-authored query annotations are provided by the framework `defineAnnotation` helper plus lane-side `.annotate(...)` (see "Annotations" above) — both are family-agnostic and live on `RuntimeMiddleware` / lane terminals rather than in the SQL `beforeCompile` chain. ## Guardrails via Middleware @@ -615,6 +665,10 @@ Tests ensure the middleware contract remains stable, plan identity is preserved, - `runWithMiddleware` unit tests (registration order, error path with `completed: false`, error swallowing in `afterExecute` during the error path, zero-middleware passthrough, and the full `intercept` matrix: first-wins semantics, hit path skipping `beforeExecute` / `runDriver` / `onRow`, hit + `source: 'middleware'` on `afterExecute`, miss-path zero-change regression, mixed chains, interceptor-throw paths, and the `Iterable` / `AsyncIterable` row-source variants). - `RuntimeCore` mock-family tests demonstrate that a minimal subclass with concrete `lower` / `runDriver` / `close` (and nothing else) typechecks, constructs, and exercises the lifecycle. - Cross-family proof: the same generic middleware observes queries from both SQL and Mongo runtimes, **and** the same generic interceptor short-circuits queries from both runtimes via inherited `runWithMiddleware` (preserved and extended from `cross-family-middleware-spi`). +- Annotations: unit + type tests for `defineAnnotation`, `ValidAnnotations` (positive + negative on both kinds), `assertAnnotationsApplicable` (cast-bypass coverage), and lane-side `.annotate(...)` on every SQL DSL builder kind plus every ORM terminal. +- Family `contentHash` implementations: SQL and Mongo unit tests cover stability across object-key order, discrimination on `storageHash` and params/command, fixed-size opaque output (`sha512:HEXDIGEST`), and round-trip determinism. +- Cache middleware: package unit tests (opt-in semantics, hit/miss paths, scope guard, key composition incl. user-supplied `key` override and Mongo-style mock parity, store mechanics — LRU + TTL with injected clock) plus integration tests against real Postgres covering the April stop condition (driver invocation count = 1 on a repeated annotated read), composition with a `beforeCompile` rewriter (cache key reflects rewritten SQL), composition with telemetry (`source` round-trip), and concurrency (parallel executes do not cross-talk via the per-exec `WeakMap` buffer). +- SQL runtime scope plumbing: `connection.execute` and `transaction.execute` populate `ctx.scope` correctly; round-trips through `runtime → connection → transaction → runtime` route to the right scope each time. - Golden SQL and hash stability tests to ensure Plan immutability and identity do not drift. - Violation matrix ensuring built-in rules behave consistently under `strict` and `permissive` modes. - Benchmarks to validate overhead budgets. From 29a243ac16d3fe7cb3b050eab61899cb85f38462 Mon Sep 17 00:00:00 2001 From: Alexey Orlenko Date: Wed, 29 Apr 2026 14:11:50 +0200 Subject: [PATCH 16/21] feat(prisma-next-demo): add cache middleware examples --- examples/prisma-next-demo/README.md | 41 +++++ examples/prisma-next-demo/package.json | 1 + examples/prisma-next-demo/src/main.ts | 73 ++++++++ .../src/orm-client/find-user-by-id-cached.ts | 70 ++++++++ .../src/orm-client/get-users-cached.ts | 44 +++++ .../src/prisma-no-emit/runtime.ts | 6 + examples/prisma-next-demo/src/prisma/db.ts | 7 + .../src/queries/get-users-cached.ts | 27 +++ .../test/repositories.integration.test.ts | 170 +++++++++++++++++- pnpm-lock.yaml | 3 + 10 files changed, 440 insertions(+), 2 deletions(-) create mode 100644 examples/prisma-next-demo/src/orm-client/find-user-by-id-cached.ts create mode 100644 examples/prisma-next-demo/src/orm-client/get-users-cached.ts create mode 100644 examples/prisma-next-demo/src/queries/get-users-cached.ts diff --git a/examples/prisma-next-demo/README.md b/examples/prisma-next-demo/README.md index e50ad0a5d3..c5d0d1c03d 100644 --- a/examples/prisma-next-demo/README.md +++ b/examples/prisma-next-demo/README.md @@ -106,6 +106,8 @@ The demo includes ORM client examples under `src/orm-client/`: - `ormClientGetUserInsights(limit, runtime)` — `include().combine()` metrics and latest related row - `ormClientGetUserKindBreakdown(minUsers, runtime)` — `groupBy().having().aggregate()` breakdown - `ormClientUpsertUser(data, runtime)` — `upsert()` for create-or-update by primary key +- `ormClientFindUserByIdCached(id, runtime, options?)` — opt-in cached `first({ id })` lookup via `cacheAnnotation.apply({ ttl })` from `@prisma-next/middleware-cache` +- `ormClientGetUsersCached(limit, runtime, options?)` — opt-in cached `User.all()` listing, with optional explicit cache-key override Run from the CLI: @@ -123,6 +125,45 @@ pnpm start -- repo-kind-breakdown 1 pnpm start -- repo-upsert-user 00000000-0000-0000-0000-000000000099 demo@example.com user ``` +## Cache Middleware Examples + +The demo wires `@prisma-next/middleware-cache` into the Postgres client in `src/prisma/db.ts`. The cache middleware is **opt-in per query** — it only acts on plans whose `meta.annotations` carry a `cacheAnnotation` payload with a `ttl` set. Three CLI commands run a query twice and report the latency of each call so the cache hit is visible: + +```bash +# ORM client first({ id }) cached for 60s. +pnpm start -- cache-demo-user 00000000-0000-0000-0000-000000000001 + +# ORM client User.all() listing cached for 60s. +pnpm start -- cache-demo-users 5 + +# SQL DSL .annotate(cacheAnnotation.apply({ ttl })) on a select. +pnpm start -- cache-demo-sql 5 +``` + +A representative run looks like: + +``` +Demonstrating opt-in caching with cacheAnnotation... +Calling User.first({ id: 00000000-... }) twice — second call should hit cache. + +First call (cache miss): 4.71ms +Second call (cache hit): 0.18ms +Speedup: 26.2x faster +``` + +The corresponding source files: + +- `src/orm-client/find-user-by-id-cached.ts` — `db.User.first({ id }, cacheAnnotation.apply({ ttl }))` +- `src/orm-client/get-users-cached.ts` — `db.User.take(n).all(cacheAnnotation.apply({ ttl, key? }))` +- `src/queries/get-users-cached.ts` — `db.sql.user.select(...).annotate(cacheAnnotation.apply({ ttl })).build()` + +Relevant points: + +- The `cacheAnnotation` handle declares `applicableTo: ['read']`. Passing it to a write terminal is rejected at both the type and runtime levels — a `as any` cast cannot smuggle it past one without failing at the other. +- The default cache key is `RuntimeMiddlewareContext.contentHash(exec)`, a SHA-512 digest of the post-lowering SQL plus parameters. Different parameters land in different cache slots; identical executions hit. Schema migrations rotate `meta.storageHash`, which feeds into `contentHash`, so cached entries do not leak across migrations. +- The default in-memory store is per-process. For shared caching across replicas, supply a custom `CacheStore` (for example a Redis-backed implementation) via `createCacheMiddleware({ store })`. +- Connection-scoped (`runtime.connection().execute(...)`) and transaction-scoped (`runtime.transaction(...)`) executions bypass the cache regardless of annotation, so transactional read-after-write coherence is preserved. + ## Setup 1. Install dependencies: diff --git a/examples/prisma-next-demo/package.json b/examples/prisma-next-demo/package.json index f133e11437..94ba4a1196 100644 --- a/examples/prisma-next-demo/package.json +++ b/examples/prisma-next-demo/package.json @@ -29,6 +29,7 @@ "@prisma-next/extension-pgvector": "workspace:*", "@prisma-next/family-sql": "workspace:*", "@prisma-next/ids": "workspace:*", + "@prisma-next/middleware-cache": "workspace:*", "@prisma-next/middleware-telemetry": "workspace:*", "@prisma-next/postgres": "workspace:*", "@prisma-next/sql-contract": "workspace:*", diff --git a/examples/prisma-next-demo/src/main.ts b/examples/prisma-next-demo/src/main.ts index 23a406c01a..213f410360 100644 --- a/examples/prisma-next-demo/src/main.ts +++ b/examples/prisma-next-demo/src/main.ts @@ -42,6 +42,12 @@ * authors via a self-join on a non-relation predicate, with * cosineDistance over two column references — a shape the * current ORM collection surface cannot directly express. + * - cache-demo-user Cached `User.first({ id })` lookup. Runs the + * same query twice and reports cache hit/miss + * by observing telemetry's `source` field. + * - cache-demo-users [limit] Cached `User.all()` listing via ORM client. + * - cache-demo-sql [limit] Cached SQL DSL select. Runs the same plan + * twice and observes the cache short-circuit. * - budget-violation Demo budget enforcement error * - guardrail-delete Demo AST lint blocking DELETE without WHERE * @@ -54,6 +60,7 @@ import { loadAppConfig } from './app-config'; import { ormClientCreateUserWithAddress } from './orm-client/create-user-with-address'; import { ormClientFindSimilarPosts } from './orm-client/find-similar-posts'; import { ormClientFindUserByEmail } from './orm-client/find-user-by-email'; +import { ormClientFindUserByIdCached } from './orm-client/find-user-by-id-cached'; import { ormClientGetAdminUsers } from './orm-client/get-admin-users'; import { ormClientGetDashboardUsers } from './orm-client/get-dashboard-users'; import { ormClientGetLatestUserPerKind } from './orm-client/get-latest-user-per-kind'; @@ -65,6 +72,7 @@ import { ormClientGetUserPosts } from './orm-client/get-user-posts'; import { ormClientGetUsers } from './orm-client/get-users'; import { ormClientGetUsersBackwardCursor } from './orm-client/get-users-backward-cursor'; import { ormClientGetUsersByIdCursor } from './orm-client/get-users-by-id-cursor'; +import { ormClientGetUsersCached } from './orm-client/get-users-cached'; import { ormClientSearchPostsByEmbedding } from './orm-client/search-posts-by-embedding'; import { ormClientUpsertUser } from './orm-client/upsert-user'; import { db } from './prisma/db'; @@ -74,6 +82,7 @@ import { getAllPostsUnbounded } from './queries/get-all-posts-unbounded'; import { getUserById } from './queries/get-user-by-id'; import { getUserPosts } from './queries/get-user-posts'; import { getUsers } from './queries/get-users'; +import { getUsersCached } from './queries/get-users-cached'; import { similaritySearch } from './queries/similarity-search'; const argv = process.argv.slice(2).filter((arg) => arg !== '--'); @@ -337,6 +346,69 @@ async function main() { const results = await crossAuthorSimilarity(limit); console.log(JSON.stringify(results, null, 2)); + } else if (cmd === 'cache-demo-user') { + const [userIdStr] = args; + if (!userIdStr) { + console.error('Usage: pnpm start -- cache-demo-user '); + process.exit(1); + } + console.log('Demonstrating opt-in caching with cacheAnnotation...'); + console.log( + `Calling User.first({ id: ${userIdStr} }) twice — second call should hit cache.\n`, + ); + + const firstStart = performance.now(); + const first = await ormClientFindUserByIdCached(userIdStr, runtime); + const firstMs = performance.now() - firstStart; + + const secondStart = performance.now(); + const second = await ormClientFindUserByIdCached(userIdStr, runtime); + const secondMs = performance.now() - secondStart; + + console.log(`First call (cache miss): ${firstMs.toFixed(2)}ms`); + console.log(`Second call (cache hit): ${secondMs.toFixed(2)}ms`); + console.log(`Speedup: ${(firstMs / Math.max(secondMs, 0.001)).toFixed(1)}x faster`); + console.log('\nResult (identical between calls):'); + console.log(JSON.stringify(second, null, 2)); + void first; + } else if (cmd === 'cache-demo-users') { + const limit = args[0] ? Number.parseInt(args[0], 10) : 10; + console.log('Demonstrating opt-in caching with cacheAnnotation on User.all()...'); + console.log(`Listing ${limit} users twice — second call should hit cache.\n`); + + const firstStart = performance.now(); + const first = await ormClientGetUsersCached(limit, runtime); + const firstMs = performance.now() - firstStart; + + const secondStart = performance.now(); + const second = await ormClientGetUsersCached(limit, runtime); + const secondMs = performance.now() - secondStart; + + console.log(`First call (cache miss): ${firstMs.toFixed(2)}ms`); + console.log(`Second call (cache hit): ${secondMs.toFixed(2)}ms`); + console.log(`Speedup: ${(firstMs / Math.max(secondMs, 0.001)).toFixed(1)}x faster`); + console.log(`\nReturned ${second.length} rows (identical between calls).`); + void first; + } else if (cmd === 'cache-demo-sql') { + const limit = args[0] ? Number.parseInt(args[0], 10) : 10; + console.log( + 'Demonstrating opt-in caching via SQL DSL .annotate(cacheAnnotation.apply(...))...', + ); + console.log('Running the same select twice — second call should hit cache.\n'); + + const firstStart = performance.now(); + const first = await getUsersCached(limit); + const firstMs = performance.now() - firstStart; + + const secondStart = performance.now(); + const second = await getUsersCached(limit); + const secondMs = performance.now() - secondStart; + + console.log(`First call (cache miss): ${firstMs.toFixed(2)}ms`); + console.log(`Second call (cache hit): ${secondMs.toFixed(2)}ms`); + console.log(`Speedup: ${(firstMs / Math.max(secondMs, 0.001)).toFixed(1)}x faster`); + console.log(`\nReturned ${second.length} rows (identical between calls).`); + void first; } else if (cmd === 'budget-violation') { console.log('Running unbounded query to demonstrate budget violation...'); @@ -390,6 +462,7 @@ async function main() { 'repo-similar-posts [limit] | repo-search-posts [limit] | ' + 'users-paginate [cursor] [limit] | users-paginate-back [limit] | ' + 'similarity-search [limit] | cross-author-similarity [limit] | ' + + 'cache-demo-user | cache-demo-users [limit] | cache-demo-sql [limit] | ' + 'budget-violation | guardrail-delete]', ); process.exit(1); diff --git a/examples/prisma-next-demo/src/orm-client/find-user-by-id-cached.ts b/examples/prisma-next-demo/src/orm-client/find-user-by-id-cached.ts new file mode 100644 index 0000000000..8fbac31eab --- /dev/null +++ b/examples/prisma-next-demo/src/orm-client/find-user-by-id-cached.ts @@ -0,0 +1,70 @@ +/** + * Cached `User.first({ id })` lookup. + * + * Demonstrates the read-only `cacheAnnotation` from + * `@prisma-next/middleware-cache`. The annotation is opt-in: the cache + * middleware only acts on plans whose `meta.annotations` carry a + * `cacheAnnotation` payload with a `ttl` set. Calling the same lookup + * with the same `id` within the TTL window is served from the in-memory + * LRU configured in `src/prisma/db.ts` — the driver is **not** invoked + * the second time. + * + * The cache key is composed by the runtime via + * `RuntimeMiddlewareContext.contentHash(exec)`, which incorporates the + * post-lowering SQL plus parameters. Two lookups for different `id` + * values therefore land in different cache slots; the same `id` hits. + * + * Notes worth pinning: + * + * - `cacheAnnotation` declares `applicableTo: ['read']`. Passing it to + * a write terminal (`create`, `update`, `delete`) is a type error + * *and* a runtime error — it cannot be smuggled through with a cast + * on one side without failing on the other. + * - On a cache hit, telemetry's `afterExecute` event reports + * `source: 'middleware'`. Telemetry is wired in front of the cache + * in `db.ts`, so observability still works for cached reads. + * - Schema migrations rotate `meta.storageHash`, which feeds + * `contentHash`, so cached entries from a previous schema cannot + * accidentally serve queries against the new schema. + */ + +import { cacheAnnotation } from '@prisma-next/middleware-cache'; +import type { DefaultModelRow } from '@prisma-next/sql-orm-client'; +import type { Runtime } from '@prisma-next/sql-runtime'; +import type { Contract } from '../prisma/contract.d'; +import { createOrmClient } from './client'; + +type UserId = DefaultModelRow['id']; + +export interface CachedLookupOptions { + /** + * Time-to-live for the cached entry, in milliseconds. Defaults to + * 60 seconds when not supplied. The cache middleware passes the + * query through unchanged when `ttl` is omitted from the + * annotation, so we always set one here. + */ + readonly ttlMs?: number; + /** + * When `true`, the cache middleware passes the query through + * untouched even if the annotation carries a `ttl`. Useful as a + * "force refresh" knob without removing the annotation entirely. + */ + readonly forceRefresh?: boolean; +} + +export async function ormClientFindUserByIdCached( + id: string, + runtime: Runtime, + options: CachedLookupOptions = {}, +) { + const db = createOrmClient(runtime); + const ttl = options.ttlMs ?? 60_000; + return db.User.first( + { id: toUserId(id) }, + cacheAnnotation.apply({ ttl, skip: options.forceRefresh ?? false }), + ); +} + +function toUserId(value: string): UserId { + return value as UserId; +} diff --git a/examples/prisma-next-demo/src/orm-client/get-users-cached.ts b/examples/prisma-next-demo/src/orm-client/get-users-cached.ts new file mode 100644 index 0000000000..2e9349599a --- /dev/null +++ b/examples/prisma-next-demo/src/orm-client/get-users-cached.ts @@ -0,0 +1,44 @@ +/** + * Cached `User.all()` listing. + * + * Companion to `find-user-by-id-cached.ts` — same opt-in caching + * mechanism, this time on a multi-row read terminal. The trailing + * variadic of `.all(...)` accepts read-typed annotations; we pass + * `cacheAnnotation.apply({ ttl })` to enable caching of the + * post-lowering execution. + * + * The example also shows the per-query `key` override. When set, the + * supplied string is used verbatim as the cache key; the cache + * middleware does not rehash it. This is useful for sharing entries + * across slightly different plans whose results you know to be + * equivalent (e.g. the same user list rendered through two different + * `select` shapes), but the trade-off is that you take responsibility + * for keeping the key bounded and free of sensitive data — the + * default `contentHash(exec)` digest is a SHA-512 hash with no + * such risks. + */ + +import { cacheAnnotation } from '@prisma-next/middleware-cache'; +import type { Runtime } from '@prisma-next/sql-runtime'; +import { createOrmClient } from './client'; + +export interface CachedListOptions { + readonly ttlMs?: number; + /** + * Optional override for the cache key. When omitted, the runtime's + * `contentHash(exec)` is used (the default and recommended path). + */ + readonly key?: string; +} + +export async function ormClientGetUsersCached( + limit: number, + runtime: Runtime, + options: CachedListOptions = {}, +) { + const db = createOrmClient(runtime); + const ttl = options.ttlMs ?? 60_000; + return db.User.take(limit).all( + cacheAnnotation.apply(options.key !== undefined ? { ttl, key: options.key } : { ttl }), + ); +} diff --git a/examples/prisma-next-demo/src/prisma-no-emit/runtime.ts b/examples/prisma-next-demo/src/prisma-no-emit/runtime.ts index eaade94fd4..0d1a5952aa 100644 --- a/examples/prisma-next-demo/src/prisma-no-emit/runtime.ts +++ b/examples/prisma-next-demo/src/prisma-no-emit/runtime.ts @@ -1,4 +1,5 @@ import { instantiateExecutionStack } from '@prisma-next/framework-components/execution'; +import { createCacheMiddleware } from '@prisma-next/middleware-cache'; import { createTelemetryMiddleware } from '@prisma-next/middleware-telemetry'; import { budgets, createRuntime, type Runtime, type SqlMiddleware } from '@prisma-next/sql-runtime'; import { Pool } from 'pg'; @@ -7,6 +8,11 @@ import { context, stack } from './context'; export async function getRuntime( databaseUrl: string, middleware: SqlMiddleware[] = [ + // Cache first: short-circuits annotated reads on a hit before + // telemetry's `beforeExecute` runs. Both middleware see the + // `afterExecute` event regardless, so observability still works + // for cached reads. + createCacheMiddleware({ maxEntries: 1_000 }), createTelemetryMiddleware(), budgets({ maxRows: 10_000, diff --git a/examples/prisma-next-demo/src/prisma/db.ts b/examples/prisma-next-demo/src/prisma/db.ts index 070af39a07..25d0173413 100644 --- a/examples/prisma-next-demo/src/prisma/db.ts +++ b/examples/prisma-next-demo/src/prisma/db.ts @@ -1,4 +1,5 @@ import pgvector from '@prisma-next/extension-pgvector/runtime'; +import { createCacheMiddleware } from '@prisma-next/middleware-cache'; import { createTelemetryMiddleware } from '@prisma-next/middleware-telemetry'; import postgres from '@prisma-next/postgres/runtime'; import { budgets, lints } from '@prisma-next/sql-runtime'; @@ -9,6 +10,12 @@ export const db = postgres({ contractJson, extensions: [pgvector], middleware: [ + // Cache first so its `intercept` short-circuits before any other + // middleware's `beforeExecute` fires on a hit. Telemetry's + // `afterExecute` still runs on both paths and observes the + // `source: 'driver' | 'middleware'` field, so cache hits are + // visible through whichever observability sink is plugged in. + createCacheMiddleware({ maxEntries: 1_000 }), createTelemetryMiddleware(), lints(), budgets({ diff --git a/examples/prisma-next-demo/src/queries/get-users-cached.ts b/examples/prisma-next-demo/src/queries/get-users-cached.ts new file mode 100644 index 0000000000..9d287847f0 --- /dev/null +++ b/examples/prisma-next-demo/src/queries/get-users-cached.ts @@ -0,0 +1,27 @@ +/** + * Cached SQL DSL `select` example. + * + * Mirrors `get-users.ts` but adds `.annotate(cacheAnnotation.apply(...))` + * to opt the plan into the cache middleware registered on the runtime + * in `src/prisma/db.ts`. The annotation is a read-only handle, so the + * type system rejects passing it to write builders (`insert`, + * `update`, `delete`); the runtime gate fails closed for callers that + * bypass the type check with a cast. + * + * The cache key is computed by the runtime via + * `RuntimeMiddlewareContext.contentHash(exec)` — the post-lowering + * statement plus parameters, hashed to a bounded SHA-512 digest. + * Subsequent calls with the same plan within the TTL window are + * served from the cache without invoking the driver. + */ +import { cacheAnnotation } from '@prisma-next/middleware-cache'; +import { db } from '../prisma/db'; + +export async function getUsersCached(limit = 10, ttlMs = 60_000) { + const plan = db.sql.user + .select('id', 'email', 'createdAt', 'kind') + .annotate(cacheAnnotation.apply({ ttl: ttlMs })) + .limit(limit) + .build(); + return db.runtime().execute(plan); +} diff --git a/examples/prisma-next-demo/test/repositories.integration.test.ts b/examples/prisma-next-demo/test/repositories.integration.test.ts index 0eb44c3b9f..e9a119f1a9 100644 --- a/examples/prisma-next-demo/test/repositories.integration.test.ts +++ b/examples/prisma-next-demo/test/repositories.integration.test.ts @@ -1,10 +1,16 @@ import { instantiateExecutionStack } from '@prisma-next/framework-components/execution'; +import { createCacheMiddleware } from '@prisma-next/middleware-cache'; import { sql } from '@prisma-next/sql-builder/runtime'; import type { SqlDriver } from '@prisma-next/sql-relational-core/ast'; -import { type CreateRuntimeOptions, createRuntime, type Runtime } from '@prisma-next/sql-runtime'; +import { + type CreateRuntimeOptions, + createRuntime, + type Runtime, + type SqlMiddleware, +} from '@prisma-next/sql-runtime'; import { timeouts, withDevDatabase } from '@prisma-next/test-utils'; import { Pool } from 'pg'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { ormClientAggregateUsers } from '../src/orm-client/aggregate-users'; import { ormClientCreateUser } from '../src/orm-client/create-user'; import { ormClientCreateUserWithAddress } from '../src/orm-client/create-user-with-address'; @@ -12,6 +18,7 @@ import { ormClientDeleteUser } from '../src/orm-client/delete-user'; import { ormClientFindSimilarPosts } from '../src/orm-client/find-similar-posts'; import { ormClientFindUserByEmail } from '../src/orm-client/find-user-by-email'; import { ormClientFindUserById } from '../src/orm-client/find-user-by-id'; +import { ormClientFindUserByIdCached } from '../src/orm-client/find-user-by-id-cached'; import { ormClientGetAdminUsers } from '../src/orm-client/get-admin-users'; import { ormClientGetDashboardUsers } from '../src/orm-client/get-dashboard-users'; import { ormClientGetLatestUserPerKind } from '../src/orm-client/get-latest-user-per-kind'; @@ -22,6 +29,7 @@ import { ormClientGetUserPosts } from '../src/orm-client/get-user-posts'; import { ormClientGetUsers } from '../src/orm-client/get-users'; import { ormClientGetUsersBackwardCursor } from '../src/orm-client/get-users-backward-cursor'; import { ormClientGetUsersByIdCursor } from '../src/orm-client/get-users-by-id-cursor'; +import { ormClientGetUsersCached } from '../src/orm-client/get-users-cached'; import { ormClientSearchPostsByEmbedding } from '../src/orm-client/search-posts-by-embedding'; import { ormClientUpdateUserEmail } from '../src/orm-client/update-user-email'; import { ormClientUpsertUser } from '../src/orm-client/upsert-user'; @@ -60,6 +68,26 @@ async function getRuntime(connectionString: string): Promise { }); } +/** + * Creates a runtime wired up with the supplied middleware. Used by + * cache-middleware tests below; each test owns its own cache instance + * so entries don't bleed between tests. + */ +async function getRuntimeWithMiddleware( + connectionString: string, + middleware: readonly SqlMiddleware[], +): Promise<{ runtime: Runtime; driver: SqlDriver }> { + const { stackInstance, driver } = await createTestDriver(connectionString); + const runtime = createRuntime({ + stackInstance, + context, + driver, + verify: { mode: 'onFirstUse', requireMarker: false }, + middleware, + }); + return { runtime, driver }; +} + const seededUserIds = { admin: '00000000-0000-0000-0000-000000000001', member: '00000000-0000-0000-0000-000000000002', @@ -776,4 +804,142 @@ describe('ORM client integration examples', () => { }, timeouts.spinUpPpgDev, ); + + // --------------------------------------------------------------------------- + // Cache middleware integration tests. + // + // The cache helpers under `src/orm-client/find-user-by-id-cached.ts` and + // `src/orm-client/get-users-cached.ts` opt their reads into the + // `@prisma-next/middleware-cache` middleware via `cacheAnnotation.apply(...)`. + // The middleware short-circuits repeated executions of the same plan via + // its `intercept` hook, so a cache hit means the SQL driver is *not* + // invoked again. We assert that contract by spying on `driver.execute`. + // --------------------------------------------------------------------------- + + it( + 'ormClientFindUserByIdCached serves the second call from the cache (driver.execute not invoked again)', + async () => { + await withDevDatabase(async ({ connectionString }) => { + await initTestDatabase({ connection: connectionString, contract }); + const cache = createCacheMiddleware({ maxEntries: 100 }); + const { runtime, driver } = await getRuntimeWithMiddleware(connectionString, [cache]); + + try { + await seedOrmClientData(runtime); + + // Spy *after* seeding so we don't count seed inserts. + const driverExecuteSpy = vi.spyOn(driver, 'execute'); + + const first = await ormClientFindUserByIdCached(seededUserIds.admin, runtime); + const driverCallsAfterFirst = driverExecuteSpy.mock.calls.length; + expect(driverCallsAfterFirst).toBeGreaterThan(0); + expect(first).toMatchObject({ id: seededUserIds.admin, kind: 'admin' }); + + const second = await ormClientFindUserByIdCached(seededUserIds.admin, runtime); + // Cache hit: driver was not invoked again. + expect(driverExecuteSpy.mock.calls.length).toBe(driverCallsAfterFirst); + expect(second).toEqual(first); + } finally { + await runtime.close(); + } + }); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'ormClientFindUserByIdCached forceRefresh: true bypasses the cache (skip annotation)', + async () => { + await withDevDatabase(async ({ connectionString }) => { + await initTestDatabase({ connection: connectionString, contract }); + const cache = createCacheMiddleware({ maxEntries: 100 }); + const { runtime, driver } = await getRuntimeWithMiddleware(connectionString, [cache]); + + try { + await seedOrmClientData(runtime); + const driverExecuteSpy = vi.spyOn(driver, 'execute'); + + // Prime the cache. + await ormClientFindUserByIdCached(seededUserIds.admin, runtime); + const callsAfterFirst = driverExecuteSpy.mock.calls.length; + + // Same query but with skip — should hit the driver again. + await ormClientFindUserByIdCached(seededUserIds.admin, runtime, { + forceRefresh: true, + }); + expect(driverExecuteSpy.mock.calls.length).toBeGreaterThan(callsAfterFirst); + } finally { + await runtime.close(); + } + }); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'ormClientGetUsersCached serves the second call from cache; different limits land in distinct slots', + async () => { + await withDevDatabase(async ({ connectionString }) => { + await initTestDatabase({ connection: connectionString, contract }); + const cache = createCacheMiddleware({ maxEntries: 100 }); + const { runtime, driver } = await getRuntimeWithMiddleware(connectionString, [cache]); + + try { + await seedOrmClientData(runtime); + const driverExecuteSpy = vi.spyOn(driver, 'execute'); + + const first = await ormClientGetUsersCached(2, runtime); + const callsAfterFirst = driverExecuteSpy.mock.calls.length; + expect(first).toHaveLength(2); + + const second = await ormClientGetUsersCached(2, runtime); + // Same plan: cache hit, driver not invoked. + expect(driverExecuteSpy.mock.calls.length).toBe(callsAfterFirst); + expect(second).toEqual(first); + + // Different plan (different limit → different params → different + // identity key): cache miss, driver invoked. + await ormClientGetUsersCached(3, runtime); + expect(driverExecuteSpy.mock.calls.length).toBeGreaterThan(callsAfterFirst); + } finally { + await runtime.close(); + } + }); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'ormClientGetUsersCached supports an explicit cache key for sharing entries across plans', + async () => { + await withDevDatabase(async ({ connectionString }) => { + await initTestDatabase({ connection: connectionString, contract }); + const cache = createCacheMiddleware({ maxEntries: 100 }); + const { runtime, driver } = await getRuntimeWithMiddleware(connectionString, [cache]); + + try { + await seedOrmClientData(runtime); + const driverExecuteSpy = vi.spyOn(driver, 'execute'); + + // Prime the cache under an explicit key. + const first = await ormClientGetUsersCached(2, runtime, { key: 'user-list:demo' }); + const callsAfterFirst = driverExecuteSpy.mock.calls.length; + expect(first).toHaveLength(2); + + // Different limit (→ different identity key) but same explicit + // key: the entry is shared. The cache middleware uses the + // supplied key verbatim and serves the previously buffered + // rows even though the underlying SQL has changed. (Two-row + // rows from the first call — the explicit key takes precedence + // over the canonical identity key.) + const second = await ormClientGetUsersCached(5, runtime, { key: 'user-list:demo' }); + expect(driverExecuteSpy.mock.calls.length).toBe(callsAfterFirst); + expect(second).toEqual(first); + } finally { + await runtime.close(); + } + }); + }, + timeouts.spinUpPpgDev, + ); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 90476f455a..4ee522dc14 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -294,6 +294,9 @@ importers: '@prisma-next/ids': specifier: workspace:* version: link:../../packages/1-framework/2-authoring/ids + '@prisma-next/middleware-cache': + specifier: workspace:* + version: link:../../packages/3-extensions/middleware-cache '@prisma-next/middleware-telemetry': specifier: workspace:* version: link:../../packages/3-extensions/middleware-telemetry From ab56351f2dc37b55e4c953f53db0e57052a76c4a Mon Sep 17 00:00:00 2001 From: Alexey Orlenko Date: Wed, 6 May 2026 11:44:33 +0200 Subject: [PATCH 17/21] docs: plan more ergonomic annotation api --- .../4. Runtime & Middleware Framework.md | 14 ++++---- .../middleware-intercept-and-cache/plan.md | 36 ++++++++++--------- .../middleware-intercept-and-cache/spec.md | 26 +++++++------- 3 files changed, 40 insertions(+), 36 deletions(-) diff --git a/docs/architecture docs/subsystems/4. Runtime & Middleware Framework.md b/docs/architecture docs/subsystems/4. Runtime & Middleware Framework.md index 96d4c8cee3..342d1aa209 100644 --- a/docs/architecture docs/subsystems/4. Runtime & Middleware Framework.md +++ b/docs/architecture docs/subsystems/4. Runtime & Middleware Framework.md @@ -240,27 +240,27 @@ Annotations are a typed, namespaced way to attach per-query metadata that middle The framework provides three pieces: - `OperationKind = 'read' | 'write'` — the binary discriminator that gates which terminals an annotation can attach to. Finer-grained kinds (`'select' | 'insert' | …`) are deferred; widening is additive. -- `defineAnnotation({ namespace, applicableTo })` — produces a typed handle with `apply(value)` and `read(plan)` methods. The handle's `applicableTo` is a frozen `ReadonlySet` consumed by both type-level and runtime applicability gates. +- `defineAnnotation()({ namespace, applicableTo })` — curried two-step factory. The first call takes `Payload` explicitly; the second takes the runtime options and infers `Kinds` from `applicableTo` via a `const` type parameter, so the operation kinds appear once at the call site rather than being repeated as a type argument. The returned handle is a **callable function**: `handle(value)` produces an `AnnotationValue`. The function also carries `namespace`, `applicableTo: ReadonlySet` (frozen), and `read(plan)` as properties. Both `applicableTo` (off the handle and off each value) feed the type-level and runtime applicability gates. - `ValidAnnotations` — mapped tuple type consumed by lane terminals. Resolves any tuple element whose declared `Kinds` does not include the terminal's `K` to `never`, surfacing the mismatch as a type error at the call site. ```typescript // packages/1-framework/1-core/framework-components/src/annotations.ts import { defineAnnotation } from '@prisma-next/framework-components/runtime' -// Read-only annotation. -export const cacheAnnotation = defineAnnotation<{ ttl?: number }, 'read'>({ +// Read-only annotation. Kinds inferred as 'read'. +export const cacheAnnotation = defineAnnotation<{ ttl?: number }>()({ namespace: 'cache', applicableTo: ['read'], }) -// Write-only annotation (illustrative). -export const auditAnnotation = defineAnnotation<{ actor: string }, 'write'>({ +// Write-only annotation (illustrative). Kinds inferred as 'write'. +export const auditAnnotation = defineAnnotation<{ actor: string }>()({ namespace: 'audit', applicableTo: ['write'], }) -// Applicable to both kinds (e.g. tracing). -export const otelAnnotation = defineAnnotation<{ traceId: string }, 'read' | 'write'>({ +// Applicable to both kinds (e.g. tracing). Kinds inferred as 'read' | 'write'. +export const otelAnnotation = defineAnnotation<{ traceId: string }>()({ namespace: 'otel', applicableTo: ['read', 'write'], }) diff --git a/projects/middleware-intercept-and-cache/plan.md b/projects/middleware-intercept-and-cache/plan.md index e3d490a3a5..e6387099ac 100644 --- a/projects/middleware-intercept-and-cache/plan.md +++ b/projects/middleware-intercept-and-cache/plan.md @@ -65,7 +65,7 @@ Lands the additions to `@prisma-next/framework-components/runtime`. Because `int - [ ] **1.6 Mongo parity test.** Extend `test/integration/test/cross-package/cross-family-middleware.test.ts` (the cross-family proof from TML-2255) with a generic mock interceptor that returns canned rows. Run it through both SQL and Mongo runtimes. Assert: driver not invoked in either family; `afterExecute` sees `source: 'middleware'` in both; rows match canned. No production code change required — Mongo inherits `intercept` via `runWithMiddleware`. -- [ ] **1.7 Implement `defineAnnotation` helper with applicability.** Add `packages/1-framework/1-core/framework-components/src/annotations.ts`. Export `OperationKind = 'read' | 'write'`. `defineAnnotation({ namespace: string, applicableTo: readonly Kinds[] }): AnnotationHandle`. Handle has `namespace`, `applicableTo: ReadonlySet` (frozen), `apply(value: Payload): AnnotationValue`, `read(plan: { meta: { annotations?: Record } }): Payload | undefined`. `AnnotationValue` carries `__annotation: true`, `namespace`, `value`, `applicableTo`. Export from `exports/runtime.ts`. +- [ ] **1.7 Implement `defineAnnotation` helper with applicability.** Add `packages/1-framework/1-core/framework-components/src/annotations.ts`. Export `OperationKind = 'read' | 'write'`. **Curried two-step signature:** `defineAnnotation(): (options: { namespace: string; applicableTo: readonly Kinds[] }) => AnnotationHandle`. The first call takes only `Payload` as an explicit type argument; the second call takes the runtime options and infers `Kinds` from `applicableTo` via a `const` type parameter, so the operation kinds appear once at the call site rather than being repeated as a type argument. Currying is required because TypeScript does not support partial type-argument inference: with a single-step `defineAnnotation` form, callers would still have to pass both type arguments explicitly (TS2558) since `Payload` is not inferable from anywhere. The returned handle is a **callable function** (not a plain object): `handle(value: Payload)` produces an `AnnotationValue`. The function also carries `namespace: string`, `applicableTo: ReadonlySet` (frozen), and `read(plan: { meta: { annotations?: Record } }): Payload | undefined` as attached properties. Implementation pattern: outer factory returns an inner generic factory; the inner factory defines a local `function handle(value: Payload) { … }`, then `Object.assign(handle, { namespace, applicableTo, read })` and freezes the result before returning. Type the return value as a hybrid `AnnotationHandle` interface that combines a call signature with the property bag. There is intentionally no `.apply(...)` method — calling the handle directly is the sole construction path. `AnnotationValue` carries `__annotation: true`, `namespace`, `value`, `applicableTo`. Export from `exports/runtime.ts`. - [ ] **1.7a Define `ValidAnnotations` mapped tuple.** In the same module. `type ValidAnnotations[]> = { readonly [I in keyof As]: As[I] extends AnnotationValue ? K extends Kinds ? AnnotationValue : never : never }`. Export. This is the gate type lane terminals consume. @@ -74,7 +74,9 @@ Lands the additions to `@prisma-next/framework-components/runtime`. Because `int - [ ] **1.8 Unit + type tests for `defineAnnotation` and `ValidAnnotations`.** In `framework-components/test/annotations.test.ts` and `annotations.types.test-d.ts`: - Two handles with different namespaces do not interfere. - `read` returns `Payload | undefined` with preserved type (negative test on wrong payload). - - `apply` produces an `AnnotationValue` with the `__annotation` brand and frozen `applicableTo`. + - Calling the handle (e.g. `handle({ ttl: 60 })`) produces an `AnnotationValue` with the `__annotation` brand and frozen `applicableTo`. + - Handle is callable as a function (typeof handle === 'function') and exposes `namespace`, `applicableTo`, and `read` as own properties. + - Handle does **not** expose a `.apply` method (negative test: `'apply' extends keyof typeof handle` is `false`, or equivalently `handle.apply` is the inherited `Function.prototype.apply` rather than the construction entry point). - `read` of an absent annotation returns `undefined`. - `read` ignores annotations applied via a different handle even when the namespace string matches (sanity check on the brand). - `ValidAnnotations<'read', [readOnly, both]>` resolves all elements to live `AnnotationValue` types. @@ -111,7 +113,7 @@ Adds the user-facing `.annotate(...)` surface on both query lanes. After this mi - A write-only annotation accepted on `InsertQueryImpl` / `UpdateQueryImpl` / `DeleteQueryImpl`. - A write-only annotation on `SelectQueryImpl` fails to compile (negative). - A `'read' | 'write'` annotation accepted on every builder kind. - - `defineAnnotation<{ ttl: number }, 'read'>({...}).apply({ ttl: 60 })` accepts only the typed payload; wrong payload shape fails to compile (negative). + - A handle defined as `defineAnnotation<{ ttl: number }>()({ namespace, applicableTo: ['read'] })` accepts only the typed payload when called (`handle({ ttl: 60 })` typechecks; `handle({ ttl: 'sixty' })` fails to compile — negative). `Kinds` is inferred from `applicableTo` (`'read'` here) — verify by asserting the handle's static type is `AnnotationHandle<{ ttl: number }, 'read'>`, not `AnnotationHandle<{ ttl: number }, OperationKind>`. - `.annotate()` does not widen the resulting plan's `Row` type. - [ ] **2.5 Add variadic annotation arg to ORM read terminals.** In `packages/3-extensions/sql-orm-client/src/collection.ts` and `model-accessor.ts`, extend each read terminal's signature with a variadic last argument `[]>(...annotations: As & ValidAnnotations<'read', As>)`: `first`, `find`, `all`, `take().all`, `count`, aggregate methods, and any `findMany`-equivalent terminals. **Use the `As & ValidAnnotations<...>` intersection** — without it the type-level gate silently lets inapplicable annotations through (see Open Items). The terminal calls `assertAnnotationsApplicable(annotations, 'read', '')` before plan construction and merges annotations into `meta.annotations` via the existing plan-builder path (`query-plan-select.ts`, `query-plan-aggregate.ts`). Enumerate the exhaustive terminal list during implementation; if `orm-consolidation` reshapes terminals mid-project, rebase mechanically. @@ -121,17 +123,17 @@ Adds the user-facing `.annotate(...)` surface on both query lanes. After this mi - [ ] **2.7 Drop chainable `Collection.annotate()` if any draft survives.** Belt-and-suspenders: confirm no `.annotate()` survives on `Collection` itself or on grouped/include collection types. Annotations only attach via terminal arguments. `Collection` should be type-identical to its pre-annotation shape minus terminal signatures. - [ ] **2.8 Unit tests for ORM annotations.** In `sql-orm-client/test/`: - - `db.User.first({ id }, cacheAnnotation.apply({ ttl: 60 }))` produces a plan with `meta.annotations.cache`. - - `db.User.where({active: true}).take(10).all(cacheAnnotation.apply({ ttl: 60 }))` likewise. - - `db.User.create(input, writeAnnotation.apply(...))` produces a plan with the annotation. + - `db.User.first({ id }, cacheAnnotation({ ttl: 60 }))` produces a plan with `meta.annotations.cache`. + - `db.User.where({active: true}).take(10).all(cacheAnnotation({ ttl: 60 }))` likewise. + - `db.User.create(input, writeAnnotation(...))` produces a plan with the annotation. - Annotation survives `.include(...)` (relation queries) and grouped paths when attached at the appropriate terminal. - Multiple annotations on a single terminal call coexist; duplicate namespace last-wins. - Runtime check: a write-only annotation cast through `as any` and passed to `first()` throws `RUNTIME.ANNOTATION_INAPPLICABLE` at the lane. - Runtime check: read-only annotation via cast on `create()` throws likewise. - [ ] **2.9 Type tests for ORM annotations.** In `sql-orm-client/test/`: - - `db.User.first({ id }, cacheAnnotation.apply({ ttl: 60 }))` typechecks. - - `db.User.create(input, cacheAnnotation.apply({ ttl: 60 }))` does not compile (negative). + - `db.User.first({ id }, cacheAnnotation({ ttl: 60 }))` typechecks. + - `db.User.create(input, cacheAnnotation({ ttl: 60 }))` does not compile (negative). - Read-only annotation accepted on `first` / `find` / `all` / `count` / aggregates; rejected on `create` / `update` / `delete` / `upsert` (one negative case per write terminal). - Write-only annotation: mirror image. - `'read' | 'write'` annotation accepted on every terminal. @@ -152,13 +154,13 @@ Delivers `@prisma-next/middleware-cache`. Exit: integration test proves a repeat - [ ] **3.3 Scaffold `@prisma-next/middleware-cache`.** Create `packages/3-extensions/middleware-cache/` following the `middleware-telemetry` layout (`package.json`, `tsconfig.json`, `tsconfig.prod.json`, `tsdown.config.ts`, `vitest.config.ts`, `biome.jsonc`, `src/exports/index.ts`, `README.md` stub). Add to `pnpm-workspace` if needed. Run `pnpm lint:deps` after scaffold (before adding real code) to verify clean baseline. -- [ ] **3.4 Define `cacheAnnotation` handle and `CachePayload` type.** In `src/cache-annotation.ts`: `cacheAnnotation = defineAnnotation({ namespace: 'cache', applicableTo: ['read'] })`. `CachePayload = { ttl?: number; skip?: boolean; key?: string }`. Export from `exports/index.ts`. +- [ ] **3.4 Define `cacheAnnotation` handle and `CachePayload` type.** In `src/cache-annotation.ts`: `cacheAnnotation = defineAnnotation()({ namespace: 'cache', applicableTo: ['read'] })` (curried form: `Payload` explicit, `Kinds` inferred as `'read'` from `applicableTo`). `CachePayload = { ttl?: number; skip?: boolean; key?: string }`. Export from `exports/index.ts`. - [ ] **3.5 Define `CacheStore` interface and in-memory LRU default.** In `src/cache-store.ts`: `CacheStore` interface (`get`, `set`), `CachedEntry = { rows: readonly Record[]; storedAt: number }`, `createInMemoryCacheStore({ maxEntries }: { maxEntries: number })` factory producing an LRU-with-TTL store. Injectable clock (`now: () => number`) for TTL testing. Export interface, factory, and `CachedEntry`. - [ ] **3.6 Implement `createCacheMiddleware`.** In `src/cache-middleware.ts`. Returns a cross-family `RuntimeMiddleware` (no `familyId`) with `intercept` / `onRow` / `afterExecute` wired. Private `WeakMap[] }>` keyed on the post-lowering plan object identity. Options: `{ store?: CacheStore; maxEntries?: number; clock?: () => number }`. Default `maxEntries`: 1000. The package depends only on `@prisma-next/framework-components/runtime` — no SQL or Mongo runtime dependency. -- [ ] **3.7 Resolve cache keys via `contentHash`.** Two-tier resolution: per-query `cacheAnnotation.apply({ key })` overrides everything; otherwise `ctx.contentHash(exec)` from the family runtime. The resolved string is an opaque, bounded SHA-512 digest (literal prefix `sha512:` plus 128 lowercase hex chars, 135 chars total) produced by the family runtime via `hashContent` (`@prisma-next/utils/hash-content`, which wraps `crypto.subtle.digest` and is async); the cache middleware awaits `ctx.contentHash(exec)` and uses the resolved string as the `Map` key as-is. The cache middleware itself never reads `exec.sql`, `exec.command`, or any other family-specific field. No `keyFn` option, no structural probe, no error path for non-SQL plans — Mongo and any future family work day one as long as their runtime populates `contentHash`. The cache package itself does not import `node:crypto`; hashing is the family runtime's responsibility, and the cache package depends only on `@prisma-next/framework-components/runtime`. User-supplied `cacheAnnotation.apply({ key })` is **not** rehashed — that's the user's responsibility (document in M3.20 README that user keys are stored as-is). +- [ ] **3.7 Resolve cache keys via `contentHash`.** Two-tier resolution: per-query `cacheAnnotation({ key })` overrides everything; otherwise `ctx.contentHash(exec)` from the family runtime. The resolved string is an opaque, bounded SHA-512 digest (literal prefix `sha512:` plus 128 lowercase hex chars, 135 chars total) produced by the family runtime via `hashContent` (`@prisma-next/utils/hash-content`, which wraps `crypto.subtle.digest` and is async); the cache middleware awaits `ctx.contentHash(exec)` and uses the resolved string as the `Map` key as-is. The cache middleware itself never reads `exec.sql`, `exec.command`, or any other family-specific field. No `keyFn` option, no structural probe, no error path for non-SQL plans — Mongo and any future family work day one as long as their runtime populates `contentHash`. The cache package itself does not import `node:crypto`; hashing is the family runtime's responsibility, and the cache package depends only on `@prisma-next/framework-components/runtime`. User-supplied `cacheAnnotation({ key })` is **not** rehashed — that's the user's responsibility (document in M3.20 README that user keys are stored as-is). - [ ] **3.8 ~~Mutation guard.~~** **Dropped.** Lane-level applicability gate (M2) makes a separate in-middleware mutation guard redundant. The cache middleware's `intercept` does not classify operation kind. @@ -170,8 +172,8 @@ Delivers `@prisma-next/middleware-cache`. Exit: integration test proves a repeat - [ ] **3.12 Unit tests — opt-in semantics.** In `middleware-cache/test/cache-middleware.test.ts`: - Un-annotated query: store never called. - - `cacheAnnotation.apply({ skip: true })`: store never called. - - `cacheAnnotation.apply({ })` (no `ttl`): store never called. + - `cacheAnnotation({ skip: true })`: store never called. + - `cacheAnnotation({ })` (no `ttl`): store never called. - Presence of annotation alone without `ttl` does not cache. - [ ] **3.13 Unit tests — store mechanics.** In `middleware-cache/test/cache-store.test.ts`: @@ -190,7 +192,7 @@ Delivers `@prisma-next/middleware-cache`. Exit: integration test proves a repeat - Default path: `ctx.contentHash(exec)` is invoked and its return value is used directly as the `Map` key (no further transformation in the cache middleware; the family runtime is responsible for hashing inside `contentHash`). - Different `storageHash` → different keys for otherwise-identical SQL (validates SQL `contentHash` impl from 1.0b end-to-end). - Different params → different keys. - - User-supplied `cacheAnnotation.apply({ key })` short-circuits `ctx.contentHash` (assert `contentHash` is not invoked when annotation `key` is supplied). User-supplied keys are stored as-is — no rehashing. + - User-supplied `cacheAnnotation({ key })` short-circuits `ctx.contentHash` (assert `contentHash` is not invoked when annotation `key` is supplied). User-supplied keys are stored as-is — no rehashing. - (Canonicalization stability across object-key order is covered by `canonicalStringify` tests in 1.0a; hash output format / opacity / bounded size are covered by `hashContent` tests in 1.0a'; not duplicated here.) - [ ] **3.16 Integration test — stop condition.** In `test/integration/test/cross-package/middleware-cache.test.ts`. Real Postgres (per `cross-family-middleware.test.ts` pattern) + ORM. Execute the same annotated ORM query twice. Assert: driver invocation count is 1 (mock-spy or driver-level counter); decoded rows equivalent on both calls; second call's `afterExecute` event sees `source: 'middleware'`. @@ -203,7 +205,7 @@ Delivers `@prisma-next/middleware-cache`. Exit: integration test proves a repeat - [ ] **3.19a Cross-family unit tests — key resolution and Mongo parity.** In `middleware-cache/test/cache-key.test.ts`: - Default path: `ctx.contentHash(exec)` is invoked; the returned string is used as the cache key. - - Per-query `cacheAnnotation.apply({ key })` overrides `ctx.contentHash` (assert `contentHash` is not invoked when annotation `key` is supplied). + - Per-query `cacheAnnotation({ key })` overrides `ctx.contentHash` (assert `contentHash` is not invoked when annotation `key` is supplied). - Mongo parity: with a mock `RuntimeMiddlewareContext` whose `contentHash` returns a Mongo-style string, the cache middleware works end-to-end against a non-SQL mock plan (no SQL fields present). Demonstrates the package is genuinely family-agnostic. - Two distinct `contentHash` returns produce two distinct cache entries. @@ -233,7 +235,8 @@ Delivers `@prisma-next/middleware-cache`. Exit: integration test proves a repeat | `hashContent` is deterministic and discriminating | Unit | 1.0a' | Trailing chars + separator placement | | `hashContent` output does not embed raw input (opacity) | Unit | 1.0a' | Sensitive-data isolation | | `OperationKind = 'read' \| 'write'` exported | Type test | 1.7 | Binary kind for April | -| `defineAnnotation({namespace, applicableTo})` signature | Type test | 1.7 | Generic over `Payload` + `Kinds` | +| `defineAnnotation

()({namespace, applicableTo})` signature | Type test | 1.7 | Curried; `Payload` explicit, `Kinds` inferred from `applicableTo` via `const` | +| `Kinds` inferred narrowly (not widened to `OperationKind`) | Type test | 1.8 | `applicableTo: ['read']` ⇒ `Kinds = 'read'`, not `'read' \| 'write'` | | `AnnotationHandle.applicableTo` is `ReadonlySet` | Unit | 1.7 | Frozen at construction | | `ValidAnnotations` resolves matching elements | Type test | 1.8 | Positive | | `ValidAnnotations` resolves mismatched elements to `never` | Type test | 1.8 | Negative both directions | @@ -276,7 +279,8 @@ Delivers `@prisma-next/middleware-cache`. Exit: integration test proves a repeat | Mixed chain: A passthrough, B intercepts → only B's intercept | Unit | 1.4 | A's `beforeExecute` not called (it was a hit) | | Intercepted rows go through codec decoding | Integration | 1.5 | Raw → decoded row assertion via SQL runtime | | Mongo runtime observes `intercept` via inherited helper | Integration | 1.6 | Cross-family proof extension | -| `defineAnnotation` preserves `P` across apply/read | Type test | 1.8 | Negative test for wrong payload | +| `defineAnnotation` preserves `P` across call signature and read | Type test | 1.8 | Negative test for wrong payload | +| Handle is callable; no `.apply` method on the handle | Unit + Type | 1.8 | `typeof handle === 'function'`; `handle({...})` is the sole construction path | | Different namespaces do not interfere | Unit | 1.8 | Two handles, disjoint reads | | `read` ignores cross-handle namespace match | Unit | 1.8 | Brand check | | SQL DSL `.annotate()` merges into `meta.annotations` (read + write) | Unit | 2.3 | All five builder kinds | diff --git a/projects/middleware-intercept-and-cache/spec.md b/projects/middleware-intercept-and-cache/spec.md index 1e3e352363..14a6d296f2 100644 --- a/projects/middleware-intercept-and-cache/spec.md +++ b/projects/middleware-intercept-and-cache/spec.md @@ -80,7 +80,7 @@ import { cacheAnnotation } from '@prisma-next/middleware-cache' // annotations; Insert/Update/DeleteQueryBuilder accepts write annotations. const plan = db.sql .from(tables.user) - .annotate(cacheAnnotation.apply({ ttl: 60 })) // ✓ select builder, read annotation + .annotate(cacheAnnotation({ ttl: 60 })) // ✓ select builder, read annotation .select({ id: tables.user.columns.id }) .build() @@ -88,12 +88,12 @@ const plan = db.sql // constrains the accepted annotations to the kinds applicable to it. const user = await db.orm.User.first( { id }, - cacheAnnotation.apply({ ttl: 60 }) // ✓ read annotation on read terminal + cacheAnnotation({ ttl: 60 }) // ✓ read annotation on read terminal ) const created = await db.orm.User.create( { email: 'a@b.com' }, - cacheAnnotation.apply({ ttl: 60 }) // ✗ type error: write terminal rejects read-only annotation + cacheAnnotation({ ttl: 60 }) // ✗ type error: write terminal rejects read-only annotation ) ``` @@ -110,13 +110,13 @@ const db = postgres({ // First call: hits the database, caches raw rows. const a = await db.orm.User - .annotate(cacheAnnotation.apply({ ttl: 60 })) + .annotate(cacheAnnotation({ ttl: 60 })) .where({ active: true }) .all() // Second call with identical plan: served from cache, driver not invoked. const b = await db.orm.User - .annotate(cacheAnnotation.apply({ ttl: 60 })) + .annotate(cacheAnnotation({ ttl: 60 })) .where({ active: true }) .all() @@ -154,7 +154,7 @@ const c = await db.orm.User.all() // always hits DB 1. **`OperationKind`.** `type OperationKind = 'read' | 'write'`. Exported from `@prisma-next/framework-components/runtime`. Read = `SELECT` / `find` / `first` / `all` / `count` / aggregates. Write = `INSERT` / `UPDATE` / `DELETE` / `create` / `update` / `delete` / `upsert`. Finer-grained kinds (`'select' | 'insert' | 'update' | 'delete' | 'upsert'`) are deferred; if an annotation appears that needs them, we widen. -2. **`defineAnnotation` helper.** Exported from `@prisma-next/framework-components/runtime`. Signature: `defineAnnotation({ namespace: string; applicableTo: readonly Kinds[] }): AnnotationHandle`. The returned handle exposes `namespace`, `applicableTo: ReadonlySet`, `apply(value)`, and `read(plan)`. Handles are the only supported public entry point for reading/writing annotations. +2. **`defineAnnotation` helper.** Exported from `@prisma-next/framework-components/runtime`. Two-step call form: `defineAnnotation()({ namespace: string; applicableTo: readonly Kinds[] }): AnnotationHandle`. The first step takes only `Payload` as an explicit type argument; the second step takes the runtime options and infers `Kinds` from the `applicableTo` array via a `const` type parameter, so the operation kinds appear once at the call site rather than being repeated in the type-argument list. (TypeScript does not support partial type-argument inference within a single call: a single-step `defineAnnotation` would still require both type arguments be passed explicitly because `Payload` cannot be inferred from anywhere; currying separates the explicit-from-inferred step. The cost is one extra `()` at definition; once defined, handles are used identically.) The returned handle is **callable**: invoking `handle(value)` produces an `AnnotationValue` ready to pass to a lane terminal's variadic `annotations` argument. The handle also exposes `namespace`, `applicableTo: ReadonlySet`, and `read(plan)` as properties on the function. Handles are the only supported public entry point for reading/writing annotations. (No `.apply(...)` method — calling the handle directly is the sole construction path; this keeps user-facing call sites compact: `cacheAnnotation({ ttl: 60 })` rather than `cacheAnnotation.apply({ ttl: 60 })`.) 3. **Applicability gate type.** `ValidAnnotations[]>` mapped tuple type that resolves each element of `As` to `never` when its declared `Kinds` does not include `K`. Lane terminals use this to constrain their variadic annotation argument. @@ -166,13 +166,13 @@ const c = await db.orm.User.all() // always hits DB 7. **Runtime applicability check at the lane.** Each lane terminal walks its variadic `annotations` array and rejects any whose `applicableTo` set does not include the terminal's operation kind, throwing a clear `runtimeError` (e.g. `RUNTIME.ANNOTATION_INAPPLICABLE`) naming the annotation namespace and the terminal. The runtime check is belt-and-suspenders: the type system fails closed for type-aware callers, and the runtime check fails closed for casts / `any` / dynamic invocations. -8. **Type safety.** `defineAnnotation` preserves `Payload` and `Kinds` across `apply` and `read`. Reading an absent annotation returns `undefined`. No `any` or unchecked casts in the public surface. +8. **Type safety.** `defineAnnotation` preserves `Payload` and `Kinds` across the handle's call signature and `read`. Reading an absent annotation returns `undefined`. No `any` or unchecked casts in the public surface. ### Cache middleware (`@prisma-next/middleware-cache`, new package) -1. **Opt-in by annotation.** `cacheAnnotation = defineAnnotation({ namespace: 'cache', applicableTo: ['read'] })`. Payload: `{ ttl?: number; skip?: boolean; key?: string }`. A query without `cacheAnnotation`, or with `skip: true`, or without a `ttl`, passes through untouched. Because `cacheAnnotation` declares `applicableTo: ['read']`, the lane gate (type-level + runtime) rejects passing it to a write terminal — there is no in-middleware mutation guard. +1. **Opt-in by annotation.** `cacheAnnotation = defineAnnotation()({ namespace: 'cache', applicableTo: ['read'] })` (`Kinds` inferred as `'read'`). Payload: `{ ttl?: number; skip?: boolean; key?: string }`. A query without `cacheAnnotation`, or with `skip: true`, or without a `ttl`, passes through untouched. Because `cacheAnnotation` declares `applicableTo: ['read']`, the lane gate (type-level + runtime) rejects passing it to a write terminal — there is no in-middleware mutation guard. -2. **Cache key resolution.** Two-tier priority: per-query `cacheAnnotation.apply({ key })` overrides everything; otherwise `ctx.contentHash(exec)` from the family runtime. The cache middleware itself never reads `exec.sql`, `exec.command`, or any other family-specific field — it depends only on `@prisma-next/framework-components/runtime`. +2. **Cache key resolution.** Two-tier priority: per-query `cacheAnnotation({ key })` overrides everything; otherwise `ctx.contentHash(exec)` from the family runtime. The cache middleware itself never reads `exec.sql`, `exec.command`, or any other family-specific field — it depends only on `@prisma-next/framework-components/runtime`. 3. **Cache hit path.** On lookup hit, `intercept` returns the cached raw rows. The SQL runtime decodes them through its normal codec pass (which wraps the orchestrator output). `afterExecute` observes `source: 'middleware'`. @@ -243,7 +243,7 @@ const c = await db.orm.User.all() // always hits DB ## Annotation surface - [ ] `OperationKind` exported from `@prisma-next/framework-components/runtime` as `'read' | 'write'` (type test). -- [ ] `defineAnnotation({ namespace, applicableTo })` exists in `@prisma-next/framework-components/runtime`, typed as described. +- [ ] `defineAnnotation()({ namespace, applicableTo })` exists in `@prisma-next/framework-components/runtime`, typed as described (curried; `Kinds` inferred from `applicableTo` via a `const` type parameter on the inner call). - [ ] `read` returns `Payload | undefined` with full type preservation (type-level test). - [ ] Handle exposes `applicableTo: ReadonlySet` for runtime checks (unit test). - [ ] Two handles with different namespaces do not interfere (unit test). @@ -252,9 +252,9 @@ const c = await db.orm.User.all() // always hits DB - [ ] SQL DSL `InsertQueryImpl.annotate(...)` accepts a write-only annotation; the same call against `SelectQueryImpl` fails to compile (type test). - [ ] SQL DSL `.annotate(...)` merges into `plan.meta.annotations[namespace]` at `.build()` time across all five builder kinds (unit test). - [ ] Multiple `.annotate()` calls compose; duplicate namespace = last-write-wins (unit test). -- [ ] ORM read-terminal call `db.User.first(where, cacheAnnotation.apply({ ttl: 60 }))` typechecks; `db.User.create(input, cacheAnnotation.apply({ ttl: 60 }))` does not (type test, both directions). +- [ ] ORM read-terminal call `db.User.first(where, cacheAnnotation({ ttl: 60 }))` typechecks; `db.User.create(input, cacheAnnotation({ ttl: 60 }))` does not (type test, both directions). - [ ] ORM write-only annotation accepted on `create` / `update` / `delete` / `upsert`; rejected on `first` / `find` / `all` / `count` (type test). -- [ ] Annotations applicable to both kinds (e.g. `defineAnnotation(...)`) accepted on every terminal (type test). +- [ ] Annotations applicable to both kinds (e.g. `defineAnnotation

()({ namespace, applicableTo: ['read', 'write'] })`) accepted on every terminal (type test). - [ ] An annotated ORM read produces a `SqlQueryPlan` whose `meta.annotations[namespace]` carries the payload (integration test). - [ ] An annotated SQL DSL query produces a `SqlQueryPlan` whose `meta.annotations[namespace]` carries the payload (integration test). - [ ] Lane runtime check rejects an annotation whose `applicableTo` set does not include the terminal's kind, with `RUNTIME.ANNOTATION_INAPPLICABLE` naming the annotation and terminal (unit test, exercised via cast bypass of the type gate). @@ -265,7 +265,7 @@ const c = await db.orm.User.all() // always hits DB - [ ] `@prisma-next/middleware-cache` package exists under `packages/3-extensions/`, following the layering conventions validated by `pnpm lint:deps`. - [ ] `cacheAnnotation` handle is exported; payload shape matches the spec. - [ ] `CacheStore` interface is exported; default in-memory LRU implementation is exported. -- [ ] `createCacheMiddleware(options)` returns a cross-family `RuntimeMiddleware` with `intercept` / `onRow` / `afterExecute` wired. The middleware reads cache keys from `ctx.contentHash(exec)` (or `cacheAnnotation.apply({ key })` when supplied per-query). The package depends only on `@prisma-next/framework-components/runtime` — no SQL or Mongo runtime dependency. +- [ ] `createCacheMiddleware(options)` returns a cross-family `RuntimeMiddleware` with `intercept` / `onRow` / `afterExecute` wired. The middleware reads cache keys from `ctx.contentHash(exec)` (or `cacheAnnotation({ key })` when supplied per-query). The package depends only on `@prisma-next/framework-components/runtime` — no SQL or Mongo runtime dependency. - [ ] Un-annotated queries are never cached (unit test). - [ ] Queries with `skip: true` are never cached (unit test). - [ ] Queries with no `ttl` are never cached (unit test). From c517d930b838f2351a6f79489850406f5ecfcad1 Mon Sep 17 00:00:00 2001 From: Alexey Orlenko Date: Wed, 6 May 2026 13:27:56 +0200 Subject: [PATCH 18/21] feat(framework-components): infer annotations' `applicableTo` type --- .../framework-components/src/annotations.ts | 80 +++++++++++-------- .../test/annotations.test.ts | 36 ++++----- .../test/annotations.types.test-d.ts | 6 +- .../test/playground/annotate.test-d.ts | 6 +- .../sql-builder/test/runtime/annotate.test.ts | 6 +- .../middleware-cache/src/cache-annotation.ts | 2 +- .../sql-orm-client/test/annotations.test.ts | 6 +- .../test/annotations.types.test-d.ts | 6 +- 8 files changed, 79 insertions(+), 69 deletions(-) diff --git a/packages/1-framework/1-core/framework-components/src/annotations.ts b/packages/1-framework/1-core/framework-components/src/annotations.ts index fda0afe7e3..ff501b80cd 100644 --- a/packages/1-framework/1-core/framework-components/src/annotations.ts +++ b/packages/1-framework/1-core/framework-components/src/annotations.ts @@ -92,26 +92,32 @@ export interface DefineAnnotationOptions { /** * Defines a typed annotation handle. * + * Two-step call form. The first step takes the `Payload` type argument + * (TypeScript cannot infer `Payload` from anything in the options, so it + * must be supplied explicitly); the second step takes the runtime options + * and infers `Kinds` from the `applicableTo` array via a `const` type + * parameter, so the operation kinds appear exactly once at the call site. + * * @example * ```typescript * // Read-only annotation. Lane terminals like `db.User.first(...)` accept * // it; `db.User.create(...)` rejects it at the type level. - * const cacheAnnotation = defineAnnotation<{ ttl?: number; skip?: boolean }, 'read'>({ + * const cacheAnnotation = defineAnnotation<{ ttl?: number; skip?: boolean }>()({ * namespace: 'cache', * applicableTo: ['read'], - * }); + * }); // Kinds inferred as 'read' * * // Write-only annotation. Mirror image. - * const auditAnnotation = defineAnnotation<{ actor: string }, 'write'>({ + * const auditAnnotation = defineAnnotation<{ actor: string }>()({ * namespace: 'audit', * applicableTo: ['write'], - * }); + * }); // Kinds inferred as 'write' * * // Annotation applicable to both kinds (e.g. tracing). - * const otelAnnotation = defineAnnotation<{ traceId: string }, 'read' | 'write'>({ + * const otelAnnotation = defineAnnotation<{ traceId: string }>()({ * namespace: 'otel', * applicableTo: ['read', 'write'], - * }); + * }); // Kinds inferred as 'read' | 'write' * ``` * * **Reserved namespaces.** See `DefineAnnotationOptions.namespace` for the @@ -120,41 +126,45 @@ export interface DefineAnnotationOptions { * reserved namespace, but the framework makes no compatibility guarantee * about handles that do. */ -export function defineAnnotation( +export function defineAnnotation(): ( options: DefineAnnotationOptions, -): AnnotationHandle { - const namespace = options.namespace; - const applicableTo: ReadonlySet = Object.freeze(new Set(options.applicableTo)); +) => AnnotationHandle { + return ( + options: DefineAnnotationOptions, + ): AnnotationHandle => { + const namespace = options.namespace; + const applicableTo: ReadonlySet = Object.freeze(new Set(options.applicableTo)); + + function apply(value: Payload): AnnotationValue { + return Object.freeze({ + __annotation: true as const, + namespace, + value, + applicableTo, + }); + } + + function read(plan: { + readonly meta: { readonly annotations?: Record }; + }): Payload | undefined { + const stored = plan.meta.annotations?.[namespace]; + if (!isAnnotationValue(stored)) { + return undefined; + } + if (stored.namespace !== namespace) { + // Defensive: a different handle wrote under our namespace key. + return undefined; + } + return stored.value as Payload; + } - function apply(value: Payload): AnnotationValue { return Object.freeze({ - __annotation: true as const, namespace, - value, applicableTo, + apply, + read, }); - } - - function read(plan: { - readonly meta: { readonly annotations?: Record }; - }): Payload | undefined { - const stored = plan.meta.annotations?.[namespace]; - if (!isAnnotationValue(stored)) { - return undefined; - } - if (stored.namespace !== namespace) { - // Defensive: a different handle wrote under our namespace key. - return undefined; - } - return stored.value as Payload; - } - - return Object.freeze({ - namespace, - applicableTo, - apply, - read, - }); + }; } /** diff --git a/packages/1-framework/1-core/framework-components/test/annotations.test.ts b/packages/1-framework/1-core/framework-components/test/annotations.test.ts index 3d793aa8ed..128a848298 100644 --- a/packages/1-framework/1-core/framework-components/test/annotations.test.ts +++ b/packages/1-framework/1-core/framework-components/test/annotations.test.ts @@ -25,7 +25,7 @@ function makePlan(annotations?: Record): { describe('defineAnnotation', () => { describe('handle metadata', () => { it('exposes the namespace it was created with', () => { - const handle = defineAnnotation<{ ttl: number }, 'read'>({ + const handle = defineAnnotation<{ ttl: number }>()({ namespace: 'cache', applicableTo: ['read'], }); @@ -33,7 +33,7 @@ describe('defineAnnotation', () => { }); it('exposes a frozen ReadonlySet for applicableTo', () => { - const handle = defineAnnotation<{ ttl: number }, 'read' | 'write'>({ + const handle = defineAnnotation<{ ttl: number }>()({ namespace: 'otel', applicableTo: ['read', 'write'], }); @@ -43,11 +43,11 @@ describe('defineAnnotation', () => { }); it('handles do not share state across separate defineAnnotation calls', () => { - const a = defineAnnotation<{ x: number }, 'read'>({ + const a = defineAnnotation<{ x: number }>()({ namespace: 'a', applicableTo: ['read'], }); - const b = defineAnnotation<{ y: string }, 'write'>({ + const b = defineAnnotation<{ y: string }>()({ namespace: 'b', applicableTo: ['write'], }); @@ -61,7 +61,7 @@ describe('defineAnnotation', () => { describe('apply', () => { it('produces an AnnotationValue carrying the __annotation brand', () => { - const handle = defineAnnotation<{ ttl: number }, 'read'>({ + const handle = defineAnnotation<{ ttl: number }>()({ namespace: 'cache', applicableTo: ['read'], }); @@ -70,7 +70,7 @@ describe('defineAnnotation', () => { }); it('embeds the namespace, payload, and applicableTo set on the value', () => { - const handle = defineAnnotation<{ ttl: number }, 'read'>({ + const handle = defineAnnotation<{ ttl: number }>()({ namespace: 'cache', applicableTo: ['read'], }); @@ -81,7 +81,7 @@ describe('defineAnnotation', () => { }); it('produces a frozen value', () => { - const handle = defineAnnotation<{ ttl: number }, 'read'>({ + const handle = defineAnnotation<{ ttl: number }>()({ namespace: 'cache', applicableTo: ['read'], }); @@ -90,7 +90,7 @@ describe('defineAnnotation', () => { }); it('produces independent values across repeated apply calls', () => { - const handle = defineAnnotation<{ ttl: number }, 'read'>({ + const handle = defineAnnotation<{ ttl: number }>()({ namespace: 'cache', applicableTo: ['read'], }); @@ -103,7 +103,7 @@ describe('defineAnnotation', () => { describe('read', () => { it('returns the payload when a value applied through the same handle is stored', () => { - const handle = defineAnnotation<{ ttl: number }, 'read'>({ + const handle = defineAnnotation<{ ttl: number }>()({ namespace: 'cache', applicableTo: ['read'], }); @@ -113,7 +113,7 @@ describe('defineAnnotation', () => { }); it('returns undefined when the annotation is absent', () => { - const handle = defineAnnotation<{ ttl: number }, 'read'>({ + const handle = defineAnnotation<{ ttl: number }>()({ namespace: 'cache', applicableTo: ['read'], }); @@ -123,7 +123,7 @@ describe('defineAnnotation', () => { }); it('returns undefined when the stored value is not a branded AnnotationValue', () => { - const handle = defineAnnotation<{ ttl: number }, 'read'>({ + const handle = defineAnnotation<{ ttl: number }>()({ namespace: 'cache', applicableTo: ['read'], }); @@ -138,11 +138,11 @@ describe('defineAnnotation', () => { }); it('two handles with different namespaces do not interfere', () => { - const cache = defineAnnotation<{ ttl: number }, 'read'>({ + const cache = defineAnnotation<{ ttl: number }>()({ namespace: 'cache', applicableTo: ['read'], }); - const audit = defineAnnotation<{ actor: string }, 'write'>({ + const audit = defineAnnotation<{ actor: string }>()({ namespace: 'audit', applicableTo: ['write'], }); @@ -160,7 +160,7 @@ describe('defineAnnotation', () => { // read() compares the stored value's `namespace` field to the // handle's, so a value applied through one handle does not surface // through the other. - const a = defineAnnotation<{ kind: 'a' }, 'read'>({ + const a = defineAnnotation<{ kind: 'a' }>()({ namespace: 'shared', applicableTo: ['read'], }); @@ -183,7 +183,7 @@ describe('defineAnnotation', () => { }); it('preserves Payload identity (handle.read returns the same object reference stored)', () => { - const handle = defineAnnotation<{ tags: string[] }, 'read'>({ + const handle = defineAnnotation<{ tags: string[] }>()({ namespace: 'tags', applicableTo: ['read'], }); @@ -198,15 +198,15 @@ describe('defineAnnotation', () => { }); describe('assertAnnotationsApplicable', () => { - const cache = defineAnnotation<{ ttl: number }, 'read'>({ + const cache = defineAnnotation<{ ttl: number }>()({ namespace: 'cache', applicableTo: ['read'], }); - const audit = defineAnnotation<{ actor: string }, 'write'>({ + const audit = defineAnnotation<{ actor: string }>()({ namespace: 'audit', applicableTo: ['write'], }); - const otel = defineAnnotation<{ traceId: string }, 'read' | 'write'>({ + const otel = defineAnnotation<{ traceId: string }>()({ namespace: 'otel', applicableTo: ['read', 'write'], }); diff --git a/packages/1-framework/1-core/framework-components/test/annotations.types.test-d.ts b/packages/1-framework/1-core/framework-components/test/annotations.types.test-d.ts index 11d0c30a8c..8fde621365 100644 --- a/packages/1-framework/1-core/framework-components/test/annotations.types.test-d.ts +++ b/packages/1-framework/1-core/framework-components/test/annotations.types.test-d.ts @@ -22,17 +22,17 @@ import { * accepted everywhere — work as expected. */ -const readOnly = defineAnnotation<{ ttl: number }, 'read'>({ +const readOnly = defineAnnotation<{ ttl: number }>()({ namespace: 'cache', applicableTo: ['read'], }); -const writeOnly = defineAnnotation<{ actor: string }, 'write'>({ +const writeOnly = defineAnnotation<{ actor: string }>()({ namespace: 'audit', applicableTo: ['write'], }); -const both = defineAnnotation<{ traceId: string }, 'read' | 'write'>({ +const both = defineAnnotation<{ traceId: string }>()({ namespace: 'otel', applicableTo: ['read', 'write'], }); diff --git a/packages/2-sql/4-lanes/sql-builder/test/playground/annotate.test-d.ts b/packages/2-sql/4-lanes/sql-builder/test/playground/annotate.test-d.ts index c02ce5f60e..17170757d2 100644 --- a/packages/2-sql/4-lanes/sql-builder/test/playground/annotate.test-d.ts +++ b/packages/2-sql/4-lanes/sql-builder/test/playground/annotate.test-d.ts @@ -17,17 +17,17 @@ import { db } from './preamble'; * builder methods. */ -const cacheAnnotation = defineAnnotation<{ ttl: number; skip?: boolean }, 'read'>({ +const cacheAnnotation = defineAnnotation<{ ttl: number; skip?: boolean }>()({ namespace: 'cache', applicableTo: ['read'], }); -const auditAnnotation = defineAnnotation<{ actor: string }, 'write'>({ +const auditAnnotation = defineAnnotation<{ actor: string }>()({ namespace: 'audit', applicableTo: ['write'], }); -const otelAnnotation = defineAnnotation<{ traceId: string }, 'read' | 'write'>({ +const otelAnnotation = defineAnnotation<{ traceId: string }>()({ namespace: 'otel', applicableTo: ['read', 'write'], }); diff --git a/packages/2-sql/4-lanes/sql-builder/test/runtime/annotate.test.ts b/packages/2-sql/4-lanes/sql-builder/test/runtime/annotate.test.ts index ce2ac6e1c5..506fd96fbf 100644 --- a/packages/2-sql/4-lanes/sql-builder/test/runtime/annotate.test.ts +++ b/packages/2-sql/4-lanes/sql-builder/test/runtime/annotate.test.ts @@ -25,17 +25,17 @@ function db() { }); } -const cacheAnnotation = defineAnnotation<{ ttl: number; skip?: boolean }, 'read'>({ +const cacheAnnotation = defineAnnotation<{ ttl: number; skip?: boolean }>()({ namespace: 'cache', applicableTo: ['read'], }); -const otelAnnotation = defineAnnotation<{ traceId: string }, 'read' | 'write'>({ +const otelAnnotation = defineAnnotation<{ traceId: string }>()({ namespace: 'otel', applicableTo: ['read', 'write'], }); -const auditAnnotation = defineAnnotation<{ actor: string }, 'write'>({ +const auditAnnotation = defineAnnotation<{ actor: string }>()({ namespace: 'audit', applicableTo: ['write'], }); diff --git a/packages/3-extensions/middleware-cache/src/cache-annotation.ts b/packages/3-extensions/middleware-cache/src/cache-annotation.ts index b5367d1fea..697cdfc774 100644 --- a/packages/3-extensions/middleware-cache/src/cache-annotation.ts +++ b/packages/3-extensions/middleware-cache/src/cache-annotation.ts @@ -55,7 +55,7 @@ export interface CachePayload { * .build(); * ``` */ -export const cacheAnnotation = defineAnnotation({ +export const cacheAnnotation = defineAnnotation()({ namespace: 'cache', applicableTo: ['read'], }); diff --git a/packages/3-extensions/sql-orm-client/test/annotations.test.ts b/packages/3-extensions/sql-orm-client/test/annotations.test.ts index 6df82ac1df..2d021a6475 100644 --- a/packages/3-extensions/sql-orm-client/test/annotations.test.ts +++ b/packages/3-extensions/sql-orm-client/test/annotations.test.ts @@ -6,17 +6,17 @@ import { createReturningCollectionFor, } from './collection-fixtures'; -const cacheAnnotation = defineAnnotation<{ ttl: number; skip?: boolean }, 'read'>({ +const cacheAnnotation = defineAnnotation<{ ttl: number; skip?: boolean }>()({ namespace: 'cache', applicableTo: ['read'], }); -const otelAnnotation = defineAnnotation<{ traceId: string }, 'read' | 'write'>({ +const otelAnnotation = defineAnnotation<{ traceId: string }>()({ namespace: 'otel', applicableTo: ['read', 'write'], }); -const auditAnnotation = defineAnnotation<{ actor: string }, 'write'>({ +const auditAnnotation = defineAnnotation<{ actor: string }>()({ namespace: 'audit', applicableTo: ['write'], }); diff --git a/packages/3-extensions/sql-orm-client/test/annotations.types.test-d.ts b/packages/3-extensions/sql-orm-client/test/annotations.types.test-d.ts index b6edaccb14..8a7d140419 100644 --- a/packages/3-extensions/sql-orm-client/test/annotations.types.test-d.ts +++ b/packages/3-extensions/sql-orm-client/test/annotations.types.test-d.ts @@ -23,17 +23,17 @@ import type { TestContract } from './helpers'; declare const userCollection: Collection; -const cacheAnnotation = defineAnnotation<{ ttl: number; skip?: boolean }, 'read'>({ +const cacheAnnotation = defineAnnotation<{ ttl: number; skip?: boolean }>()({ namespace: 'cache', applicableTo: ['read'], }); -const auditAnnotation = defineAnnotation<{ actor: string }, 'write'>({ +const auditAnnotation = defineAnnotation<{ actor: string }>()({ namespace: 'audit', applicableTo: ['write'], }); -const otelAnnotation = defineAnnotation<{ traceId: string }, 'read' | 'write'>({ +const otelAnnotation = defineAnnotation<{ traceId: string }>()({ namespace: 'otel', applicableTo: ['read', 'write'], }); From 1a574d97f4b20185d313603be80fc78aa3011fcb Mon Sep 17 00:00:00 2001 From: Alexey Orlenko Date: Wed, 6 May 2026 13:56:45 +0200 Subject: [PATCH 19/21] refactor(framework-components): drop `apply` from annotation handles --- examples/prisma-next-demo/README.md | 10 +- examples/prisma-next-demo/src/main.ts | 4 +- .../src/orm-client/find-user-by-id-cached.ts | 2 +- .../src/orm-client/get-users-cached.ts | 4 +- .../src/queries/get-users-cached.ts | 4 +- .../test/repositories.integration.test.ts | 2 +- .../framework-components/src/annotations.ts | 64 +++++++--- .../test/annotations.test.ts | 50 ++++---- .../test/annotations.types.test-d.ts | 36 +++--- .../test/playground/annotate.test-d.ts | 50 ++++---- .../sql-builder/test/runtime/annotate.test.ts | 58 +++++----- .../3-extensions/middleware-cache/README.md | 24 ++-- .../middleware-cache/src/cache-annotation.ts | 6 +- .../middleware-cache/src/cache-middleware.ts | 4 +- .../test/cache-annotation.test.ts | 10 +- .../test/cache-annotation.types.test-d.ts | 18 +-- .../middleware-cache/test/cache-key.test.ts | 22 ++-- .../test/cache-middleware.test.ts | 38 +++--- .../sql-orm-client/test/annotations.test.ts | 77 ++++++------- .../test/annotations.types.test-d.ts | 109 ++++++++---------- .../cross-package/middleware-cache.test.ts | 22 ++-- 21 files changed, 309 insertions(+), 305 deletions(-) diff --git a/examples/prisma-next-demo/README.md b/examples/prisma-next-demo/README.md index c5d0d1c03d..ae1c1ab812 100644 --- a/examples/prisma-next-demo/README.md +++ b/examples/prisma-next-demo/README.md @@ -106,7 +106,7 @@ The demo includes ORM client examples under `src/orm-client/`: - `ormClientGetUserInsights(limit, runtime)` — `include().combine()` metrics and latest related row - `ormClientGetUserKindBreakdown(minUsers, runtime)` — `groupBy().having().aggregate()` breakdown - `ormClientUpsertUser(data, runtime)` — `upsert()` for create-or-update by primary key -- `ormClientFindUserByIdCached(id, runtime, options?)` — opt-in cached `first({ id })` lookup via `cacheAnnotation.apply({ ttl })` from `@prisma-next/middleware-cache` +- `ormClientFindUserByIdCached(id, runtime, options?)` — opt-in cached `first({ id })` lookup via `cacheAnnotation({ ttl })` from `@prisma-next/middleware-cache` - `ormClientGetUsersCached(limit, runtime, options?)` — opt-in cached `User.all()` listing, with optional explicit cache-key override Run from the CLI: @@ -136,7 +136,7 @@ pnpm start -- cache-demo-user 00000000-0000-0000-0000-000000000001 # ORM client User.all() listing cached for 60s. pnpm start -- cache-demo-users 5 -# SQL DSL .annotate(cacheAnnotation.apply({ ttl })) on a select. +# SQL DSL .annotate(cacheAnnotation({ ttl })) on a select. pnpm start -- cache-demo-sql 5 ``` @@ -153,9 +153,9 @@ Speedup: 26.2x faster The corresponding source files: -- `src/orm-client/find-user-by-id-cached.ts` — `db.User.first({ id }, cacheAnnotation.apply({ ttl }))` -- `src/orm-client/get-users-cached.ts` — `db.User.take(n).all(cacheAnnotation.apply({ ttl, key? }))` -- `src/queries/get-users-cached.ts` — `db.sql.user.select(...).annotate(cacheAnnotation.apply({ ttl })).build()` +- `src/orm-client/find-user-by-id-cached.ts` — `db.User.first({ id }, cacheAnnotation({ ttl }))` +- `src/orm-client/get-users-cached.ts` — `db.User.take(n).all(cacheAnnotation({ ttl, key? }))` +- `src/queries/get-users-cached.ts` — `db.sql.user.select(...).annotate(cacheAnnotation({ ttl })).build()` Relevant points: diff --git a/examples/prisma-next-demo/src/main.ts b/examples/prisma-next-demo/src/main.ts index 213f410360..57248bddaf 100644 --- a/examples/prisma-next-demo/src/main.ts +++ b/examples/prisma-next-demo/src/main.ts @@ -391,9 +391,7 @@ async function main() { void first; } else if (cmd === 'cache-demo-sql') { const limit = args[0] ? Number.parseInt(args[0], 10) : 10; - console.log( - 'Demonstrating opt-in caching via SQL DSL .annotate(cacheAnnotation.apply(...))...', - ); + console.log('Demonstrating opt-in caching via SQL DSL .annotate(cacheAnnotation(...))...'); console.log('Running the same select twice — second call should hit cache.\n'); const firstStart = performance.now(); diff --git a/examples/prisma-next-demo/src/orm-client/find-user-by-id-cached.ts b/examples/prisma-next-demo/src/orm-client/find-user-by-id-cached.ts index 8fbac31eab..04293d7a16 100644 --- a/examples/prisma-next-demo/src/orm-client/find-user-by-id-cached.ts +++ b/examples/prisma-next-demo/src/orm-client/find-user-by-id-cached.ts @@ -61,7 +61,7 @@ export async function ormClientFindUserByIdCached( const ttl = options.ttlMs ?? 60_000; return db.User.first( { id: toUserId(id) }, - cacheAnnotation.apply({ ttl, skip: options.forceRefresh ?? false }), + cacheAnnotation({ ttl, skip: options.forceRefresh ?? false }), ); } diff --git a/examples/prisma-next-demo/src/orm-client/get-users-cached.ts b/examples/prisma-next-demo/src/orm-client/get-users-cached.ts index 2e9349599a..367d9788ab 100644 --- a/examples/prisma-next-demo/src/orm-client/get-users-cached.ts +++ b/examples/prisma-next-demo/src/orm-client/get-users-cached.ts @@ -4,7 +4,7 @@ * Companion to `find-user-by-id-cached.ts` — same opt-in caching * mechanism, this time on a multi-row read terminal. The trailing * variadic of `.all(...)` accepts read-typed annotations; we pass - * `cacheAnnotation.apply({ ttl })` to enable caching of the + * `cacheAnnotation({ ttl })` to enable caching of the * post-lowering execution. * * The example also shows the per-query `key` override. When set, the @@ -39,6 +39,6 @@ export async function ormClientGetUsersCached( const db = createOrmClient(runtime); const ttl = options.ttlMs ?? 60_000; return db.User.take(limit).all( - cacheAnnotation.apply(options.key !== undefined ? { ttl, key: options.key } : { ttl }), + cacheAnnotation(options.key !== undefined ? { ttl, key: options.key } : { ttl }), ); } diff --git a/examples/prisma-next-demo/src/queries/get-users-cached.ts b/examples/prisma-next-demo/src/queries/get-users-cached.ts index 9d287847f0..d30c3efc3f 100644 --- a/examples/prisma-next-demo/src/queries/get-users-cached.ts +++ b/examples/prisma-next-demo/src/queries/get-users-cached.ts @@ -1,7 +1,7 @@ /** * Cached SQL DSL `select` example. * - * Mirrors `get-users.ts` but adds `.annotate(cacheAnnotation.apply(...))` + * Mirrors `get-users.ts` but adds `.annotate(cacheAnnotation(...))` * to opt the plan into the cache middleware registered on the runtime * in `src/prisma/db.ts`. The annotation is a read-only handle, so the * type system rejects passing it to write builders (`insert`, @@ -20,7 +20,7 @@ import { db } from '../prisma/db'; export async function getUsersCached(limit = 10, ttlMs = 60_000) { const plan = db.sql.user .select('id', 'email', 'createdAt', 'kind') - .annotate(cacheAnnotation.apply({ ttl: ttlMs })) + .annotate(cacheAnnotation({ ttl: ttlMs })) .limit(limit) .build(); return db.runtime().execute(plan); diff --git a/examples/prisma-next-demo/test/repositories.integration.test.ts b/examples/prisma-next-demo/test/repositories.integration.test.ts index e9a119f1a9..39164db9d1 100644 --- a/examples/prisma-next-demo/test/repositories.integration.test.ts +++ b/examples/prisma-next-demo/test/repositories.integration.test.ts @@ -810,7 +810,7 @@ describe('ORM client integration examples', () => { // // The cache helpers under `src/orm-client/find-user-by-id-cached.ts` and // `src/orm-client/get-users-cached.ts` opt their reads into the - // `@prisma-next/middleware-cache` middleware via `cacheAnnotation.apply(...)`. + // `@prisma-next/middleware-cache` middleware via `cacheAnnotation(...)`. // The middleware short-circuits repeated executions of the same plan via // its `intercept` hook, so a cache hit means the SQL driver is *not* // invoked again. We assert that contract by spying on `driver.execute`. diff --git a/packages/1-framework/1-core/framework-components/src/annotations.ts b/packages/1-framework/1-core/framework-components/src/annotations.ts index ff501b80cd..492a88b954 100644 --- a/packages/1-framework/1-core/framework-components/src/annotations.ts +++ b/packages/1-framework/1-core/framework-components/src/annotations.ts @@ -27,8 +27,8 @@ export type OperationKind = 'read' | 'write'; * `plan.meta.annotations` (e.g. framework-internal metadata such as * `meta.annotations.codecs`). * - * Constructed via `AnnotationHandle.apply(...)`; never instantiated - * directly. + * Constructed by calling an `AnnotationHandle` directly (e.g. + * `cacheAnnotation({ ttl: 60 })`); never instantiated by hand. */ export interface AnnotationValue { readonly __annotation: true; @@ -38,11 +38,15 @@ export interface AnnotationValue { } /** - * Handle returned by `defineAnnotation`. Carries the static metadata - * (namespace, applicableTo) and the two operations a handle exposes: + * Handle returned by `defineAnnotation`. The handle is **callable**: the + * call signature wraps a `Payload` into an `AnnotationValue` ready to + * pass to a lane terminal's variadic `annotations` argument. The handle + * also carries static metadata as own properties: * - * - `apply(value)` — wrap a `Payload` into an `AnnotationValue` ready to - * pass to a lane terminal's variadic `annotations` argument. + * - `namespace` — the namespace string the handle was declared with. + * - `applicableTo` — the frozen `ReadonlySet` consumed by both + * the type-level `ValidAnnotations` gate and the runtime + * `assertAnnotationsApplicable` gate. * - `read(plan)` — extract the `Payload` from a plan's `meta.annotations` * if a value was previously written under this handle's namespace. * Returns `undefined` when the annotation is absent or when the stored @@ -52,11 +56,32 @@ export interface AnnotationValue { * Handles are the only supported public entry point for reading and * writing annotations. Direct mutation of `plan.meta.annotations` is not * part of the public API. + * + * ```typescript + * const cacheAnnotation = defineAnnotation<{ ttl: number }>()({ + * namespace: 'cache', + * applicableTo: ['read'], + * }); + * + * // Call the handle to construct a value: + * const applied = cacheAnnotation({ ttl: 60 }); + * + * // Read a stored value off a plan: + * const payload = cacheAnnotation.read(plan); + * ``` + * + * Note on the inherited `Function.prototype.apply`: because the handle is + * a function, the property name `apply` resolves to JavaScript's built-in + * `Function.prototype.apply` (which lets you invoke a function with an + * array of arguments). This is **not** the construction entry point — to + * build an `AnnotationValue`, call the handle directly. The + * `AnnotationHandle` interface deliberately does not declare an `apply` + * member of its own. */ export interface AnnotationHandle { + (value: Payload): AnnotationValue; readonly namespace: string; readonly applicableTo: ReadonlySet; - apply(value: Payload): AnnotationValue; read(plan: { readonly meta: { readonly annotations?: Record }; }): Payload | undefined; @@ -135,7 +160,7 @@ export function defineAnnotation(): const namespace = options.namespace; const applicableTo: ReadonlySet = Object.freeze(new Set(options.applicableTo)); - function apply(value: Payload): AnnotationValue { + function handle(value: Payload): AnnotationValue { return Object.freeze({ __annotation: true as const, namespace, @@ -158,12 +183,13 @@ export function defineAnnotation(): return stored.value as Payload; } - return Object.freeze({ - namespace, - applicableTo, - apply, - read, - }); + return Object.freeze( + Object.assign(handle, { + namespace, + applicableTo, + read, + }), + ); }; } @@ -195,10 +221,10 @@ export function defineAnnotation(): * ): Promise; * } * - * db.User.first({ id }, cacheAnnotation.apply({ ttl: 60 })); + * db.User.first({ id }, cacheAnnotation({ ttl: 60 })); * // ✓ cacheAnnotation declares 'read'; first() requires 'read'. * - * db.User.create(input, cacheAnnotation.apply({ ttl: 60 })); + * db.User.create(input, cacheAnnotation({ ttl: 60 })); * // ✗ cacheAnnotation declares 'read'; create() requires 'write'. * // Element resolves to `never` → tuple unassignable → type error. * ``` @@ -280,9 +306,9 @@ export function assertAnnotationsApplicable( * Type guard for branded annotation values stored in `plan.meta.annotations`. * * Internal — used by `AnnotationHandle.read` to distinguish user - * annotations (created via `defineAnnotation(...).apply(...)`) from - * framework-internal metadata that may happen to live under the same - * namespace key. + * annotations (created by calling a handle returned from + * `defineAnnotation(...)`) from framework-internal metadata that may + * happen to live under the same namespace key. */ function isAnnotationValue(value: unknown): value is AnnotationValue { if (value === null || typeof value !== 'object') { diff --git a/packages/1-framework/1-core/framework-components/test/annotations.test.ts b/packages/1-framework/1-core/framework-components/test/annotations.test.ts index 128a848298..94cd7cfac1 100644 --- a/packages/1-framework/1-core/framework-components/test/annotations.test.ts +++ b/packages/1-framework/1-core/framework-components/test/annotations.test.ts @@ -59,13 +59,13 @@ describe('defineAnnotation', () => { }); }); - describe('apply', () => { + describe('value construction (calling the handle)', () => { it('produces an AnnotationValue carrying the __annotation brand', () => { const handle = defineAnnotation<{ ttl: number }>()({ namespace: 'cache', applicableTo: ['read'], }); - const applied = handle.apply({ ttl: 60 }); + const applied = handle({ ttl: 60 }); expect(applied.__annotation).toBe(true); }); @@ -74,7 +74,7 @@ describe('defineAnnotation', () => { namespace: 'cache', applicableTo: ['read'], }); - const applied = handle.apply({ ttl: 60 }); + const applied = handle({ ttl: 60 }); expect(applied.namespace).toBe('cache'); expect(applied.value).toEqual({ ttl: 60 }); expect(applied.applicableTo.has('read')).toBe(true); @@ -85,17 +85,17 @@ describe('defineAnnotation', () => { namespace: 'cache', applicableTo: ['read'], }); - const applied = handle.apply({ ttl: 60 }); + const applied = handle({ ttl: 60 }); expect(Object.isFrozen(applied)).toBe(true); }); - it('produces independent values across repeated apply calls', () => { + it('produces independent values across repeated calls', () => { const handle = defineAnnotation<{ ttl: number }>()({ namespace: 'cache', applicableTo: ['read'], }); - const a = handle.apply({ ttl: 60 }); - const b = handle.apply({ ttl: 120 }); + const a = handle({ ttl: 60 }); + const b = handle({ ttl: 120 }); expect(a.value).toEqual({ ttl: 60 }); expect(b.value).toEqual({ ttl: 120 }); }); @@ -107,7 +107,7 @@ describe('defineAnnotation', () => { namespace: 'cache', applicableTo: ['read'], }); - const applied = handle.apply({ ttl: 60 }); + const applied = handle({ ttl: 60 }); const plan = makePlan({ cache: applied }); expect(handle.read(plan)).toEqual({ ttl: 60 }); }); @@ -147,8 +147,8 @@ describe('defineAnnotation', () => { applicableTo: ['write'], }); const plan = makePlan({ - cache: cache.apply({ ttl: 60 }), - audit: audit.apply({ actor: 'system' }), + cache: cache({ ttl: 60 }), + audit: audit({ actor: 'system' }), }); expect(cache.read(plan)).toEqual({ ttl: 60 }); @@ -188,7 +188,7 @@ describe('defineAnnotation', () => { applicableTo: ['read'], }); const payload = { tags: ['admin', 'staff'] }; - const applied = handle.apply(payload); + const applied = handle(payload); const plan = makePlan({ tags: applied }); const out = handle.read(plan); @@ -219,38 +219,32 @@ describe('assertAnnotationsApplicable', () => { it('when every annotation applies to the kind', () => { expect(() => - assertAnnotationsApplicable([cache.apply({ ttl: 60 })], 'read', 'first'), + assertAnnotationsApplicable([cache({ ttl: 60 })], 'read', 'first'), ).not.toThrow(); expect(() => - assertAnnotationsApplicable([audit.apply({ actor: 'a' })], 'write', 'create'), + assertAnnotationsApplicable([audit({ actor: 'a' })], 'write', 'create'), ).not.toThrow(); }); it('when an annotation declares both kinds and is used on either', () => { expect(() => - assertAnnotationsApplicable([otel.apply({ traceId: 't' })], 'read', 'first'), + assertAnnotationsApplicable([otel({ traceId: 't' })], 'read', 'first'), ).not.toThrow(); expect(() => - assertAnnotationsApplicable([otel.apply({ traceId: 't' })], 'write', 'create'), + assertAnnotationsApplicable([otel({ traceId: 't' })], 'write', 'create'), ).not.toThrow(); }); it('when multiple compatible annotations are passed together', () => { expect(() => - assertAnnotationsApplicable( - [cache.apply({ ttl: 60 }), otel.apply({ traceId: 't' })], - 'read', - 'first', - ), + assertAnnotationsApplicable([cache({ ttl: 60 }), otel({ traceId: 't' })], 'read', 'first'), ).not.toThrow(); }); }); describe('throws RUNTIME.ANNOTATION_INAPPLICABLE', () => { it('on a read-only annotation passed to a write terminal', () => { - expect(() => - assertAnnotationsApplicable([cache.apply({ ttl: 60 })], 'write', 'create'), - ).toThrow( + expect(() => assertAnnotationsApplicable([cache({ ttl: 60 })], 'write', 'create')).toThrow( expect.objectContaining({ code: 'RUNTIME.ANNOTATION_INAPPLICABLE', category: 'RUNTIME', @@ -260,7 +254,7 @@ describe('assertAnnotationsApplicable', () => { it('on a write-only annotation passed to a read terminal', () => { expect(() => - assertAnnotationsApplicable([audit.apply({ actor: 'system' })], 'read', 'first'), + assertAnnotationsApplicable([audit({ actor: 'system' })], 'read', 'first'), ).toThrow( expect.objectContaining({ code: 'RUNTIME.ANNOTATION_INAPPLICABLE', @@ -272,7 +266,7 @@ describe('assertAnnotationsApplicable', () => { it('on the first inapplicable annotation when several are passed', () => { expect(() => assertAnnotationsApplicable( - [otel.apply({ traceId: 't' }), audit.apply({ actor: 'system' })], + [otel({ traceId: 't' }), audit({ actor: 'system' })], 'read', 'first', ), @@ -285,7 +279,7 @@ describe('assertAnnotationsApplicable', () => { it('with a message naming the offending annotation namespace and the terminal', () => { try { - assertAnnotationsApplicable([cache.apply({ ttl: 60 })], 'write', 'create'); + assertAnnotationsApplicable([cache({ ttl: 60 })], 'write', 'create'); expect.fail('expected assertAnnotationsApplicable to throw'); } catch (error) { expect(error).toBeInstanceOf(Error); @@ -298,7 +292,7 @@ describe('assertAnnotationsApplicable', () => { it('with structured details including namespace, terminalName, kind, and applicableTo', () => { try { - assertAnnotationsApplicable([cache.apply({ ttl: 60 })], 'write', 'create'); + assertAnnotationsApplicable([cache({ ttl: 60 })], 'write', 'create'); expect.fail('expected assertAnnotationsApplicable to throw'); } catch (error) { const envelope = error as Error & { details?: Record }; @@ -319,7 +313,7 @@ describe('assertAnnotationsApplicable', () => { // belt-and-suspenders that catches casts / `any` / dynamic // invocations. it('rejects an opaquely-typed inapplicable annotation forced through a cast', () => { - const sneakyWriteAnnotation = audit.apply({ actor: 'system' }); + const sneakyWriteAnnotation = audit({ actor: 'system' }); // Imagine a caller bypassed the type gate via `as any` and handed // the runtime an annotation whose kinds do not match the terminal. const annotations: readonly AnnotationValue[] = [ diff --git a/packages/1-framework/1-core/framework-components/test/annotations.types.test-d.ts b/packages/1-framework/1-core/framework-components/test/annotations.types.test-d.ts index 8fde621365..83fd89d3d2 100644 --- a/packages/1-framework/1-core/framework-components/test/annotations.types.test-d.ts +++ b/packages/1-framework/1-core/framework-components/test/annotations.types.test-d.ts @@ -54,23 +54,23 @@ describe('defineAnnotation generics', () => { expectTypeOf(both.applicableTo).toEqualTypeOf>(); }); - test('apply preserves Payload and Kinds in the AnnotationValue', () => { - const r = readOnly.apply({ ttl: 60 }); - const w = writeOnly.apply({ actor: 'system' }); - const x = both.apply({ traceId: 't' }); + test('calling the handle preserves Payload and Kinds in the AnnotationValue', () => { + const r = readOnly({ ttl: 60 }); + const w = writeOnly({ actor: 'system' }); + const x = both({ traceId: 't' }); expectTypeOf(r).toEqualTypeOf>(); expectTypeOf(w).toEqualTypeOf>(); expectTypeOf(x).toEqualTypeOf>(); }); - test('apply rejects payloads of the wrong shape (negative)', () => { + test('handle call rejects payloads of the wrong shape (negative)', () => { // @ts-expect-error - missing required `ttl` field - readOnly.apply({}); + readOnly({}); // @ts-expect-error - wrong field name - readOnly.apply({ wrong: 60 }); + readOnly({ wrong: 60 }); // @ts-expect-error - wrong field type - readOnly.apply({ ttl: 'not a number' }); + readOnly({ ttl: 'not a number' }); }); test('read returns Payload | undefined', () => { @@ -139,7 +139,7 @@ describe('ValidAnnotations gate', () => { type Gated = ValidAnnotations<'read', As>; // The gated tuple's second element is `never`, so the original tuple // cannot be assigned to it. - const original: As = [readOnly.apply({ ttl: 60 }), writeOnly.apply({ actor: 'system' })]; + const original: As = [readOnly({ ttl: 60 }), writeOnly({ actor: 'system' })]; // @ts-expect-error - second element resolves to never under 'read' const _gated: Gated = original; void _gated; @@ -175,38 +175,38 @@ describe('lane-terminal call-shape simulation', () => { } test('read terminal accepts read-only annotations', () => { - readTerminal(readOnly.apply({ ttl: 60 })); + readTerminal(readOnly({ ttl: 60 })); }); test('read terminal accepts both-kind annotations', () => { - readTerminal(both.apply({ traceId: 't' })); + readTerminal(both({ traceId: 't' })); }); test('read terminal accepts a mix of read-only and both-kind annotations', () => { - readTerminal(readOnly.apply({ ttl: 60 }), both.apply({ traceId: 't' })); + readTerminal(readOnly({ ttl: 60 }), both({ traceId: 't' })); }); test('read terminal rejects write-only annotations (negative)', () => { // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' - readTerminal(writeOnly.apply({ actor: 'system' })); + readTerminal(writeOnly({ actor: 'system' })); }); test('read terminal rejects a mix that includes a write-only annotation (negative)', () => { // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' - readTerminal(readOnly.apply({ ttl: 60 }), writeOnly.apply({ actor: 'system' })); + readTerminal(readOnly({ ttl: 60 }), writeOnly({ actor: 'system' })); }); test('write terminal accepts write-only annotations', () => { - writeTerminal(writeOnly.apply({ actor: 'system' })); + writeTerminal(writeOnly({ actor: 'system' })); }); test('write terminal accepts both-kind annotations', () => { - writeTerminal(both.apply({ traceId: 't' })); + writeTerminal(both({ traceId: 't' })); }); test('write terminal rejects read-only annotations (negative)', () => { // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' - writeTerminal(readOnly.apply({ ttl: 60 })); + writeTerminal(readOnly({ ttl: 60 })); }); test('terminals accept zero annotations (empty variadic)', () => { @@ -223,7 +223,7 @@ describe('type narrowness preserved across the gate', () => { return annotations as unknown as As; } - const out = inspect(both.apply({ traceId: 't' })); + const out = inspect(both({ traceId: 't' })); // The handle's payload type survives the gate. assertType<{ traceId: string }>(out[0].value); }); diff --git a/packages/2-sql/4-lanes/sql-builder/test/playground/annotate.test-d.ts b/packages/2-sql/4-lanes/sql-builder/test/playground/annotate.test-d.ts index 17170757d2..61322a030b 100644 --- a/packages/2-sql/4-lanes/sql-builder/test/playground/annotate.test-d.ts +++ b/packages/2-sql/4-lanes/sql-builder/test/playground/annotate.test-d.ts @@ -36,7 +36,7 @@ describe('SelectQuery.annotate (read-typed)', () => { test('accepts a read-only annotation', () => { const plan = db.users .select('id') - .annotate(cacheAnnotation.apply({ ttl: 60 })) + .annotate(cacheAnnotation({ ttl: 60 })) .build(); expectTypeOf(plan).toEqualTypeOf>(); }); @@ -44,7 +44,7 @@ describe('SelectQuery.annotate (read-typed)', () => { test('accepts a both-kind annotation', () => { const plan = db.users .select('id') - .annotate(otelAnnotation.apply({ traceId: 't' })) + .annotate(otelAnnotation({ traceId: 't' })) .build(); expectTypeOf(plan).toEqualTypeOf>(); }); @@ -52,21 +52,21 @@ describe('SelectQuery.annotate (read-typed)', () => { test('accepts multiple compatible annotations in a single call', () => { const plan = db.users .select('id') - .annotate(cacheAnnotation.apply({ ttl: 60 }), otelAnnotation.apply({ traceId: 't' })) + .annotate(cacheAnnotation({ ttl: 60 }), otelAnnotation({ traceId: 't' })) .build(); expectTypeOf(plan).toEqualTypeOf>(); }); test('rejects a write-only annotation (negative)', () => { // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' - db.users.select('id').annotate(auditAnnotation.apply({ actor: 'system' })); + db.users.select('id').annotate(auditAnnotation({ actor: 'system' })); }); test('rejects a mix containing a write-only annotation (negative)', () => { db.users .select('id') // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' - .annotate(cacheAnnotation.apply({ ttl: 60 }), auditAnnotation.apply({ actor: 'system' })); + .annotate(cacheAnnotation({ ttl: 60 }), auditAnnotation({ actor: 'system' })); }); test('accepts zero annotations (empty variadic)', () => { @@ -77,7 +77,7 @@ describe('SelectQuery.annotate (read-typed)', () => { test('chainable: .annotate() before .where preserves row type', () => { const plan = db.users .select('id', 'email') - .annotate(cacheAnnotation.apply({ ttl: 60 })) + .annotate(cacheAnnotation({ ttl: 60 })) .where((c, fns) => fns.eq(c.id, 1)) .build(); expectTypeOf(plan).toEqualTypeOf>(); @@ -87,7 +87,7 @@ describe('SelectQuery.annotate (read-typed)', () => { const plan = db.users .select('id', 'email') .where((c, fns) => fns.eq(c.id, 1)) - .annotate(cacheAnnotation.apply({ ttl: 60 })) + .annotate(cacheAnnotation({ ttl: 60 })) .build(); expectTypeOf(plan).toEqualTypeOf>(); }); @@ -95,7 +95,7 @@ describe('SelectQuery.annotate (read-typed)', () => { test('chainable: .annotate() between .select and .limit preserves row type', () => { const plan = db.users .select('id') - .annotate(cacheAnnotation.apply({ ttl: 60 })) + .annotate(cacheAnnotation({ ttl: 60 })) .limit(10) .build(); expectTypeOf(plan).toEqualTypeOf>(); @@ -107,7 +107,7 @@ describe('GroupedQuery.annotate (read-typed)', () => { const plan = db.posts .select('user_id') .groupBy('user_id') - .annotate(cacheAnnotation.apply({ ttl: 60 })) + .annotate(cacheAnnotation({ ttl: 60 })) .build(); expectTypeOf(plan).toEqualTypeOf>(); }); @@ -116,7 +116,7 @@ describe('GroupedQuery.annotate (read-typed)', () => { const plan = db.posts .select('user_id') .groupBy('user_id') - .annotate(otelAnnotation.apply({ traceId: 't' })) + .annotate(otelAnnotation({ traceId: 't' })) .build(); expectTypeOf(plan).toEqualTypeOf>(); }); @@ -126,14 +126,14 @@ describe('GroupedQuery.annotate (read-typed)', () => { .select('user_id') .groupBy('user_id') // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' - .annotate(auditAnnotation.apply({ actor: 'system' })); + .annotate(auditAnnotation({ actor: 'system' })); }); test('chainable: .annotate() between .groupBy and .orderBy preserves row type', () => { const plan = db.posts .select('user_id') .groupBy('user_id') - .annotate(cacheAnnotation.apply({ ttl: 60 })) + .annotate(cacheAnnotation({ ttl: 60 })) .orderBy('user_id') .build(); expectTypeOf(plan).toEqualTypeOf>(); @@ -142,29 +142,29 @@ describe('GroupedQuery.annotate (read-typed)', () => { describe('InsertQuery.annotate (write-typed)', () => { test('accepts a write-only annotation', () => { - db.users.insert({ name: 'Alice' }).annotate(auditAnnotation.apply({ actor: 'system' })); + db.users.insert({ name: 'Alice' }).annotate(auditAnnotation({ actor: 'system' })); }); test('accepts a both-kind annotation', () => { - db.users.insert({ name: 'Alice' }).annotate(otelAnnotation.apply({ traceId: 't' })); + db.users.insert({ name: 'Alice' }).annotate(otelAnnotation({ traceId: 't' })); }); test('rejects a read-only annotation (negative)', () => { // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' - db.users.insert({ name: 'Alice' }).annotate(cacheAnnotation.apply({ ttl: 60 })); + db.users.insert({ name: 'Alice' }).annotate(cacheAnnotation({ ttl: 60 })); }); test('rejects a mix containing a read-only annotation (negative)', () => { db.users .insert({ name: 'Alice' }) // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' - .annotate(auditAnnotation.apply({ actor: 'system' }), cacheAnnotation.apply({ ttl: 60 })); + .annotate(auditAnnotation({ actor: 'system' }), cacheAnnotation({ ttl: 60 })); }); test('chainable: .annotate() before .returning preserves the resulting row type', () => { const plan = db.users .insert({ name: 'Alice' }) - .annotate(auditAnnotation.apply({ actor: 'system' })) + .annotate(auditAnnotation({ actor: 'system' })) .returning('id', 'name') .build(); expectTypeOf(plan).toEqualTypeOf>(); @@ -176,14 +176,14 @@ describe('UpdateQuery.annotate (write-typed)', () => { db.users .update({ name: 'Alice' }) .where((f, fns) => fns.eq(f.id, 1)) - .annotate(auditAnnotation.apply({ actor: 'system' })); + .annotate(auditAnnotation({ actor: 'system' })); }); test('accepts a both-kind annotation', () => { db.users .update({ name: 'Alice' }) .where((f, fns) => fns.eq(f.id, 1)) - .annotate(otelAnnotation.apply({ traceId: 't' })); + .annotate(otelAnnotation({ traceId: 't' })); }); test('rejects a read-only annotation (negative)', () => { @@ -191,14 +191,14 @@ describe('UpdateQuery.annotate (write-typed)', () => { .update({ name: 'Alice' }) .where((f, fns) => fns.eq(f.id, 1)) // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' - .annotate(cacheAnnotation.apply({ ttl: 60 })); + .annotate(cacheAnnotation({ ttl: 60 })); }); test('chainable: .annotate() before .returning preserves the resulting row type', () => { const plan = db.users .update({ name: 'Alice' }) .where((f, fns) => fns.eq(f.id, 1)) - .annotate(auditAnnotation.apply({ actor: 'system' })) + .annotate(auditAnnotation({ actor: 'system' })) .returning('id', 'name') .build(); expectTypeOf(plan).toEqualTypeOf>(); @@ -210,14 +210,14 @@ describe('DeleteQuery.annotate (write-typed)', () => { db.users .delete() .where((f, fns) => fns.eq(f.id, 1)) - .annotate(auditAnnotation.apply({ actor: 'system' })); + .annotate(auditAnnotation({ actor: 'system' })); }); test('accepts a both-kind annotation', () => { db.users .delete() .where((f, fns) => fns.eq(f.id, 1)) - .annotate(otelAnnotation.apply({ traceId: 't' })); + .annotate(otelAnnotation({ traceId: 't' })); }); test('rejects a read-only annotation (negative)', () => { @@ -225,14 +225,14 @@ describe('DeleteQuery.annotate (write-typed)', () => { .delete() .where((f, fns) => fns.eq(f.id, 1)) // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' - .annotate(cacheAnnotation.apply({ ttl: 60 })); + .annotate(cacheAnnotation({ ttl: 60 })); }); test('chainable: .annotate() before .returning preserves the resulting row type', () => { const plan = db.users .delete() .where((f, fns) => fns.eq(f.id, 1)) - .annotate(auditAnnotation.apply({ actor: 'system' })) + .annotate(auditAnnotation({ actor: 'system' })) .returning('id') .build(); expectTypeOf(plan).toEqualTypeOf>(); diff --git a/packages/2-sql/4-lanes/sql-builder/test/runtime/annotate.test.ts b/packages/2-sql/4-lanes/sql-builder/test/runtime/annotate.test.ts index 506fd96fbf..b7d9e6635d 100644 --- a/packages/2-sql/4-lanes/sql-builder/test/runtime/annotate.test.ts +++ b/packages/2-sql/4-lanes/sql-builder/test/runtime/annotate.test.ts @@ -44,7 +44,7 @@ describe('SelectQuery.annotate', () => { it('writes the applied annotation under its namespace on plan.meta.annotations', () => { const plan = db() .users.select('id') - .annotate(cacheAnnotation.apply({ ttl: 60 })) + .annotate(cacheAnnotation({ ttl: 60 })) .build(); const stored = plan.meta.annotations?.['cache']; @@ -58,7 +58,7 @@ describe('SelectQuery.annotate', () => { it('round-trips through the typed handle.read accessor', () => { const plan = db() .users.select('id') - .annotate(cacheAnnotation.apply({ ttl: 60 })) + .annotate(cacheAnnotation({ ttl: 60 })) .build(); expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); @@ -72,8 +72,8 @@ describe('SelectQuery.annotate', () => { it('multiple annotations under different namespaces coexist', () => { const plan = db() .users.select('id') - .annotate(cacheAnnotation.apply({ ttl: 60 })) - .annotate(otelAnnotation.apply({ traceId: 't-1' })) + .annotate(cacheAnnotation({ ttl: 60 })) + .annotate(otelAnnotation({ traceId: 't-1' })) .build(); expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); @@ -83,7 +83,7 @@ describe('SelectQuery.annotate', () => { it('multiple annotations passed in a single call coexist', () => { const plan = db() .users.select('id') - .annotate(cacheAnnotation.apply({ ttl: 60 }), otelAnnotation.apply({ traceId: 't-1' })) + .annotate(cacheAnnotation({ ttl: 60 }), otelAnnotation({ traceId: 't-1' })) .build(); expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); @@ -93,8 +93,8 @@ describe('SelectQuery.annotate', () => { it('duplicate namespace last-write-wins', () => { const plan = db() .users.select('id') - .annotate(cacheAnnotation.apply({ ttl: 60 })) - .annotate(cacheAnnotation.apply({ ttl: 120 })) + .annotate(cacheAnnotation({ ttl: 60 })) + .annotate(cacheAnnotation({ ttl: 120 })) .build(); expect(cacheAnnotation.read(plan)).toEqual({ ttl: 120 }); @@ -102,7 +102,7 @@ describe('SelectQuery.annotate', () => { it('annotate does not mutate the original builder (immutability)', () => { const base = db().users.select('id'); - const annotated = base.annotate(cacheAnnotation.apply({ ttl: 60 })); + const annotated = base.annotate(cacheAnnotation({ ttl: 60 })); const basePlan = base.build(); const annotatedPlan = annotated.build(); @@ -113,7 +113,7 @@ describe('SelectQuery.annotate', () => { it('chainable in any position: immediately after .select', () => { const plan = db() .users.select('id') - .annotate(cacheAnnotation.apply({ ttl: 60 })) + .annotate(cacheAnnotation({ ttl: 60 })) .build(); expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); @@ -122,7 +122,7 @@ describe('SelectQuery.annotate', () => { it('chainable in any position: between .select and .where', () => { const plan = db() .users.select('id') - .annotate(cacheAnnotation.apply({ ttl: 60 })) + .annotate(cacheAnnotation({ ttl: 60 })) .where((f, fns) => fns.eq(f.id, 1)) .build(); @@ -133,7 +133,7 @@ describe('SelectQuery.annotate', () => { const plan = db() .users.select('id') .where((f, fns) => fns.eq(f.id, 1)) - .annotate(cacheAnnotation.apply({ ttl: 60 })) + .annotate(cacheAnnotation({ ttl: 60 })) .limit(10) .build(); @@ -148,9 +148,9 @@ describe('SelectQuery.annotate', () => { const annotatedAst = db() .users.select('id') - .annotate(cacheAnnotation.apply({ ttl: 60 })) + .annotate(cacheAnnotation({ ttl: 60 })) .where((f, fns) => fns.eq(f.id, 1)) - .annotate(otelAnnotation.apply({ traceId: 't-1' })) + .annotate(otelAnnotation({ traceId: 't-1' })) .buildAst(); expect(annotatedAst).toEqual(baseAst); @@ -174,7 +174,7 @@ describe('SelectQuery.annotate', () => { const builder = db().users.select('id') as unknown as { annotate(annotation: unknown): unknown; }; - expect(() => builder.annotate(auditAnnotation.apply({ actor: 'system' }))).toThrow( + expect(() => builder.annotate(auditAnnotation({ actor: 'system' }))).toThrow( expect.objectContaining({ code: 'RUNTIME.ANNOTATION_INAPPLICABLE', category: 'RUNTIME', @@ -188,7 +188,7 @@ describe('GroupedQuery.annotate', () => { const plan = db() .posts.select('user_id') .groupBy('user_id') - .annotate(cacheAnnotation.apply({ ttl: 60 })) + .annotate(cacheAnnotation({ ttl: 60 })) .build(); expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); @@ -197,7 +197,7 @@ describe('GroupedQuery.annotate', () => { it('chainable in any position: between .select and .groupBy', () => { const plan = db() .posts.select('user_id') - .annotate(cacheAnnotation.apply({ ttl: 60 })) + .annotate(cacheAnnotation({ ttl: 60 })) .groupBy('user_id') .build(); @@ -208,7 +208,7 @@ describe('GroupedQuery.annotate', () => { const plan = db() .posts.select('user_id') .groupBy('user_id') - .annotate(cacheAnnotation.apply({ ttl: 60 })) + .annotate(cacheAnnotation({ ttl: 60 })) .orderBy('user_id') .build(); @@ -219,7 +219,7 @@ describe('GroupedQuery.annotate', () => { const builder = db().posts.select('user_id').groupBy('user_id') as unknown as { annotate(annotation: unknown): unknown; }; - expect(() => builder.annotate(auditAnnotation.apply({ actor: 'system' }))).toThrow( + expect(() => builder.annotate(auditAnnotation({ actor: 'system' }))).toThrow( expect.objectContaining({ code: 'RUNTIME.ANNOTATION_INAPPLICABLE' }), ); }); @@ -229,7 +229,7 @@ describe('InsertQuery.annotate', () => { it('writes the applied annotation under its namespace on plan.meta.annotations', () => { const plan = db() .users.insert({ name: 'Alice' }) - .annotate(auditAnnotation.apply({ actor: 'system' })) + .annotate(auditAnnotation({ actor: 'system' })) .build(); expect(auditAnnotation.read(plan)).toEqual({ actor: 'system' }); @@ -238,7 +238,7 @@ describe('InsertQuery.annotate', () => { it('accepts both-kind annotations', () => { const plan = db() .users.insert({ name: 'Alice' }) - .annotate(otelAnnotation.apply({ traceId: 't-1' })) + .annotate(otelAnnotation({ traceId: 't-1' })) .build(); expect(otelAnnotation.read(plan)).toEqual({ traceId: 't-1' }); @@ -247,7 +247,7 @@ describe('InsertQuery.annotate', () => { it('survives across .returning(...) chaining', () => { const plan = db() .users.insert({ name: 'Alice' }) - .annotate(auditAnnotation.apply({ actor: 'system' })) + .annotate(auditAnnotation({ actor: 'system' })) .returning('id', 'name') .build(); @@ -258,7 +258,7 @@ describe('InsertQuery.annotate', () => { const builder = db().users.insert({ name: 'Alice' }) as unknown as { annotate(annotation: unknown): unknown; }; - expect(() => builder.annotate(cacheAnnotation.apply({ ttl: 60 }))).toThrow( + expect(() => builder.annotate(cacheAnnotation({ ttl: 60 }))).toThrow( expect.objectContaining({ code: 'RUNTIME.ANNOTATION_INAPPLICABLE' }), ); }); @@ -269,7 +269,7 @@ describe('UpdateQuery.annotate', () => { const plan = db() .users.update({ name: 'Alice' }) .where((f, fns) => fns.eq(f.id, 1)) - .annotate(auditAnnotation.apply({ actor: 'system' })) + .annotate(auditAnnotation({ actor: 'system' })) .build(); expect(auditAnnotation.read(plan)).toEqual({ actor: 'system' }); @@ -278,7 +278,7 @@ describe('UpdateQuery.annotate', () => { it('survives across .where(...) and .returning(...) chaining', () => { const plan = db() .users.update({ name: 'Alice' }) - .annotate(auditAnnotation.apply({ actor: 'system' })) + .annotate(auditAnnotation({ actor: 'system' })) .where((f, fns) => fns.eq(f.id, 1)) .returning('id', 'name') .build(); @@ -290,7 +290,7 @@ describe('UpdateQuery.annotate', () => { const builder = db().users.update({ name: 'Alice' }) as unknown as { annotate(annotation: unknown): unknown; }; - expect(() => builder.annotate(cacheAnnotation.apply({ ttl: 60 }))).toThrow( + expect(() => builder.annotate(cacheAnnotation({ ttl: 60 }))).toThrow( expect.objectContaining({ code: 'RUNTIME.ANNOTATION_INAPPLICABLE' }), ); }); @@ -301,7 +301,7 @@ describe('DeleteQuery.annotate', () => { const plan = db() .users.delete() .where((f, fns) => fns.eq(f.id, 1)) - .annotate(auditAnnotation.apply({ actor: 'system' })) + .annotate(auditAnnotation({ actor: 'system' })) .build(); expect(auditAnnotation.read(plan)).toEqual({ actor: 'system' }); @@ -310,7 +310,7 @@ describe('DeleteQuery.annotate', () => { it('survives across .where(...) and .returning(...) chaining', () => { const plan = db() .users.delete() - .annotate(auditAnnotation.apply({ actor: 'system' })) + .annotate(auditAnnotation({ actor: 'system' })) .where((f, fns) => fns.eq(f.id, 1)) .returning('id') .build(); @@ -322,7 +322,7 @@ describe('DeleteQuery.annotate', () => { const builder = db().users.delete() as unknown as { annotate(annotation: unknown): unknown; }; - expect(() => builder.annotate(cacheAnnotation.apply({ ttl: 60 }))).toThrow( + expect(() => builder.annotate(cacheAnnotation({ ttl: 60 }))).toThrow( expect.objectContaining({ code: 'RUNTIME.ANNOTATION_INAPPLICABLE' }), ); }); @@ -335,7 +335,7 @@ describe('annotate alongside framework-internal codecs metadata', () => { it('coexists with the framework codecs map under its reserved namespace', () => { const plan = db() .users.select('id') - .annotate(cacheAnnotation.apply({ ttl: 60 })) + .annotate(cacheAnnotation({ ttl: 60 })) .build(); // User annotation lives under its own namespace. diff --git a/packages/3-extensions/middleware-cache/README.md b/packages/3-extensions/middleware-cache/README.md index 71885f9104..5782a43a48 100644 --- a/packages/3-extensions/middleware-cache/README.md +++ b/packages/3-extensions/middleware-cache/README.md @@ -10,7 +10,7 @@ The package depends only on `@prisma-next/framework-components/runtime` — no S - Provide an opt-in caching `RuntimeMiddleware` that short-circuits repeated reads via the `intercept` hook. - Define the `cacheAnnotation` handle (read-only) that lane terminals (SQL DSL `.annotate(...)`, ORM read terminals) use to attach per-query cache parameters (`ttl`, `skip`, `key`). -- Resolve the cache key per execution: per-query `cacheAnnotation.apply({ key })` override, otherwise `RuntimeMiddlewareContext.contentHash(exec)` from the family runtime. +- Resolve the cache key per execution: per-query `cacheAnnotation({ key })` override, otherwise `RuntimeMiddlewareContext.contentHash(exec)` from the family runtime. - Buffer driver rows on a miss and commit to the `CacheStore` only on successful completion (`completed: true && source: 'driver'`). - Bypass the cache when `RuntimeMiddlewareContext.scope` is `'connection'` or `'transaction'`. - Ship a default in-memory LRU-with-TTL `CacheStore` and expose the `CacheStore` interface for pluggable backends (Redis, Memcached, etc.). @@ -42,14 +42,14 @@ const db = postgres({ // First call: hits the database, caches the raw rows. const first = await db.orm.User.first( { id: 1 }, - cacheAnnotation.apply({ ttl: 60_000 }), + cacheAnnotation({ ttl: 60_000 }), ); // Second call with the identical plan: served from cache, driver // not invoked. const second = await db.orm.User.first( { id: 1 }, - cacheAnnotation.apply({ ttl: 60_000 }), + cacheAnnotation({ ttl: 60_000 }), ); // Un-annotated queries are never cached — caching is strictly opt-in. @@ -63,25 +63,25 @@ The cache middleware acts only on plans that carry a `cacheAnnotation` payload w | Annotation state | Behavior | |---|---| | No `cacheAnnotation` on the plan | Pass through; never cached. | -| `cacheAnnotation.apply({ })` (no `ttl`) | Pass through; never cached. | -| `cacheAnnotation.apply({ skip: true })` | Pass through; never cached. | -| `cacheAnnotation.apply({ ttl })` | Cache lookup; commit on miss + success. | -| `cacheAnnotation.apply({ ttl, key })` | As above, but use the supplied key verbatim. | +| `cacheAnnotation({ })` (no `ttl`) | Pass through; never cached. | +| `cacheAnnotation({ skip: true })` | Pass through; never cached. | +| `cacheAnnotation({ ttl })` | Cache lookup; commit on miss + success. | +| `cacheAnnotation({ ttl, key })` | As above, but use the supplied key verbatim. | The annotation is **read-only**: it declares `applicableTo: ['read']`, so the lane gate (TML-2143 M2) rejects passing it to write terminals at both type and runtime levels. "Cache a mutation" is structurally impossible without an `as any` cast bypass at both the type and runtime levels — the cache middleware itself ships without any mutation classifier. ```typescript // ✓ ORM read terminal accepts the read-only annotation. -await db.orm.User.first({ id }, cacheAnnotation.apply({ ttl: 60_000 })); +await db.orm.User.first({ id }, cacheAnnotation({ ttl: 60_000 })); // ✗ Type error: write terminal rejects read-only annotation. -await db.orm.User.create(input, cacheAnnotation.apply({ ttl: 60_000 })); +await db.orm.User.create(input, cacheAnnotation({ ttl: 60_000 })); // ✓ SQL DSL: chainable on select / grouped builders. const plan = db.sql .from(tables.user) .select({ id: tables.user.columns.id }) - .annotate(cacheAnnotation.apply({ ttl: 60_000 })) + .annotate(cacheAnnotation({ ttl: 60_000 })) .build(); ``` @@ -89,7 +89,7 @@ const plan = db.sql Two-tier resolution: -1. **Per-query override.** `cacheAnnotation.apply({ key })` — the supplied string is used verbatim. The cache middleware does **not** rehash user-supplied keys; the caller is responsible for keeping the string bounded in size and free of sensitive data they do not want flowing into debug logs, Redis `KEYS` output, persistence dumps, or any user-supplied `CacheStore`. +1. **Per-query override.** `cacheAnnotation({ key })` — the supplied string is used verbatim. The cache middleware does **not** rehash user-supplied keys; the caller is responsible for keeping the string bounded in size and free of sensitive data they do not want flowing into debug logs, Redis `KEYS` output, persistence dumps, or any user-supplied `CacheStore`. 2. **Default.** `RuntimeMiddlewareContext.contentHash(exec)` — the family runtime owns this. The SQL and Mongo runtimes today compose `meta.storageHash + '|' + …` and pipe the result through `hashContent` (SHA-512), producing a bounded, opaque digest of the form `sha512:HEXDIGEST`. The cache middleware uses the returned string directly as the `Map` key. Two consequences worth pinning: @@ -141,7 +141,7 @@ The default `createInMemoryCacheStore({ maxEntries, clock? })`: - **Default store is not coherent across replicas.** Multiple processes / pods do not share state. Use a custom `CacheStore` (Redis, etc.) for cross-process coherence. - **Concurrent misses both populate the store.** Two concurrent first-time reads of the same key both run the driver and both commit; last writer wins. Single-flight / coalescing semantics are deferred to a follow-up. - **Reads of stale-on-arrival entries.** With a custom replicated store, a follower may serve a stale entry for a brief window after the writer commits. Use the storage-hash discrimination plus a sensible TTL. -- **No invalidation beyond TTL.** Entries are not invalidated by writes; tag-based or event-based invalidation is out of scope for this milestone. If a write invalidates a cached read, choose a TTL short enough to bound the staleness window, or pass `cacheAnnotation.apply({ skip: true })` on the read that needs to be authoritative. +- **No invalidation beyond TTL.** Entries are not invalidated by writes; tag-based or event-based invalidation is out of scope for this milestone. If a write invalidates a cached read, choose a TTL short enough to bound the staleness window, or pass `cacheAnnotation({ skip: true })` on the read that needs to be authoritative. ## See also diff --git a/packages/3-extensions/middleware-cache/src/cache-annotation.ts b/packages/3-extensions/middleware-cache/src/cache-annotation.ts index 697cdfc774..05f63a43f6 100644 --- a/packages/3-extensions/middleware-cache/src/cache-annotation.ts +++ b/packages/3-extensions/middleware-cache/src/cache-annotation.ts @@ -1,7 +1,7 @@ import { defineAnnotation } from '@prisma-next/framework-components/runtime'; /** - * Payload accepted by `cacheAnnotation.apply(...)`. + * Payload accepted when calling the `cacheAnnotation` handle. * * - `ttl` — Time-to-live for the cached entry, in milliseconds. When * omitted, the cache middleware passes the query through untouched — @@ -44,13 +44,13 @@ export interface CachePayload { * // ORM read terminal — accepts the read-only annotation. * const user = await db.User.first( * { id }, - * cacheAnnotation.apply({ ttl: 60_000 }), + * cacheAnnotation({ ttl: 60_000 }), * ); * * // SQL DSL select builder — chainable. * const plan = db.sql * .from(tables.user) - * .annotate(cacheAnnotation.apply({ ttl: 60_000 })) + * .annotate(cacheAnnotation({ ttl: 60_000 })) * .select({ id: tables.user.columns.id }) * .build(); * ``` diff --git a/packages/3-extensions/middleware-cache/src/cache-middleware.ts b/packages/3-extensions/middleware-cache/src/cache-middleware.ts index 95f05e3637..d6168a9dd4 100644 --- a/packages/3-extensions/middleware-cache/src/cache-middleware.ts +++ b/packages/3-extensions/middleware-cache/src/cache-middleware.ts @@ -72,7 +72,7 @@ function readCachePayload(plan: ExecutionPlan): CachePayload | undefined { * * Two-tier resolution: * - * 1. Per-query override: `cacheAnnotation.apply({ key })` — the supplied + * 1. Per-query override: `cacheAnnotation({ key })` — the supplied * string is used verbatim. Not rehashed; the user is responsible for * keeping the string bounded and free of sensitive data. * 2. Default: `ctx.contentHash(exec)` — the family runtime owns this and @@ -138,7 +138,7 @@ async function resolveCacheKey( * * const users = await db.User.first( * { id }, - * cacheAnnotation.apply({ ttl: 60_000 }), + * cacheAnnotation({ ttl: 60_000 }), * ); * ``` */ diff --git a/packages/3-extensions/middleware-cache/test/cache-annotation.test.ts b/packages/3-extensions/middleware-cache/test/cache-annotation.test.ts index 98d9a2d019..c9d4828306 100644 --- a/packages/3-extensions/middleware-cache/test/cache-annotation.test.ts +++ b/packages/3-extensions/middleware-cache/test/cache-annotation.test.ts @@ -23,15 +23,15 @@ describe('cacheAnnotation handle', () => { }); it('produces an applied annotation under namespace "cache" carrying the payload', () => { - const applied = cacheAnnotation.apply({ ttl: 60 }); + const applied = cacheAnnotation({ ttl: 60 }); expect(applied.namespace).toBe('cache'); expect(applied.value).toEqual({ ttl: 60 }); expect(Array.from(applied.applicableTo)).toEqual(['read']); }); - it('round-trips a payload via apply -> read on a plan', () => { - const applied = cacheAnnotation.apply({ ttl: 60 }); + it('round-trips a payload via call -> read on a plan', () => { + const applied = cacheAnnotation({ ttl: 60 }); const plan = planWith({ cache: applied }); const recovered = cacheAnnotation.read(plan); @@ -50,14 +50,14 @@ describe('cacheAnnotation handle', () => { it('preserves all CachePayload fields (ttl, skip, key)', () => { const payload: CachePayload = { ttl: 120, skip: false, key: 'custom-key' }; - const applied = cacheAnnotation.apply(payload); + const applied = cacheAnnotation(payload); const plan = planWith({ cache: applied }); expect(cacheAnnotation.read(plan)).toEqual(payload); }); it('accepts an empty payload', () => { - const applied = cacheAnnotation.apply({}); + const applied = cacheAnnotation({}); const plan = planWith({ cache: applied }); expect(cacheAnnotation.read(plan)).toEqual({}); diff --git a/packages/3-extensions/middleware-cache/test/cache-annotation.types.test-d.ts b/packages/3-extensions/middleware-cache/test/cache-annotation.types.test-d.ts index 63d683bc2d..8f40a3996d 100644 --- a/packages/3-extensions/middleware-cache/test/cache-annotation.types.test-d.ts +++ b/packages/3-extensions/middleware-cache/test/cache-annotation.types.test-d.ts @@ -2,20 +2,20 @@ import type { AnnotationValue, OperationKind } from '@prisma-next/framework-comp import { assertType, expectTypeOf, test } from 'vitest'; import { type CachePayload, cacheAnnotation } from '../src/cache-annotation'; -test('cacheAnnotation.apply preserves the CachePayload type', () => { - const applied = cacheAnnotation.apply({ ttl: 60 }); +test('cacheAnnotation call signature preserves the CachePayload type', () => { + const applied = cacheAnnotation({ ttl: 60 }); expectTypeOf(applied).toEqualTypeOf>(); }); -test('cacheAnnotation.apply rejects non-CachePayload arguments', () => { +test('cacheAnnotation call rejects non-CachePayload arguments', () => { // @ts-expect-error - unknown field on payload - cacheAnnotation.apply({ ttl: 60, nonsense: true }); + cacheAnnotation({ ttl: 60, nonsense: true }); // @ts-expect-error - wrong field type - cacheAnnotation.apply({ ttl: '60' }); + cacheAnnotation({ ttl: '60' }); // @ts-expect-error - wrong field type - cacheAnnotation.apply({ skip: 'yes' }); + cacheAnnotation({ skip: 'yes' }); }); test('cacheAnnotation.read returns CachePayload | undefined', () => { @@ -69,9 +69,9 @@ test('cacheAnnotation is not applicable to write operations at the type level', type WriteApplies = 'write' extends Kinds ? true : false; assertType(false as WriteApplies); - // And the AnnotationValue produced by apply() carries 'read' specifically, - // so ValidAnnotations<'write', [typeof applied]> resolves to [never]. - const applied = cacheAnnotation.apply({ ttl: 60 }); + // And the AnnotationValue produced by calling the handle carries 'read' + // specifically, so ValidAnnotations<'write', [typeof applied]> resolves to [never]. + const applied = cacheAnnotation({ ttl: 60 }); expectTypeOf(applied).toExtend>(); // The applied value is NOT assignable to AnnotationValue. expectTypeOf(applied).not.toExtend>(); diff --git a/packages/3-extensions/middleware-cache/test/cache-key.test.ts b/packages/3-extensions/middleware-cache/test/cache-key.test.ts index e480ca8adf..9a0009f238 100644 --- a/packages/3-extensions/middleware-cache/test/cache-key.test.ts +++ b/packages/3-extensions/middleware-cache/test/cache-key.test.ts @@ -63,7 +63,7 @@ describe('cache key resolution', () => { const store = spyStore(); const mw = createCacheMiddleware({ store }); const exec = makeExec('select 1', { - cache: cacheAnnotation.apply({ ttl: 60_000 }), + cache: cacheAnnotation({ ttl: 60_000 }), }); const ctx = makeCtx(); @@ -84,7 +84,7 @@ describe('cache key resolution', () => { const store = spyStore(); const mw = createCacheMiddleware({ store }); const exec = makeExec('select 1', { - cache: cacheAnnotation.apply({ ttl: 60_000 }), + cache: cacheAnnotation({ ttl: 60_000 }), }); const contentHash = vi.fn(async (e: ExecutionPlan) => `derived:${(e as MockExec).statement}`); const ctx = makeCtx({ contentHash }); @@ -100,10 +100,10 @@ describe('cache key resolution', () => { const store = spyStore(); const mw = createCacheMiddleware({ store, clock: () => 0 }); const execA = makeExec('A', { - cache: cacheAnnotation.apply({ ttl: 60_000 }), + cache: cacheAnnotation({ ttl: 60_000 }), }); const execB = makeExec('B', { - cache: cacheAnnotation.apply({ ttl: 60_000 }), + cache: cacheAnnotation({ ttl: 60_000 }), }); const ctx = makeCtx(); @@ -131,12 +131,12 @@ describe('cache key resolution', () => { }); }); - describe('per-query override: cacheAnnotation.apply({ key })', () => { + describe('per-query override: cacheAnnotation({ key })', () => { it('uses the user-supplied key in place of ctx.contentHash', async () => { const store = spyStore(); const mw = createCacheMiddleware({ store }); const exec = makeExec('select 1', { - cache: cacheAnnotation.apply({ ttl: 60_000, key: 'custom-key' }), + cache: cacheAnnotation({ ttl: 60_000, key: 'custom-key' }), }); const ctx = makeCtx(); @@ -156,7 +156,7 @@ describe('cache key resolution', () => { const store = spyStore(); const mw = createCacheMiddleware({ store }); const exec = makeExec('select 1', { - cache: cacheAnnotation.apply({ ttl: 60_000, key: 'custom-key' }), + cache: cacheAnnotation({ ttl: 60_000, key: 'custom-key' }), }); const contentHash = vi.fn(async () => 'should-not-be-used'); const ctx = makeCtx({ contentHash }); @@ -173,7 +173,7 @@ describe('cache key resolution', () => { // mangle, hash, or otherwise transform it. const userKey = 'tenant=acme|user=alice|page=42'; const exec = makeExec('select 1', { - cache: cacheAnnotation.apply({ ttl: 60_000, key: userKey }), + cache: cacheAnnotation({ ttl: 60_000, key: userKey }), }); const ctx = makeCtx(); @@ -197,7 +197,7 @@ describe('cache key resolution', () => { const mw = createCacheMiddleware({ store }); const exec = makeExec('select anything', { - cache: cacheAnnotation.apply({ ttl: 60_000, key: 'shared-key' }), + cache: cacheAnnotation({ ttl: 60_000, key: 'shared-key' }), }); const ctx = makeCtx(); @@ -229,7 +229,7 @@ describe('cache key resolution', () => { target: 'mongo', targetFamily: 'mongo', annotations: { - cache: cacheAnnotation.apply({ ttl: 60_000 }), + cache: cacheAnnotation({ ttl: 60_000 }), }, }, }); @@ -260,7 +260,7 @@ describe('cache key resolution', () => { const store = spyStore(); const mw = createCacheMiddleware({ store, clock: () => 0 }); const exec = makeExec('shared statement', { - cache: cacheAnnotation.apply({ ttl: 60_000 }), + cache: cacheAnnotation({ ttl: 60_000 }), }); // Same exec object but two different ctx.contentHash returns — diff --git a/packages/3-extensions/middleware-cache/test/cache-middleware.test.ts b/packages/3-extensions/middleware-cache/test/cache-middleware.test.ts index f455c71457..4e2362c643 100644 --- a/packages/3-extensions/middleware-cache/test/cache-middleware.test.ts +++ b/packages/3-extensions/middleware-cache/test/cache-middleware.test.ts @@ -80,7 +80,7 @@ describe('createCacheMiddleware — opt-in semantics', () => { const store = spyStore(); const mw = createCacheMiddleware({ store }); const exec = makeExec('select 1', { - cache: cacheAnnotation.apply({ ttl: 60_000, skip: true }), + cache: cacheAnnotation({ ttl: 60_000, skip: true }), }); const result = await mw.intercept!(exec, makeCtx()); @@ -93,7 +93,7 @@ describe('createCacheMiddleware — opt-in semantics', () => { const store = spyStore(); const mw = createCacheMiddleware({ store }); const exec = makeExec('select 1', { - cache: cacheAnnotation.apply({}), + cache: cacheAnnotation({}), }); const result = await mw.intercept!(exec, makeCtx()); @@ -130,7 +130,7 @@ describe('createCacheMiddleware — hit path', () => { const mw = createCacheMiddleware({ store }); const exec = makeExec('select 1', { - cache: cacheAnnotation.apply({ ttl: 60_000 }), + cache: cacheAnnotation({ ttl: 60_000 }), }); const result = await mw.intercept!(exec, makeCtx()); @@ -146,7 +146,7 @@ describe('createCacheMiddleware — hit path', () => { store.inner.set('key:select 1', { rows: [{ id: 1 }], storedAt: 0 }); const mw = createCacheMiddleware({ store }); const exec = makeExec('select 1', { - cache: cacheAnnotation.apply({ ttl: 60_000 }), + cache: cacheAnnotation({ ttl: 60_000 }), }); const debug = vi.fn(); const ctx = makeCtx({ @@ -163,7 +163,7 @@ describe('createCacheMiddleware — hit path', () => { store.inner.set('key:select 1', { rows: [{ id: 1 }], storedAt: 0 }); const mw = createCacheMiddleware({ store }); const exec = makeExec('select 1', { - cache: cacheAnnotation.apply({ ttl: 60_000 }), + cache: cacheAnnotation({ ttl: 60_000 }), }); const ctx = makeCtx(); @@ -186,7 +186,7 @@ describe('createCacheMiddleware — hit path', () => { store.inner.set('key:select 1', { rows: [{ id: 1 }], storedAt: 0 }); const mw = createCacheMiddleware({ store }); const exec = makeExec('select 1', { - cache: cacheAnnotation.apply({ ttl: 60_000 }), + cache: cacheAnnotation({ ttl: 60_000 }), }); const ctx = makeCtx({ // No debug field. @@ -202,7 +202,7 @@ describe('createCacheMiddleware — miss path', () => { const store = spyStore(); const mw = createCacheMiddleware({ store }); const exec = makeExec('select 1', { - cache: cacheAnnotation.apply({ ttl: 60_000 }), + cache: cacheAnnotation({ ttl: 60_000 }), }); const result = await mw.intercept!(exec, makeCtx()); @@ -213,7 +213,7 @@ describe('createCacheMiddleware — miss path', () => { const store = spyStore(); const mw = createCacheMiddleware({ store }); const exec = makeExec('select 1', { - cache: cacheAnnotation.apply({ ttl: 60_000 }), + cache: cacheAnnotation({ ttl: 60_000 }), }); const debug = vi.fn(); const ctx = makeCtx({ @@ -229,7 +229,7 @@ describe('createCacheMiddleware — miss path', () => { const store = spyStore(); const mw = createCacheMiddleware({ store, clock: () => 1_234 }); const exec = makeExec('select 1', { - cache: cacheAnnotation.apply({ ttl: 60_000 }), + cache: cacheAnnotation({ ttl: 60_000 }), }); const ctx = makeCtx(); @@ -257,7 +257,7 @@ describe('createCacheMiddleware — miss path', () => { const store = spyStore(); const mw = createCacheMiddleware({ store }); const exec = makeExec('select 1', { - cache: cacheAnnotation.apply({ ttl: 60_000 }), + cache: cacheAnnotation({ ttl: 60_000 }), }); const ctx = makeCtx(); @@ -279,7 +279,7 @@ describe('createCacheMiddleware — miss path', () => { const store = spyStore(); const mw = createCacheMiddleware({ store }); const exec = makeExec('select 1', { - cache: cacheAnnotation.apply({ ttl: 60_000 }), + cache: cacheAnnotation({ ttl: 60_000 }), }); const ctx = makeCtx(); @@ -302,7 +302,7 @@ describe('createCacheMiddleware — miss path', () => { const store = spyStore(); const mw = createCacheMiddleware({ store }); const exec = makeExec('select 1', { - cache: cacheAnnotation.apply({ ttl: 60_000 }), + cache: cacheAnnotation({ ttl: 60_000 }), }); const ctx = makeCtx(); @@ -330,10 +330,10 @@ describe('createCacheMiddleware — miss path', () => { const store = spyStore(); const mw = createCacheMiddleware({ store, clock: () => 0 }); const execA = makeExec('select A', { - cache: cacheAnnotation.apply({ ttl: 60_000 }), + cache: cacheAnnotation({ ttl: 60_000 }), }); const execB = makeExec('select B', { - cache: cacheAnnotation.apply({ ttl: 60_000 }), + cache: cacheAnnotation({ ttl: 60_000 }), }); const ctx = makeCtx(); @@ -371,7 +371,7 @@ describe('createCacheMiddleware — scope guard', () => { store.inner.set('key:select 1', { rows: [{ id: 1 }], storedAt: 0 }); const mw = createCacheMiddleware({ store }); const exec = makeExec('select 1', { - cache: cacheAnnotation.apply({ ttl: 60_000 }), + cache: cacheAnnotation({ ttl: 60_000 }), }); const result = await mw.intercept!(exec, makeCtx({ scope: 'connection' })); @@ -384,7 +384,7 @@ describe('createCacheMiddleware — scope guard', () => { store.inner.set('key:select 1', { rows: [{ id: 1 }], storedAt: 0 }); const mw = createCacheMiddleware({ store }); const exec = makeExec('select 1', { - cache: cacheAnnotation.apply({ ttl: 60_000 }), + cache: cacheAnnotation({ ttl: 60_000 }), }); const result = await mw.intercept!(exec, makeCtx({ scope: 'transaction' })); @@ -396,7 +396,7 @@ describe('createCacheMiddleware — scope guard', () => { const store = spyStore(); const mw = createCacheMiddleware({ store }); const exec = makeExec('select 1', { - cache: cacheAnnotation.apply({ ttl: 60_000 }), + cache: cacheAnnotation({ ttl: 60_000 }), }); const ctx = makeCtx({ scope: 'connection' }); @@ -444,7 +444,7 @@ describe('createCacheMiddleware — middleware shape', () => { it('roundtrips a miss-then-hit through the default in-memory store', async () => { const mw = createCacheMiddleware({ maxEntries: 10 }); const exec = makeExec('select roundtrip', { - cache: cacheAnnotation.apply({ ttl: 60_000 }), + cache: cacheAnnotation({ ttl: 60_000 }), }); const ctx = makeCtx(); @@ -471,7 +471,7 @@ describe('createCacheMiddleware — middleware shape', () => { const store = createInMemoryCacheStore({ maxEntries: 5 }); const mw = createCacheMiddleware({ store }); const exec = makeExec('select custom', { - cache: cacheAnnotation.apply({ ttl: 60_000 }), + cache: cacheAnnotation({ ttl: 60_000 }), }); const ctx = makeCtx(); diff --git a/packages/3-extensions/sql-orm-client/test/annotations.test.ts b/packages/3-extensions/sql-orm-client/test/annotations.test.ts index 2d021a6475..8a2d1de156 100644 --- a/packages/3-extensions/sql-orm-client/test/annotations.test.ts +++ b/packages/3-extensions/sql-orm-client/test/annotations.test.ts @@ -26,7 +26,7 @@ describe('Collection.all annotations', () => { const { collection, runtime } = createCollection(); runtime.setNextResults([[]]); - await collection.all(cacheAnnotation.apply({ ttl: 60 })).toArray(); + await collection.all(cacheAnnotation({ ttl: 60 })).toArray(); expect(runtime.executions).toHaveLength(1); const stored = runtime.executions[0]!.plan.meta.annotations?.['cache']; @@ -41,7 +41,7 @@ describe('Collection.all annotations', () => { const { collection, runtime } = createCollection(); runtime.setNextResults([[]]); - await collection.all(cacheAnnotation.apply({ ttl: 60 })).toArray(); + await collection.all(cacheAnnotation({ ttl: 60 })).toArray(); const plan = runtime.executions[0]!.plan; expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); @@ -62,7 +62,7 @@ describe('Collection.all annotations', () => { runtime.setNextResults([[]]); await collection - .all(cacheAnnotation.apply({ ttl: 60 }), otelAnnotation.apply({ traceId: 't-1' })) + .all(cacheAnnotation({ ttl: 60 }), otelAnnotation({ traceId: 't-1' })) .toArray(); const plan = runtime.executions[0]!.plan; @@ -88,7 +88,7 @@ describe('Collection.all annotations', () => { await collection .where((user) => user.name.eq('Alice')) .take(10) - .all(cacheAnnotation.apply({ ttl: 60 })) + .all(cacheAnnotation({ ttl: 60 })) .toArray(); const plan = runtime.executions[0]!.plan; @@ -98,7 +98,7 @@ describe('Collection.all annotations', () => { it('runtime gate rejects a write-only annotation forced through a cast', () => { const { collection } = createCollection(); const allFn = collection.all as unknown as (annotation: unknown) => unknown; - expect(() => allFn.call(collection, auditAnnotation.apply({ actor: 'system' }))).toThrow( + expect(() => allFn.call(collection, auditAnnotation({ actor: 'system' }))).toThrow( expect.objectContaining({ code: 'RUNTIME.ANNOTATION_INAPPLICABLE', category: 'RUNTIME', @@ -112,7 +112,7 @@ describe('Collection.first annotations', () => { const { collection, runtime } = createCollection(); runtime.setNextResults([[]]); - await collection.first(cacheAnnotation.apply({ ttl: 60 })); + await collection.first(cacheAnnotation({ ttl: 60 })); expect(runtime.executions).toHaveLength(1); const plan = runtime.executions[0]!.plan; @@ -123,7 +123,7 @@ describe('Collection.first annotations', () => { const { collection, runtime } = createCollection(); runtime.setNextResults([[]]); - await collection.first((user) => user.name.eq('Alice'), cacheAnnotation.apply({ ttl: 60 })); + await collection.first((user) => user.name.eq('Alice'), cacheAnnotation({ ttl: 60 })); const plan = runtime.executions[0]!.plan; expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); @@ -133,7 +133,7 @@ describe('Collection.first annotations', () => { const { collection, runtime } = createCollection(); runtime.setNextResults([[]]); - await collection.first({ name: 'Alice' }, cacheAnnotation.apply({ ttl: 60 })); + await collection.first({ name: 'Alice' }, cacheAnnotation({ ttl: 60 })); const plan = runtime.executions[0]!.plan; expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); @@ -145,7 +145,7 @@ describe('Collection.first annotations', () => { // The leading argument is an AnnotationValue, not a filter. The terminal // must treat it as the leading annotation, not as a where shorthand. - await collection.first(cacheAnnotation.apply({ ttl: 60 })); + await collection.first(cacheAnnotation({ ttl: 60 })); expect(runtime.executions).toHaveLength(1); const plan = runtime.executions[0]!.plan; @@ -163,8 +163,8 @@ describe('Collection.first annotations', () => { await collection.first( (user) => user.name.eq('Alice'), - cacheAnnotation.apply({ ttl: 60 }), - otelAnnotation.apply({ traceId: 't-1' }), + cacheAnnotation({ ttl: 60 }), + otelAnnotation({ traceId: 't-1' }), ); const plan = runtime.executions[0]!.plan; @@ -176,7 +176,7 @@ describe('Collection.first annotations', () => { const { collection } = createCollection(); const firstFn = collection.first as unknown as (annotation: unknown) => Promise; await expect( - firstFn.call(collection, auditAnnotation.apply({ actor: 'system' })), + firstFn.call(collection, auditAnnotation({ actor: 'system' })), ).rejects.toMatchObject({ code: 'RUNTIME.ANNOTATION_INAPPLICABLE', category: 'RUNTIME', @@ -189,7 +189,7 @@ describe('Collection annotations alongside framework-internal codecs metadata', const { collection, runtime } = createCollection(); runtime.setNextResults([[]]); - await collection.all(cacheAnnotation.apply({ ttl: 60 })).toArray(); + await collection.all(cacheAnnotation({ ttl: 60 })).toArray(); const plan = runtime.executions[0]!.plan; // User annotation lives under its own namespace. @@ -210,7 +210,7 @@ describe('Collection.create annotations', () => { await collection.create( { id: 1, name: 'Alice', email: 'a@b.com' }, - auditAnnotation.apply({ actor: 'system' }), + auditAnnotation({ actor: 'system' }), ); expect(runtime.executions).toHaveLength(1); @@ -224,7 +224,7 @@ describe('Collection.create annotations', () => { await collection.create( { id: 1, name: 'Alice', email: 'a@b.com' }, - otelAnnotation.apply({ traceId: 't-1' }), + otelAnnotation({ traceId: 't-1' }), ); const plan = runtime.executions[0]!.plan; @@ -252,7 +252,7 @@ describe('Collection.create annotations', () => { createFn.call( collection, { id: 1, name: 'Alice', email: 'a@b.com' }, - cacheAnnotation.apply({ ttl: 60 }), + cacheAnnotation({ ttl: 60 }), ), ).rejects.toMatchObject({ code: 'RUNTIME.ANNOTATION_INAPPLICABLE', @@ -275,7 +275,7 @@ describe('Collection.createAll annotations', () => { { id: 1, name: 'A', email: 'a@b.com' }, { id: 2, name: 'B', email: 'b@b.com' }, ], - auditAnnotation.apply({ actor: 'system' }), + auditAnnotation({ actor: 'system' }), ) .toArray(); @@ -293,7 +293,7 @@ describe('Collection.createCount annotations', () => { await collection.createCount( [{ id: 1, name: 'A', email: 'a@b.com' }], - auditAnnotation.apply({ actor: 'system' }), + auditAnnotation({ actor: 'system' }), ); expect(runtime.executions.length).toBeGreaterThan(0); @@ -314,7 +314,7 @@ describe('Collection.upsert annotations', () => { update: { name: 'Alice' }, conflictOn: { id: 1 }, }, - auditAnnotation.apply({ actor: 'system' }), + auditAnnotation({ actor: 'system' }), ); const plan = runtime.executions[0]!.plan; @@ -335,7 +335,7 @@ describe('Collection.upsert annotations', () => { update: { name: 'Alice' }, conflictOn: { id: 1 }, }, - cacheAnnotation.apply({ ttl: 60 }), + cacheAnnotation({ ttl: 60 }), ), ).rejects.toMatchObject({ code: 'RUNTIME.ANNOTATION_INAPPLICABLE', @@ -350,7 +350,7 @@ describe('Collection.update annotations', () => { await collection .where({ id: 1 }) - .update({ name: 'Alice' }, auditAnnotation.apply({ actor: 'system' })); + .update({ name: 'Alice' }, auditAnnotation({ actor: 'system' })); const plan = runtime.executions[0]!.plan; expect(auditAnnotation.read(plan)).toEqual({ actor: 'system' }); @@ -364,7 +364,7 @@ describe('Collection.update annotations', () => { annotation: unknown, ) => Promise; await expect( - updateFn.call(filtered, { name: 'Alice' }, cacheAnnotation.apply({ ttl: 60 })), + updateFn.call(filtered, { name: 'Alice' }, cacheAnnotation({ ttl: 60 })), ).rejects.toMatchObject({ code: 'RUNTIME.ANNOTATION_INAPPLICABLE', }); @@ -378,7 +378,7 @@ describe('Collection.updateAll annotations', () => { await collection .where({ id: 1 }) - .updateAll({ name: 'Alice' }, auditAnnotation.apply({ actor: 'system' })) + .updateAll({ name: 'Alice' }, auditAnnotation({ actor: 'system' })) .toArray(); const plan = runtime.executions[0]!.plan; @@ -394,7 +394,7 @@ describe('Collection.updateCount annotations', () => { await collection .where({ id: 1 }) - .updateCount({ name: 'Alice' }, auditAnnotation.apply({ actor: 'system' })); + .updateCount({ name: 'Alice' }, auditAnnotation({ actor: 'system' })); expect(runtime.executions).toHaveLength(2); const matchingPlan = runtime.executions[0]!.plan; @@ -411,7 +411,7 @@ describe('Collection.delete annotations', () => { const { collection, runtime } = createReturningCollectionFor('User'); runtime.setNextResults([[{ id: 1, name: 'Alice', email: 'a@b.com' }]]); - await collection.where({ id: 1 }).delete(auditAnnotation.apply({ actor: 'system' })); + await collection.where({ id: 1 }).delete(auditAnnotation({ actor: 'system' })); const plan = runtime.executions[0]!.plan; expect(auditAnnotation.read(plan)).toEqual({ actor: 'system' }); @@ -421,11 +421,9 @@ describe('Collection.delete annotations', () => { const { collection } = createReturningCollectionFor('User'); const filtered = collection.where({ id: 1 }); const deleteFn = filtered.delete as unknown as (annotation: unknown) => Promise; - await expect(deleteFn.call(filtered, cacheAnnotation.apply({ ttl: 60 }))).rejects.toMatchObject( - { - code: 'RUNTIME.ANNOTATION_INAPPLICABLE', - }, - ); + await expect(deleteFn.call(filtered, cacheAnnotation({ ttl: 60 }))).rejects.toMatchObject({ + code: 'RUNTIME.ANNOTATION_INAPPLICABLE', + }); }); }); @@ -436,7 +434,7 @@ describe('Collection.deleteAll annotations', () => { await collection .where({ id: 1 }) - .deleteAll(auditAnnotation.apply({ actor: 'system' })) + .deleteAll(auditAnnotation({ actor: 'system' })) .toArray(); const plan = runtime.executions[0]!.plan; @@ -450,7 +448,7 @@ describe('Collection.deleteCount annotations', () => { // Two execute calls: matching select first, then the delete. runtime.setNextResults([[{ id: 1 }], []]); - await collection.where({ id: 1 }).deleteCount(auditAnnotation.apply({ actor: 'system' })); + await collection.where({ id: 1 }).deleteCount(auditAnnotation({ actor: 'system' })); expect(runtime.executions).toHaveLength(2); const matchingPlan = runtime.executions[0]!.plan; @@ -469,7 +467,7 @@ describe('Collection.aggregate annotations', () => { await collection.aggregate( (aggregate) => ({ count: aggregate.count() }), - cacheAnnotation.apply({ ttl: 60 }), + cacheAnnotation({ ttl: 60 }), ); expect(runtime.executions).toHaveLength(1); @@ -483,7 +481,7 @@ describe('Collection.aggregate annotations', () => { await collection.aggregate( (aggregate) => ({ count: aggregate.count() }), - otelAnnotation.apply({ traceId: 't-1' }), + otelAnnotation({ traceId: 't-1' }), ); const plan = runtime.executions[0]!.plan; @@ -511,7 +509,7 @@ describe('Collection.aggregate annotations', () => { aggregateFn.call( collection, (aggregate: { count: () => unknown }) => ({ count: aggregate.count() }), - auditAnnotation.apply({ actor: 'system' }), + auditAnnotation({ actor: 'system' }), ), ).rejects.toMatchObject({ code: 'RUNTIME.ANNOTATION_INAPPLICABLE', @@ -527,7 +525,7 @@ describe('GroupedCollection.aggregate annotations', () => { await collection .groupBy('userId') - .aggregate((aggregate) => ({ count: aggregate.count() }), cacheAnnotation.apply({ ttl: 60 })); + .aggregate((aggregate) => ({ count: aggregate.count() }), cacheAnnotation({ ttl: 60 })); expect(runtime.executions).toHaveLength(1); const plan = runtime.executions[0]!.plan; @@ -540,10 +538,7 @@ describe('GroupedCollection.aggregate annotations', () => { await collection .groupBy('userId') - .aggregate( - (aggregate) => ({ count: aggregate.count() }), - otelAnnotation.apply({ traceId: 't-1' }), - ); + .aggregate((aggregate) => ({ count: aggregate.count() }), otelAnnotation({ traceId: 't-1' })); const plan = runtime.executions[0]!.plan; expect(otelAnnotation.read(plan)).toEqual({ traceId: 't-1' }); @@ -568,7 +563,7 @@ describe('GroupedCollection.aggregate annotations', () => { await expect( grouped.aggregate( (aggregate: { count: () => unknown }) => ({ count: aggregate.count() }), - auditAnnotation.apply({ actor: 'system' }), + auditAnnotation({ actor: 'system' }), ), ).rejects.toMatchObject({ code: 'RUNTIME.ANNOTATION_INAPPLICABLE', diff --git a/packages/3-extensions/sql-orm-client/test/annotations.types.test-d.ts b/packages/3-extensions/sql-orm-client/test/annotations.types.test-d.ts index 8a7d140419..5a48f87c16 100644 --- a/packages/3-extensions/sql-orm-client/test/annotations.types.test-d.ts +++ b/packages/3-extensions/sql-orm-client/test/annotations.types.test-d.ts @@ -40,15 +40,15 @@ const otelAnnotation = defineAnnotation<{ traceId: string }>()({ describe('Collection.all (read-typed)', () => { test('accepts a read-only annotation', () => { - userCollection.all(cacheAnnotation.apply({ ttl: 60 })); + userCollection.all(cacheAnnotation({ ttl: 60 })); }); test('accepts a both-kind annotation', () => { - userCollection.all(otelAnnotation.apply({ traceId: 't' })); + userCollection.all(otelAnnotation({ traceId: 't' })); }); test('accepts multiple compatible annotations in a single call', () => { - userCollection.all(cacheAnnotation.apply({ ttl: 60 }), otelAnnotation.apply({ traceId: 't' })); + userCollection.all(cacheAnnotation({ ttl: 60 }), otelAnnotation({ traceId: 't' })); }); test('accepts zero annotations (empty variadic)', () => { @@ -57,17 +57,17 @@ describe('Collection.all (read-typed)', () => { test('rejects a write-only annotation (negative)', () => { // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' - userCollection.all(auditAnnotation.apply({ actor: 'system' })); + userCollection.all(auditAnnotation({ actor: 'system' })); }); test('rejects a mix containing a write-only annotation (negative)', () => { // biome-ignore format: keep on one line so @ts-expect-error attaches to the call // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' - userCollection.all(cacheAnnotation.apply({ ttl: 60 }), auditAnnotation.apply({ actor: 'system' })); + userCollection.all(cacheAnnotation({ ttl: 60 }), auditAnnotation({ actor: 'system' })); }); test('the return type is not widened by the variadic argument', () => { - const result = userCollection.all(cacheAnnotation.apply({ ttl: 60 })); + const result = userCollection.all(cacheAnnotation({ ttl: 60 })); // The return type is AsyncIterableResult regardless of annotations. expectTypeOf(result).toHaveProperty('toArray'); expectTypeOf(result.toArray).returns.toMatchTypeOf>(); @@ -76,22 +76,22 @@ describe('Collection.all (read-typed)', () => { describe('Collection.first (read-typed)', () => { test('accepts a read-only annotation with no filter', () => { - userCollection.first(cacheAnnotation.apply({ ttl: 60 })); + userCollection.first(cacheAnnotation({ ttl: 60 })); }); test('accepts a read-only annotation after a function filter', () => { - userCollection.first((user) => user.name.eq('Alice'), cacheAnnotation.apply({ ttl: 60 })); + userCollection.first((user) => user.name.eq('Alice'), cacheAnnotation({ ttl: 60 })); }); test('accepts a read-only annotation after a shorthand filter', () => { - userCollection.first({ name: 'Alice' }, cacheAnnotation.apply({ ttl: 60 })); + userCollection.first({ name: 'Alice' }, cacheAnnotation({ ttl: 60 })); }); test('accepts multiple compatible annotations after a filter', () => { userCollection.first( (user) => user.name.eq('Alice'), - cacheAnnotation.apply({ ttl: 60 }), - otelAnnotation.apply({ traceId: 't' }), + cacheAnnotation({ ttl: 60 }), + otelAnnotation({ traceId: 't' }), ); }); @@ -105,16 +105,16 @@ describe('Collection.first (read-typed)', () => { test('rejects a write-only annotation (negative)', () => { // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' - userCollection.first(auditAnnotation.apply({ actor: 'system' })); + userCollection.first(auditAnnotation({ actor: 'system' })); }); test('rejects a write-only annotation after a shorthand filter (negative)', () => { // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' - userCollection.first({ name: 'Alice' }, auditAnnotation.apply({ actor: 'system' })); + userCollection.first({ name: 'Alice' }, auditAnnotation({ actor: 'system' })); }); test('the return type is Promise', () => { - const result = userCollection.first(cacheAnnotation.apply({ ttl: 60 })); + const result = userCollection.first(cacheAnnotation({ ttl: 60 })); expectTypeOf(result).resolves.toMatchTypeOf | null>(); }); }); @@ -136,8 +136,8 @@ describe('annotation handle types are preserved through the lane', () => { // property exercised in the framework-components type-d tests, but // verified here at the ORM lane to ensure no widening happens // through the Collection.all/first signature. - test('cacheAnnotation.apply is assignable through to the terminal', () => { - const value = cacheAnnotation.apply({ ttl: 60 }); + test('cacheAnnotation construction is assignable through to the terminal', () => { + const value = cacheAnnotation({ ttl: 60 }); expectTypeOf(value).toMatchTypeOf>(); userCollection.all(value); }); @@ -148,10 +148,7 @@ describe('annotation handle types are preserved through the lane', () => { ): As { return annotations; } - const tuple = passthrough( - cacheAnnotation.apply({ ttl: 60 }), - otelAnnotation.apply({ traceId: 't' }), - ); + const tuple = passthrough(cacheAnnotation({ ttl: 60 }), otelAnnotation({ traceId: 't' })); expectTypeOf(tuple).toMatchTypeOf< readonly [ AnnotationValue<{ ttl: number; skip?: boolean }, 'read'>, @@ -185,14 +182,14 @@ describe('Collection.create (write-typed)', () => { test('accepts a write-only annotation', () => { userCollection.create( { id: 1, name: 'Alice', email: 'a@b.com' }, - auditAnnotation.apply({ actor: 'system' }), + auditAnnotation({ actor: 'system' }), ); }); test('accepts a both-kind annotation', () => { userCollection.create( { id: 1, name: 'Alice', email: 'a@b.com' }, - otelAnnotation.apply({ traceId: 't' }), + otelAnnotation({ traceId: 't' }), ); }); @@ -204,20 +201,20 @@ describe('Collection.create (write-typed)', () => { userCollection.create( { id: 1, name: 'Alice', email: 'a@b.com' }, // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' - cacheAnnotation.apply({ ttl: 60 }), + cacheAnnotation({ ttl: 60 }), ); }); test('rejects a mix containing a read-only annotation (negative)', () => { // biome-ignore format: keep on one line so @ts-expect-error attaches to the call // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' - userCollection.create({ id: 1, name: 'Alice', email: 'a@b.com' }, auditAnnotation.apply({ actor: 'system' }), cacheAnnotation.apply({ ttl: 60 })); + userCollection.create({ id: 1, name: 'Alice', email: 'a@b.com' }, auditAnnotation({ actor: 'system' }), cacheAnnotation({ ttl: 60 })); }); test('the return type is Promise', () => { const result = userCollection.create( { id: 1, name: 'Alice', email: 'a@b.com' }, - auditAnnotation.apply({ actor: 'system' }), + auditAnnotation({ actor: 'system' }), ); expectTypeOf(result).resolves.toMatchTypeOf>(); }); @@ -227,7 +224,7 @@ describe('Collection.createAll (write-typed)', () => { test('accepts a write-only annotation', () => { userCollection.createAll( [{ id: 1, name: 'Alice', email: 'a@b.com' }], - auditAnnotation.apply({ actor: 'system' }), + auditAnnotation({ actor: 'system' }), ); }); @@ -239,7 +236,7 @@ describe('Collection.createAll (write-typed)', () => { userCollection.createAll( [{ id: 1, name: 'Alice', email: 'a@b.com' }], // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' - cacheAnnotation.apply({ ttl: 60 }), + cacheAnnotation({ ttl: 60 }), ); }); }); @@ -248,7 +245,7 @@ describe('Collection.createCount (write-typed)', () => { test('accepts a write-only annotation', () => { userCollection.createCount( [{ id: 1, name: 'Alice', email: 'a@b.com' }], - auditAnnotation.apply({ actor: 'system' }), + auditAnnotation({ actor: 'system' }), ); }); @@ -256,14 +253,14 @@ describe('Collection.createCount (write-typed)', () => { userCollection.createCount( [{ id: 1, name: 'Alice', email: 'a@b.com' }], // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' - cacheAnnotation.apply({ ttl: 60 }), + cacheAnnotation({ ttl: 60 }), ); }); test('the return type is Promise', () => { const result = userCollection.createCount( [{ id: 1, name: 'Alice', email: 'a@b.com' }], - auditAnnotation.apply({ actor: 'system' }), + auditAnnotation({ actor: 'system' }), ); expectTypeOf(result).resolves.toBeNumber(); }); @@ -277,7 +274,7 @@ describe('Collection.upsert (write-typed)', () => { update: { name: 'Alice' }, conflictOn: { id: 1 }, }, - auditAnnotation.apply({ actor: 'system' }), + auditAnnotation({ actor: 'system' }), ); }); @@ -289,7 +286,7 @@ describe('Collection.upsert (write-typed)', () => { conflictOn: { id: 1 }, }, // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' - cacheAnnotation.apply({ ttl: 60 }), + cacheAnnotation({ ttl: 60 }), ); }); }); @@ -299,51 +296,45 @@ describe('Collection.update / .updateAll / .updateCount (write-typed)', () => { // `State['hasWhere'] extends true` gate, so we use a separately- // declared `userCollectionWithWhere` whose State is post-where. test('update accepts a write-only annotation', () => { - userCollectionWithWhere.update({ name: 'Alice' }, auditAnnotation.apply({ actor: 'system' })); + userCollectionWithWhere.update({ name: 'Alice' }, auditAnnotation({ actor: 'system' })); }); test('update rejects a read-only annotation (negative)', () => { userCollectionWithWhere.update( { name: 'Alice' }, // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' - cacheAnnotation.apply({ ttl: 60 }), + cacheAnnotation({ ttl: 60 }), ); }); test('updateAll accepts a write-only annotation', () => { - userCollectionWithWhere.updateAll( - { name: 'Alice' }, - auditAnnotation.apply({ actor: 'system' }), - ); + userCollectionWithWhere.updateAll({ name: 'Alice' }, auditAnnotation({ actor: 'system' })); }); test('updateAll rejects a read-only annotation (negative)', () => { userCollectionWithWhere.updateAll( { name: 'Alice' }, // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' - cacheAnnotation.apply({ ttl: 60 }), + cacheAnnotation({ ttl: 60 }), ); }); test('updateCount accepts a write-only annotation', () => { - userCollectionWithWhere.updateCount( - { name: 'Alice' }, - auditAnnotation.apply({ actor: 'system' }), - ); + userCollectionWithWhere.updateCount({ name: 'Alice' }, auditAnnotation({ actor: 'system' })); }); test('updateCount rejects a read-only annotation (negative)', () => { userCollectionWithWhere.updateCount( { name: 'Alice' }, // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' - cacheAnnotation.apply({ ttl: 60 }), + cacheAnnotation({ ttl: 60 }), ); }); test('updateCount returns Promise', () => { const result = userCollectionWithWhere.updateCount( { name: 'Alice' }, - auditAnnotation.apply({ actor: 'system' }), + auditAnnotation({ actor: 'system' }), ); expectTypeOf(result).resolves.toBeNumber(); }); @@ -351,30 +342,30 @@ describe('Collection.update / .updateAll / .updateCount (write-typed)', () => { describe('Collection.delete / .deleteAll / .deleteCount (write-typed)', () => { test('delete accepts a write-only annotation', () => { - userCollectionWithWhere.delete(auditAnnotation.apply({ actor: 'system' })); + userCollectionWithWhere.delete(auditAnnotation({ actor: 'system' })); }); test('delete rejects a read-only annotation (negative)', () => { // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' - userCollectionWithWhere.delete(cacheAnnotation.apply({ ttl: 60 })); + userCollectionWithWhere.delete(cacheAnnotation({ ttl: 60 })); }); test('deleteAll accepts a write-only annotation', () => { - userCollectionWithWhere.deleteAll(auditAnnotation.apply({ actor: 'system' })); + userCollectionWithWhere.deleteAll(auditAnnotation({ actor: 'system' })); }); test('deleteAll rejects a read-only annotation (negative)', () => { // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' - userCollectionWithWhere.deleteAll(cacheAnnotation.apply({ ttl: 60 })); + userCollectionWithWhere.deleteAll(cacheAnnotation({ ttl: 60 })); }); test('deleteCount accepts a write-only annotation', () => { - userCollectionWithWhere.deleteCount(auditAnnotation.apply({ actor: 'system' })); + userCollectionWithWhere.deleteCount(auditAnnotation({ actor: 'system' })); }); test('deleteCount rejects a read-only annotation (negative)', () => { // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' - userCollectionWithWhere.deleteCount(cacheAnnotation.apply({ ttl: 60 })); + userCollectionWithWhere.deleteCount(cacheAnnotation({ ttl: 60 })); }); }); @@ -391,14 +382,14 @@ describe('Collection.aggregate (read-typed)', () => { test('accepts a read-only annotation', () => { userCollection.aggregate( (aggregate) => ({ count: aggregate.count() }), - cacheAnnotation.apply({ ttl: 60 }), + cacheAnnotation({ ttl: 60 }), ); }); test('accepts a both-kind annotation', () => { userCollection.aggregate( (aggregate) => ({ count: aggregate.count() }), - otelAnnotation.apply({ traceId: 't' }), + otelAnnotation({ traceId: 't' }), ); }); @@ -410,20 +401,20 @@ describe('Collection.aggregate (read-typed)', () => { userCollection.aggregate( (aggregate) => ({ count: aggregate.count() }), // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' - auditAnnotation.apply({ actor: 'system' }), + auditAnnotation({ actor: 'system' }), ); }); test('rejects a mix containing a write-only annotation (negative)', () => { // biome-ignore format: keep on one line so @ts-expect-error attaches to the call // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' - userCollection.aggregate((aggregate) => ({ count: aggregate.count() }), cacheAnnotation.apply({ ttl: 60 }), auditAnnotation.apply({ actor: 'system' })); + userCollection.aggregate((aggregate) => ({ count: aggregate.count() }), cacheAnnotation({ ttl: 60 }), auditAnnotation({ actor: 'system' })); }); test('the aggregation spec type is preserved through the gate', () => { const result = userCollection.aggregate( (aggregate) => ({ count: aggregate.count() }), - cacheAnnotation.apply({ ttl: 60 }), + cacheAnnotation({ ttl: 60 }), ); expectTypeOf(result).resolves.toMatchTypeOf<{ count: number }>(); }); @@ -435,14 +426,14 @@ describe('GroupedCollection.aggregate (read-typed)', () => { test('accepts a read-only annotation', () => { userGroupedCollection.aggregate( (aggregate) => ({ count: aggregate.count() }), - cacheAnnotation.apply({ ttl: 60 }), + cacheAnnotation({ ttl: 60 }), ); }); test('accepts a both-kind annotation', () => { userGroupedCollection.aggregate( (aggregate) => ({ count: aggregate.count() }), - otelAnnotation.apply({ traceId: 't' }), + otelAnnotation({ traceId: 't' }), ); }); @@ -454,7 +445,7 @@ describe('GroupedCollection.aggregate (read-typed)', () => { userGroupedCollection.aggregate( (aggregate) => ({ count: aggregate.count() }), // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' - auditAnnotation.apply({ actor: 'system' }), + auditAnnotation({ actor: 'system' }), ); }); }); diff --git a/test/integration/test/cross-package/middleware-cache.test.ts b/test/integration/test/cross-package/middleware-cache.test.ts index 666679d7e3..c4cd72dca3 100644 --- a/test/integration/test/cross-package/middleware-cache.test.ts +++ b/test/integration/test/cross-package/middleware-cache.test.ts @@ -211,7 +211,7 @@ describe( const buildPlan = () => db.users .select('id', 'name') - .annotate(cacheAnnotation.apply({ ttl: 60_000 })) + .annotate(cacheAnnotation({ ttl: 60_000 })) .build(); driverExecuteSpy.mockClear(); @@ -254,7 +254,7 @@ describe( const buildPlan = () => db.users .select('id') - .annotate(cacheAnnotation.apply({ ttl: 60_000, skip: true })) + .annotate(cacheAnnotation({ ttl: 60_000, skip: true })) .build(); driverExecuteSpy.mockClear(); @@ -281,7 +281,7 @@ describe( const buildPlan = () => db.users .select('id', 'name') - .annotate(cacheAnnotation.apply({ ttl: 60_000 })) + .annotate(cacheAnnotation({ ttl: 60_000 })) .build(); driverExecuteSpy.mockClear(); @@ -319,7 +319,7 @@ describe( const buildPlan = () => db.users .select('id') - .annotate(cacheAnnotation.apply({ ttl: 60_000 })) + .annotate(cacheAnnotation({ ttl: 60_000 })) .build(); driverExecuteSpy.mockClear(); @@ -352,7 +352,7 @@ describe( const buildPlan = () => db.users .select('id') - .annotate(cacheAnnotation.apply({ ttl: 60_000 })) + .annotate(cacheAnnotation({ ttl: 60_000 })) .build(); driverExecuteSpy.mockClear(); @@ -393,7 +393,7 @@ describe( const buildPlan = () => db.users .select('id', 'name') - .annotate(cacheAnnotation.apply({ ttl: 60_000 })) + .annotate(cacheAnnotation({ ttl: 60_000 })) .build(); // Miss → commit. @@ -419,7 +419,7 @@ describe( const buildPlan = () => db.users .select('id', 'name') - .annotate(cacheAnnotation.apply({ ttl: 60_000, key: 'concurrency-test' })) + .annotate(cacheAnnotation({ ttl: 60_000, key: 'concurrency-test' })) .build(); driverExecuteSpy.mockClear(); @@ -453,11 +453,11 @@ describe( const planA = db.users .select('id') - .annotate(cacheAnnotation.apply({ ttl: 60_000, key: 'parallel-A' })) + .annotate(cacheAnnotation({ ttl: 60_000, key: 'parallel-A' })) .build(); const planB = db.posts .select('id', 'title') - .annotate(cacheAnnotation.apply({ ttl: 60_000, key: 'parallel-B' })) + .annotate(cacheAnnotation({ ttl: 60_000, key: 'parallel-B' })) .build(); const [a, b] = await Promise.all([ @@ -475,7 +475,7 @@ describe( .execute( db.users .select('id') - .annotate(cacheAnnotation.apply({ ttl: 60_000, key: 'parallel-A' })) + .annotate(cacheAnnotation({ ttl: 60_000, key: 'parallel-A' })) .build(), ) .toArray(); @@ -483,7 +483,7 @@ describe( .execute( db.posts .select('id', 'title') - .annotate(cacheAnnotation.apply({ ttl: 60_000, key: 'parallel-B' })) + .annotate(cacheAnnotation({ ttl: 60_000, key: 'parallel-B' })) .build(), ) .toArray(); From 7a713ed3260da30a68ece44c59f7a52d9a2d355b Mon Sep 17 00:00:00 2001 From: Alexey Orlenko Date: Wed, 6 May 2026 15:14:53 +0200 Subject: [PATCH 20/21] docs: revisit the annotation api --- .../api-revision-meta-callback.md | 241 ++++++++++++++++++ .../follow-ups.md | 2 + .../middleware-intercept-and-cache/spec.md | 4 +- 3 files changed, 245 insertions(+), 2 deletions(-) create mode 100644 projects/middleware-intercept-and-cache/api-revision-meta-callback.md diff --git a/projects/middleware-intercept-and-cache/api-revision-meta-callback.md b/projects/middleware-intercept-and-cache/api-revision-meta-callback.md new file mode 100644 index 0000000000..f6cd51ef4f --- /dev/null +++ b/projects/middleware-intercept-and-cache/api-revision-meta-callback.md @@ -0,0 +1,241 @@ +# API revision: ORM terminal annotations as a meta-callback + +**Status:** planned. Supersedes the variadic `...annotations` shape on ORM terminals shipped in M2 and pinned by `spec.md` Functional Requirement #6. + +**Scope:** ORM `Collection` and `GroupedCollection` terminals only. SQL DSL `.annotate(...)` is unaffected (see "Why SQL DSL is out of scope" below). + +## Summary + +Replace the variadic `...annotations: As & ValidAnnotations` last argument on every ORM terminal with a single optional callback whose parameter is a typed `MetaBuilder`: + +```typescript +// Before (shipped in M2) +await db.orm.User.all(cacheAnnotation({ ttl })); +await db.orm.User.where({ id }).find(cacheAnnotation({ ttl })); +await db.orm.User.find({ id }, cacheAnnotation({ ttl })); + +// After +await db.orm.User.all((meta) => meta.annotate(cacheAnnotation({ ttl }))); +await db.orm.User.where({ id }).find((meta) => meta.annotate(cacheAnnotation({ ttl }))); +await db.orm.User.find({ id }, (meta) => meta.annotate(cacheAnnotation({ ttl }))); +``` + +The callback receives a `MetaBuilder` whose `annotate(annotation)` method takes one annotation, validates it eagerly against the terminal's operation kind `K`, records it, and returns the builder for chaining. + +## Why + +**The variadic forecloses on growth.** A terminal whose last positional argument is a variadic of annotations cannot grow new positional or named per-query options without a breaking change. There is no future shape `db.orm.User.find(input, { … }, ann1, ann2)` we can evolve into without churning every call site, and the variadic-tuple inference rules make alternatives like `find(input, { annotations: [ann1, ann2], timeout: 5_000 })` lose the applicability gate that the `As & ValidAnnotations` intersection currently enforces. The callback shape sidesteps both: per-query options become methods on `MetaBuilder`, and adding a method (`meta.timeout(...)`, `meta.tag(...)`, `meta.cancellation(signal)`) is a non-breaking surface extension. + +**The callback drops a load-bearing TypeScript trick.** Today every variadic terminal carries the `As & ValidAnnotations<'read', As>` intersection (see `ValidAnnotations` TSDoc and `follow-ups.md` Open Items). The intersection exists because TypeScript's variadic-tuple inference is too forgiving: without it, an inapplicable annotation would silently typecheck. The callback hands one annotation at a time to `meta.annotate(...)`, which uses an ordinary conditional constraint — no variadic-tuple inference involved, no intersection, no documented "load-bearing trick". + +**The callback removes the `isAnnotationValue` discriminator inside `first`.** The shipped `first(filterOrFirstAnnotation, ...rest)` runtime branches on `isAnnotationValue(filterOrFirstAnnotation)` to decide whether the first positional argument is a filter or an annotation (collection.ts:686). Once annotations move into the callback, `first(filter?, configure?)` is unambiguous: positional 1 is the filter, positional 2 is the configurator. The dispatch shrinks to a few lines. + +**The callback aligns with the rest of the ORM client's mental model.** `.where((model) => …)`, `.include((collection) => …)`, `.aggregate((agg) => …)`, `.having((having) => …)` already use callback configurators. A meta-configurator slots into the same mental model — "the optional last argument is a function that receives a typed builder." + +**A note on what "terminal variadic" means.** The user-facing concern is specifically variadics on terminal methods, where the next argument we want to add is a per-call option. Chainable builder methods like SQL DSL `.annotate(...)` are not terminals — they return a new builder that participates in further chaining — and growth of *that* surface happens via additional methods on the builder, not via additional arguments on `.annotate()` itself. SQL DSL stays as it is. + +## The shape in detail + +### `MetaBuilder` + +```typescript +// packages/1-framework/1-core/framework-components/src/meta-builder.ts (new) +import type { AnnotationValue, OperationKind } from './annotations'; + +/** + * Per-terminal meta configurator. The terminal's operation kind `K` is fixed + * by the terminal that constructed the builder; `annotate` accepts any + * annotation whose declared `Kinds` includes `K`. + * + * The conditional `K extends Kinds ? AnnotationValue : never` + * collapses the parameter type to `never` for inapplicable annotations, + * surfacing the mismatch as a type error at the call site of `meta.annotate`. + * No variadic-tuple inference is involved — TypeScript infers `Kinds` from + * the annotation argument, then checks the conditional. + */ +export interface MetaBuilder { + annotate( + annotation: K extends Kinds ? AnnotationValue : never, + ): this; +} +``` + +The reference implementation is a small class (or factory) that holds the terminal's `kind`, the `terminalName` for error messages, and a `Map>` of recorded annotations. + +`annotate(...)` validates eagerly via `assertAnnotationsApplicable([annotation], this.kind, this.terminalName)` so cast-bypass cases (`as any`) throw `RUNTIME.ANNOTATION_INAPPLICABLE` at the configurator call site rather than later in the terminal. The terminal then reads `meta.annotations` (the recorded `Map`) and threads it into the existing plumbing. + +### Terminal signatures (read) + +```typescript +// Before +all[]>( + ...annotations: As & ValidAnnotations<'read', As> +): AsyncIterableResult; + +// After +all(configure?: (meta: MetaBuilder<'read'>) => void): AsyncIterableResult; +``` + +```typescript +// Before — three overloads with variadic annotations + a runtime branch on isAnnotationValue +async first(): Promise; +async first(filter: (model: ModelAccessor<…>) => WhereArg): Promise; +async first(filter: ShorthandWhereFilter<…>): Promise; +async first(...annotations: As & ValidAnnotations<'read', As>): Promise; +async first(filter: (model: …) => WhereArg, ...annotations: As & ValidAnnotations<'read', As>): Promise; +async first(filter: ShorthandWhereFilter<…>, ...annotations: As & ValidAnnotations<'read', As>): Promise; + +// After — two overloads, no runtime ambiguity +async first(configure?: (meta: MetaBuilder<'read'>) => void): Promise; +async first( + filter: ((model: ModelAccessor<…>) => WhereArg) | ShorthandWhereFilter<…>, + configure?: (meta: MetaBuilder<'read'>) => void, +): Promise; +``` + +`aggregate(fn, configure?)` follows the same pattern: existing builder callback first, optional meta configurator second. + +### Terminal signatures (write) + +```typescript +// Before +async create( + data: ResolvedCreateInput<…>, + ...annotations: As & ValidAnnotations<'write', As> +): Promise; + +// After +async create( + data: ResolvedCreateInput<…>, + configure?: (meta: MetaBuilder<'write'>) => void, +): Promise; +``` + +Same shape for `createAll`, `createCount`, `update`, `updateAll`, `updateCount`, `delete`, `deleteAll`, `deleteCount`, `upsert`. Each terminal pins the kind to `'write'` when constructing its `MetaBuilder`. + +`delete` / `deleteAll` / `deleteCount` (which take only `this`-typed receiver guards today) gain a single optional `configure` argument: + +```typescript +async delete( + this: State['hasWhere'] extends true ? Collection<…> : never, + configure?: (meta: MetaBuilder<'write'>) => void, +): Promise; +``` + +### `GroupedCollection.aggregate` + +```typescript +// Before +async aggregate( + fn: (aggregate: AggregateBuilder<…>) => Spec, + ...annotations: As & ValidAnnotations<'read', As> +): Promise<…>; + +// After +async aggregate( + fn: (aggregate: AggregateBuilder<…>) => Spec, + configure?: (meta: MetaBuilder<'read'>) => void, +): Promise<…>; +``` + +### Multiple annotations per call + +Chainable, since `annotate` returns `this`: + +```typescript +await db.orm.User.find({ id }, (meta) => + meta + .annotate(cacheAnnotation({ ttl })) + .annotate(otelAnnotation({ traceId })), +); +``` + +Or block form, since the callback's return value is unused: + +```typescript +await db.orm.User.find({ id }, (meta) => { + meta.annotate(cacheAnnotation({ ttl })); + meta.annotate(otelAnnotation({ traceId })); +}); +``` + +Last-write-wins on duplicate namespaces, matching the existing semantics. + +### Reading the annotations inside the terminal + +The state-driven path (`all`, `first`) folds the meta builder's recorded map into `state.userAnnotations` at the terminal boundary, replacing the existing `#withAnnotations(annotations, 'read', 'all')` array entry point with a `#withAnnotationsFromMeta(meta)` map entry point. `compileSelect` continues to receive `state.userAnnotations` unchanged. + +The post-wrap path (`aggregate`, all writes) calls `mergeUserAnnotations(compiled, meta.annotations)` directly with the meta builder's recorded map — the function already accepts a `ReadonlyMap>`, so the call-site shape simplifies. + +In both paths the runtime applicability check has already fired inside `meta.annotate(...)`. The terminal does not need to call `assertAnnotationsApplicable` again. + +## What stays the same + +- `defineAnnotation`, `AnnotationValue`, `AnnotationHandle`, `OperationKind`, `assertAnnotationsApplicable` — unchanged. +- SQL DSL `.annotate(...)` — unchanged. +- Annotation storage on `plan.meta.annotations[namespace]` — unchanged. +- `mergeUserAnnotations` and `buildOrmQueryPlan` — unchanged (their input signatures already accept a `ReadonlyMap`). +- Cache middleware behavior, contract semantics, error envelope codes — unchanged. +- Reserved namespaces, plan-identity invariant, transaction-scope guard — unchanged. + +## What goes away + +- `ValidAnnotations` — kept exported for back-compat consumers, but the framework itself no longer relies on it. The variadic-tuple inference workaround (the `As & ValidAnnotations` intersection) is no longer load-bearing on any first-party surface; the documentation can stop calling it out as a trap. +- The `first` runtime branch on `isAnnotationValue(filterOrFirstAnnotation)` — gone (the configurator's function-type identity makes the dispatch unambiguous). +- The `#withAnnotations(annotations, kind, terminal)` array entry point and the `#buildAnnotationsMap(annotations, kind, terminal)` array entry point — replaced by `MetaBuilder`-aware variants that consume the recorded map directly. + +## Why SQL DSL is out of scope + +`SelectQueryImpl.annotate(...)`, `InsertQueryImpl.annotate(...)`, etc. are chainable builder methods on intermediate builders, not terminals. The user-facing concern — that a variadic on a terminal blocks future per-call options — does not apply to a builder method whose host returns a new builder. The natural growth path on the SQL DSL is additional builder methods (`.cache(...)`, `.tag(...)`) that compose with `.annotate(...)` rather than additional positional arguments to `.annotate(...)` itself. Leaving the SQL DSL chainable form alone also keeps the SQL DSL's annotation surface mechanically the same as it is today — only the ORM client's terminal surface changes. + +If a follow-up wants to align the SQL DSL with the ORM client's "single annotation per call, chain for more" idiom, that can land separately. It does not block this revision. + +## Net effect on the codebase + +Mostly localized to the ORM client and a small new module in framework-components. + +1. **`packages/1-framework/1-core/framework-components/src/meta-builder.ts` (new).** `MetaBuilder` interface and a `createMetaBuilder(kind, terminalName)` factory. Eager `assertAnnotationsApplicable` inside `annotate`. Exported from `exports/runtime.ts`. + +2. **`packages/3-extensions/sql-orm-client/src/collection.ts`.** Each terminal's signature swaps `...annotations: As & ValidAnnotations` for `configure?: (meta: MetaBuilder) => void`. The terminal constructs a `MetaBuilder`, invokes the configurator (no-op if absent), and threads `meta.annotations` into the existing plumbing. The `first` overload set collapses from six to two; the runtime branch on `isAnnotationValue` is removed. `#withAnnotations` and `#buildAnnotationsMap` are replaced by `MetaBuilder`-shaped equivalents. + +3. **`packages/3-extensions/sql-orm-client/src/grouped-collection.ts`.** Mirror change for `aggregate`. + +4. **`packages/3-extensions/sql-orm-client/test/`.** Existing unit and type tests — every site that called `db.User.first({ id }, cacheAnnotation({ ttl }))` becomes `db.User.first({ id }, (meta) => meta.annotate(cacheAnnotation({ ttl })))`. The negative type tests (write-only annotation on read terminal, read-only on write terminal) move from variadic-position negatives to `meta.annotate(...)` argument-position negatives. Cast-bypass runtime tests target `meta.annotate(annotation as any)` and assert the same `RUNTIME.ANNOTATION_INAPPLICABLE` envelope. + +5. **`packages/3-extensions/middleware-cache/test/` and `examples/prisma-next-demo/`.** Mechanical: every annotated ORM call site gains a `(meta) => meta.annotate(...)` wrapper. + +6. **`docs/architecture docs/subsystems/4. Runtime & Middleware Framework.md` "Lane integration" section.** Update the ORM `Collection` bullet from "variadic argument with the same gated shape" to "optional last argument `configure: (meta: MetaBuilder) => void`". Update the storage paragraph's "Multiple `.annotate()` calls or terminal arguments compose" to "Multiple `meta.annotate(...)` calls compose". + +7. **`packages/3-extensions/middleware-cache/README.md`.** Update the quick-start example and the "Opt-in by annotation" examples. + +The framework-components annotation module (`annotations.ts`) does **not** change — `defineAnnotation`, `AnnotationValue`, `assertAnnotationsApplicable`, `ValidAnnotations` all stay. The runtime gate keeps working unchanged. + +## Acceptance criteria + +- [ ] `MetaBuilder` is exported from `@prisma-next/framework-components/runtime`. Type tests cover: `meta.annotate(readApplicable)` typechecks for `K = 'read'`; `meta.annotate(writeOnly)` does not for `K = 'read'`; mirror image for `K = 'write'`; a `'read' | 'write'` annotation works on both. Type test asserts `meta.annotate` returns `this` (for chaining). +- [ ] `MetaBuilder.annotate` validates eagerly via `assertAnnotationsApplicable` and throws `RUNTIME.ANNOTATION_INAPPLICABLE` on cast-bypass (unit test, both kinds). +- [ ] Every ORM read terminal accepts an optional `configure: (meta: MetaBuilder<'read'>) => void` last argument and threads `meta.annotations` into `plan.meta.annotations` (integration test, one per terminal). +- [ ] Every ORM write terminal accepts an optional `configure: (meta: MetaBuilder<'write'>) => void` last argument and threads `meta.annotations` into the compiled mutation plan(s) via `mergeUserAnnotations` (integration test, one per terminal). +- [ ] Multiple `meta.annotate(...)` calls compose; duplicate namespace = last-write-wins (unit test). +- [ ] Negative type tests: passing a write-only annotation through `meta.annotate(...)` on a read terminal's configurator fails to compile; mirror image. The test set covers the same matrix the variadic form covers today. +- [ ] `Collection.annotate` does not exist as a method (regression — chainable form was never added; this acceptance carries forward). +- [ ] The runtime `first` overload set collapses to two signatures and the `isAnnotationValue(filterOrFirstAnnotation)` branch is removed (unit test asserts both overloads dispatch correctly: `first(configure)` and `first(filter, configure)`). +- [ ] `pnpm test:packages` passes; `pnpm typecheck` passes; `pnpm lint:deps` clean. +- [ ] Cache middleware's stop-condition integration test (`test/integration/test/cross-package/middleware-cache.test.ts`) passes against the new call shape with the annotated ORM read. +- [ ] Demo (`examples/prisma-next-demo/`) updated to use the new shape; run remains green. + +## Sequencing + +Implement after the cache project's M2 merges (the variadic shape ships first, this revision replaces it). Land as a single PR scoped to: +- the new `meta-builder.ts` module, +- the ORM client signature swap, +- the test file rewrites, +- the subsystem doc + middleware-cache README updates. + +Architecturally independent of M3 (cache middleware) and the deferred follow-ups (drop `Row` generic; thread annotations into nested-mutation / MTI). Can ship before or after either. + +## Open questions + +- **Configurator naming.** `meta` matches `plan.meta.annotations` storage and reads naturally at the call site. Alternatives considered: `q` (too short), `query` (overlaps with existing `query`-named identifiers), `options` (suggests a record literal, not a function). Lean: `meta`. Resolve before signature lock-in. +- **Should the configurator be allowed to return a value?** Today the recorded annotations are read off the builder regardless of return value. We could keep the return type `void` for clarity, or `void | undefined | unknown` for tolerance of arrow expressions like `(meta) => meta.annotate(…)` that return the builder. Lean: type the parameter as `(meta: MetaBuilder) => void` and rely on TypeScript's "return value is ignored when the parameter return type is `void`" rule, which makes both block-body and expression-body callbacks compile. Validate with a type test. +- **`ValidAnnotations` deprecation.** Keep exported for now (an external annotation library or contributor middleware may depend on it); revisit deprecation when the broader middleware API redesign lands in May. Not a blocker for this revision. diff --git a/projects/middleware-intercept-and-cache/follow-ups.md b/projects/middleware-intercept-and-cache/follow-ups.md index 3c6316f0e6..bc83e5f5fc 100644 --- a/projects/middleware-intercept-and-cache/follow-ups.md +++ b/projects/middleware-intercept-and-cache/follow-ups.md @@ -90,6 +90,8 @@ Land after TML-2143 M1 merges. Doesn't block M2 or M3. ### What landed in M2 +> **Status update.** The variadic shape described below shipped in M2 and is the current behavior. It is being replaced by a meta-callback configurator (`(meta) => meta.annotate(...)`); see `api-revision-meta-callback.md` for the delta spec. The threading paths described here (state-driven for `all`/`first`, post-wrap for aggregates and writes) carry over unchanged — only the call-site shape and the per-terminal entry-point methods change. + The variadic annotation argument is now available on every user-facing query-issuing terminal of `Collection` and `GroupedCollection`: - **Read terminals (state-driven):** `all`, `first`. Annotations flow via `state.userAnnotations`, which `compileSelect` and `compileSelectWithIncludeStrategy` thread to `buildOrmQueryPlan`. diff --git a/projects/middleware-intercept-and-cache/spec.md b/projects/middleware-intercept-and-cache/spec.md index 14a6d296f2..bbfdd54f5e 100644 --- a/projects/middleware-intercept-and-cache/spec.md +++ b/projects/middleware-intercept-and-cache/spec.md @@ -162,7 +162,7 @@ const c = await db.orm.User.all() // always hits DB 5. **SQL DSL `.annotate()`.** Chainable builder method, typed per builder kind. `SelectQueryImpl` / `GroupedQueryImpl` (in `packages/2-sql/4-lanes/sql-builder/src/runtime/query-impl.ts`) accept `ValidAnnotations<'read', As>`; `InsertQueryImpl` / `UpdateQueryImpl` / `DeleteQueryImpl` (in `mutation-impl.ts`) accept `ValidAnnotations<'write', As>`. Annotations merge into `plan.meta.annotations` at `.build()` time. Multiple `.annotate()` calls compose; duplicate namespaces use last-write-wins. -6. **ORM terminal-argument annotations.** Each terminal method on `Collection` (`packages/3-extensions/sql-orm-client/src/collection.ts`) accepts a variadic last argument `...annotations: ValidAnnotations` where `K` is the terminal's operation kind. Read terminals (`first`, `find`, `all`, `take().all`, `count`, aggregate methods, `get`, `findMany`-equivalents): `K = 'read'`. Write terminals (`create`, `update`, `delete`, `upsert`, and any in-place mutation entry points): `K = 'write'`. There is **no** chainable `.annotate()` on `Collection`; this is an intentional scope cut from earlier drafts (it would have required a separate mutation-classifier in the cache middleware). +6. **ORM terminal-argument annotations.** Each terminal method on `Collection` (`packages/3-extensions/sql-orm-client/src/collection.ts`) accepts a variadic last argument `...annotations: ValidAnnotations` where `K` is the terminal's operation kind. Read terminals (`first`, `find`, `all`, `take().all`, `count`, aggregate methods, `get`, `findMany`-equivalents): `K = 'read'`. Write terminals (`create`, `update`, `delete`, `upsert`, and any in-place mutation entry points): `K = 'write'`. There is **no** chainable `.annotate()` on `Collection`; this is an intentional scope cut from earlier drafts (it would have required a separate mutation-classifier in the cache middleware). *(Superseded post-M2 by `api-revision-meta-callback.md`: the variadic last argument is replaced by an optional `configure: (meta: MetaBuilder) => void` callback. The applicability gate `K` and its semantics carry over; see the revision spec for the call-site shape and the rationale.)* 7. **Runtime applicability check at the lane.** Each lane terminal walks its variadic `annotations` array and rejects any whose `applicableTo` set does not include the terminal's operation kind, throwing a clear `runtimeError` (e.g. `RUNTIME.ANNOTATION_INAPPLICABLE`) naming the annotation namespace and the terminal. The runtime check is belt-and-suspenders: the type system fails closed for type-aware callers, and the runtime check fails closed for casts / `any` / dynamic invocations. @@ -215,7 +215,7 @@ const c = await db.orm.User.all() // always hits DB - **`ctx.state` per-query scratch space.** Not needed for the cache middleware; not added opportunistically. Middleware that need correlation use per-instance `WeakMap` keyed on plan identity. - **Middleware ordering metadata (`dependsOn`, `conflictsWith`).** Registration order remains the sole source of truth. - **Cache invalidation strategies beyond TTL and storage-hash keying.** No event-based invalidation, no tag-based invalidation, no `db.orm.User.invalidate()`. Deferred to May. -- **Chainable ORM `.annotate()`.** Annotations attach via the variadic last argument on terminal methods only. The original draft proposed a chainable `Collection.annotate()`; that was dropped because it forced an in-middleware mutation guard and fought the applicability-gate design. May reconsider if a real ergonomic problem surfaces. +- **Chainable ORM `.annotate()`.** Annotations attach via the variadic last argument on terminal methods only. The original draft proposed a chainable `Collection.annotate()`; that was dropped because it forced an in-middleware mutation guard and fought the applicability-gate design. May reconsider if a real ergonomic problem surfaces. *(Update: a real ergonomic problem did surface — variadic-on-terminal forecloses on adding any future per-call options. The replacement is not a chainable `Collection.annotate()` but a meta-callback configurator: `db.orm.User.find({ id }, (meta) => meta.annotate(cacheAnnotation({ ttl })))`. See `api-revision-meta-callback.md` for the delta spec. The "no chainable on `Collection`" cut still holds — the chainable would have lived on `Collection`; the configurator lives on a `MetaBuilder` constructed by the terminal.)* - **Finer-grained `OperationKind`.** `'read' | 'write'` for April. No `'select' | 'insert' | 'update' | 'delete' | 'upsert'`, no per-aggregate distinctions. Widening is additive — handles already accept a `Kinds` set, so a future split keeps existing handles compiling. - **`contentHash` API surface beyond what the cache middleware needs.** The method returns `Promise` (resolving to a `sha512:HEX128` digest). No structured (`{statement, params}`) shape and no batch variant. Future content-hash-keyed middleware (request coalescing, single-flight) consume the same string. - **Annotation validation at contract-emit time.** Annotations are runtime metadata only; they do not affect the Contract or its hashes. From f99e5b00769b8e598446e419f0c08269e6b26795 Mon Sep 17 00:00:00 2001 From: Alexey Orlenko Date: Wed, 6 May 2026 15:46:18 +0200 Subject: [PATCH 21/21] refactor(sql-orm-client): change the annotation api --- .../4. Runtime & Middleware Framework.md | 10 +- examples/prisma-next-demo/README.md | 4 +- .../src/orm-client/find-user-by-id-cached.ts | 5 +- .../src/orm-client/get-users-cached.ts | 12 +- .../src/exports/runtime.ts | 2 + .../framework-components/src/meta-builder.ts | 99 +++++ .../test/meta-builder.test.ts | 113 ++++++ .../test/meta-builder.types.test-d.ts | 141 +++++++ .../3-extensions/middleware-cache/README.md | 16 +- .../sql-orm-client/src/collection.ts | 307 ++++++--------- .../sql-orm-client/src/grouped-collection.ts | 31 +- .../sql-orm-client/test/annotations.test.ts | 202 +++++----- .../test/annotations.types.test-d.ts | 367 +++++++++--------- .../api-revision-meta-callback.md | 30 +- 14 files changed, 826 insertions(+), 513 deletions(-) create mode 100644 packages/1-framework/1-core/framework-components/src/meta-builder.ts create mode 100644 packages/1-framework/1-core/framework-components/test/meta-builder.test.ts create mode 100644 packages/1-framework/1-core/framework-components/test/meta-builder.types.test-d.ts diff --git a/docs/architecture docs/subsystems/4. Runtime & Middleware Framework.md b/docs/architecture docs/subsystems/4. Runtime & Middleware Framework.md index 342d1aa209..fafa923997 100644 --- a/docs/architecture docs/subsystems/4. Runtime & Middleware Framework.md +++ b/docs/architecture docs/subsystems/4. Runtime & Middleware Framework.md @@ -266,14 +266,14 @@ export const otelAnnotation = defineAnnotation<{ traceId: string }>()({ }) ``` -**Lane integration.** Each lane terminal accepts a variadic `...annotations` parameter constrained by `As & ValidAnnotations` where `K` is the terminal's operation kind: +**Lane integration.** SQL DSL and ORM `Collection` adopt different surface shapes appropriate to their idioms; both gate by the terminal's operation kind `K`: -- SQL DSL — `.annotate(...)` is chainable on every builder kind: `SelectQueryImpl` / `GroupedQueryImpl` accept `'read'`-applicable annotations; `InsertQueryImpl` / `UpdateQueryImpl` / `DeleteQueryImpl` accept `'write'`-applicable. The intersection `As & ValidAnnotations` is load-bearing — TypeScript's variadic-tuple inference is too forgiving with `ValidAnnotations` alone, but the intersection pins `As` to the call-site tuple AND requires assignability to the gated form. -- ORM `Collection` — terminal methods (`first`, `find`, `all`, `count`, aggregates; `create`, `update`, `delete`, `upsert`) accept the variadic argument with the same gated shape. There is no chainable `Collection.annotate()` — annotations attach via the terminal call only. This is an intentional scope cut: chainable form would have forced an in-middleware mutation classifier. +- SQL DSL — `.annotate(...)` is a variadic chainable method on every builder kind: `SelectQueryImpl` / `GroupedQueryImpl` accept `'read'`-applicable annotations; `InsertQueryImpl` / `UpdateQueryImpl` / `DeleteQueryImpl` accept `'write'`-applicable. The variadic parameter is constrained by `As & ValidAnnotations`. The intersection is load-bearing — TypeScript's variadic-tuple inference is too forgiving with `ValidAnnotations` alone, but the intersection pins `As` to the call-site tuple AND requires assignability to the gated form. +- ORM `Collection` and `GroupedCollection` — every terminal accepts an optional `configure: (meta: MetaBuilder) => void` callback as its last argument. The user calls `meta.annotate(annotation)` (chainable, returns the builder) once per annotation; the conditional `K extends Kinds ? AnnotationValue : never` parameter type rejects inapplicable annotations at the call site. There is no chainable `Collection.annotate()` — annotations attach via the configurator only. The callback shape (rather than the variadic shape SQL DSL uses) is deliberate at the ORM layer: a terminal's last argument grows over time (future per-call options), and a callback configurator is extension-friendly where a variadic forecloses on additional arguments. The callback also drops the load-bearing `As & ValidAnnotations` intersection — taking one annotation at a time avoids variadic-tuple inference entirely. -**Runtime applicability check.** Every lane terminal calls `assertAnnotationsApplicable(annotations, kind, terminalName)` before plan construction. The helper iterates the array and, on any annotation whose `applicableTo` set lacks `kind`, throws `RUNTIME.ANNOTATION_INAPPLICABLE` naming the namespace and terminal. This is belt-and-suspenders: the type system fails closed for type-aware callers, and the runtime check fails closed for casts / `any` / dynamic invocations. +**Runtime applicability check.** SQL DSL `.build()` and the ORM `MetaBuilder.annotate(...)` both call `assertAnnotationsApplicable(...)` against the annotation(s) and the terminal's kind. The helper throws `RUNTIME.ANNOTATION_INAPPLICABLE` naming the namespace and terminal on any annotation whose `applicableTo` set lacks `kind`. This is belt-and-suspenders: the type system fails closed for type-aware callers, and the runtime check fails closed for casts / `any` / dynamic invocations. ORM terminals construct the meta builder via `createMetaBuilder(kind, terminalName)` and let the builder's `annotate` perform the gate eagerly; lane code does not re-validate after the user callback returns. -**Storage.** Applied annotations land under `plan.meta.annotations[namespace]` as branded `AnnotationValue` objects. Multiple `.annotate()` calls or terminal arguments compose; duplicate namespaces use last-write-wins. Reading via `handle.read(plan)` defensively checks the `__annotation` brand so framework-internal metadata (e.g. `meta.annotations.codecs` written by the SQL emitter) is not mistaken for a user annotation. +**Storage.** Applied annotations land under `plan.meta.annotations[namespace]` as branded `AnnotationValue` objects. Multiple `.annotate()` chain calls (SQL DSL) or `meta.annotate(...)` calls inside the configurator (ORM) compose; duplicate namespaces use last-write-wins. Reading via `handle.read(plan)` defensively checks the `__annotation` brand so framework-internal metadata (e.g. `meta.annotations.codecs` written by the SQL emitter) is not mistaken for a user annotation. **Reserved namespaces.** `codecs` is consumed by the SQL emitter (`meta.annotations.codecs[alias] = 'pg/text@1'`) and read by the SQL runtime's `decodeRow`; it must not be used by user handles. Target-specific keys such as `pg` are similarly reserved. `defineAnnotation` does not structurally prevent a user from naming a reserved namespace — handles are namespaced strings, not branded types — but the framework makes no compatibility guarantees about handles that do. diff --git a/examples/prisma-next-demo/README.md b/examples/prisma-next-demo/README.md index ae1c1ab812..14b3dd27d5 100644 --- a/examples/prisma-next-demo/README.md +++ b/examples/prisma-next-demo/README.md @@ -153,8 +153,8 @@ Speedup: 26.2x faster The corresponding source files: -- `src/orm-client/find-user-by-id-cached.ts` — `db.User.first({ id }, cacheAnnotation({ ttl }))` -- `src/orm-client/get-users-cached.ts` — `db.User.take(n).all(cacheAnnotation({ ttl, key? }))` +- `src/orm-client/find-user-by-id-cached.ts` — `db.User.first({ id }, (meta) => meta.annotate(cacheAnnotation({ ttl })))` +- `src/orm-client/get-users-cached.ts` — `db.User.take(n).all((meta) => meta.annotate(cacheAnnotation({ ttl, key? })))` - `src/queries/get-users-cached.ts` — `db.sql.user.select(...).annotate(cacheAnnotation({ ttl })).build()` Relevant points: diff --git a/examples/prisma-next-demo/src/orm-client/find-user-by-id-cached.ts b/examples/prisma-next-demo/src/orm-client/find-user-by-id-cached.ts index 04293d7a16..ee70dea1bf 100644 --- a/examples/prisma-next-demo/src/orm-client/find-user-by-id-cached.ts +++ b/examples/prisma-next-demo/src/orm-client/find-user-by-id-cached.ts @@ -59,9 +59,8 @@ export async function ormClientFindUserByIdCached( ) { const db = createOrmClient(runtime); const ttl = options.ttlMs ?? 60_000; - return db.User.first( - { id: toUserId(id) }, - cacheAnnotation({ ttl, skip: options.forceRefresh ?? false }), + return db.User.first({ id: toUserId(id) }, (meta) => + meta.annotate(cacheAnnotation({ ttl, skip: options.forceRefresh ?? false })), ); } diff --git a/examples/prisma-next-demo/src/orm-client/get-users-cached.ts b/examples/prisma-next-demo/src/orm-client/get-users-cached.ts index 367d9788ab..0fb63c8ccb 100644 --- a/examples/prisma-next-demo/src/orm-client/get-users-cached.ts +++ b/examples/prisma-next-demo/src/orm-client/get-users-cached.ts @@ -2,10 +2,10 @@ * Cached `User.all()` listing. * * Companion to `find-user-by-id-cached.ts` — same opt-in caching - * mechanism, this time on a multi-row read terminal. The trailing - * variadic of `.all(...)` accepts read-typed annotations; we pass - * `cacheAnnotation({ ttl })` to enable caching of the - * post-lowering execution. + * mechanism, this time on a multi-row read terminal. The terminal's + * `configure: (meta) => void` callback hands the caller a + * `MetaBuilder<'read'>`; calling `meta.annotate(cacheAnnotation({ ttl }))` + * enables caching of the post-lowering execution. * * The example also shows the per-query `key` override. When set, the * supplied string is used verbatim as the cache key; the cache @@ -38,7 +38,7 @@ export async function ormClientGetUsersCached( ) { const db = createOrmClient(runtime); const ttl = options.ttlMs ?? 60_000; - return db.User.take(limit).all( - cacheAnnotation(options.key !== undefined ? { ttl, key: options.key } : { ttl }), + return db.User.take(limit).all((meta) => + meta.annotate(cacheAnnotation(options.key !== undefined ? { ttl, key: options.key } : { ttl })), ); } diff --git a/packages/1-framework/1-core/framework-components/src/exports/runtime.ts b/packages/1-framework/1-core/framework-components/src/exports/runtime.ts index abf38da10b..54d75ccbc3 100644 --- a/packages/1-framework/1-core/framework-components/src/exports/runtime.ts +++ b/packages/1-framework/1-core/framework-components/src/exports/runtime.ts @@ -6,6 +6,8 @@ export type { ValidAnnotations, } from '../annotations'; export { assertAnnotationsApplicable, defineAnnotation } from '../annotations'; +export type { LaneMetaBuilder, MetaBuilder } from '../meta-builder'; +export { createMetaBuilder } from '../meta-builder'; export { AsyncIterableResult } from '../execution/async-iterable-result'; export type { ExecutionPlan, QueryPlan, ResultType } from '../execution/query-plan'; export { checkAborted, raceAgainstAbort } from '../execution/race-against-abort'; diff --git a/packages/1-framework/1-core/framework-components/src/meta-builder.ts b/packages/1-framework/1-core/framework-components/src/meta-builder.ts new file mode 100644 index 0000000000..389f105c2f --- /dev/null +++ b/packages/1-framework/1-core/framework-components/src/meta-builder.ts @@ -0,0 +1,99 @@ +import { + type AnnotationValue, + assertAnnotationsApplicable, + type OperationKind, +} from './annotations'; + +/** + * Per-terminal meta configurator handed to user callbacks. The terminal's + * operation kind `K` is fixed by the terminal that constructed the builder; + * `annotate(...)` accepts only annotations whose declared `Kinds` include + * `K`. + * + * The conditional parameter type + * `K extends Kinds ? AnnotationValue : never` collapses to `never` + * for inapplicable annotations, surfacing the mismatch as a type error at + * the call site of `meta.annotate(...)`. No variadic-tuple inference is + * involved — TypeScript infers `Kinds` from the annotation argument and + * checks the conditional directly. + * + * The runtime gate inside `annotate` (via + * `assertAnnotationsApplicable`) catches cast / `any` / dynamic bypasses + * and throws `RUNTIME.ANNOTATION_INAPPLICABLE`. + * + * `annotate` returns the builder for chaining; the return value of the + * configurator callback is unused, so both block-body and expression-body + * callbacks compile. + * + * @example + * ```typescript + * await db.User.find({ id }, (meta) => meta.annotate(cacheAnnotation({ ttl: 60 }))); + * await db.User.create(input, (meta) => { + * meta.annotate(auditAnnotation({ actor: 'system' })); + * meta.annotate(otelAnnotation({ traceId })); + * }); + * ``` + */ +export interface MetaBuilder { + annotate( + annotation: K extends Kinds ? AnnotationValue : never, + ): this; +} + +/** + * Lane-side view of a meta builder. Extends the public `MetaBuilder` + * surface with `annotations` so lane terminals can read the recorded map + * after invoking the user configurator. + * + * Lane terminals construct one of these via `createMetaBuilder(kind, terminalName)`, + * pass it to the user callback as `MetaBuilder` (the narrower public + * view), then read `meta.annotations` to thread the recorded values into + * `plan.meta.annotations`. + */ +export interface LaneMetaBuilder extends MetaBuilder { + readonly annotations: ReadonlyMap>; +} + +class MetaBuilderImpl implements LaneMetaBuilder { + readonly #kind: K; + readonly #terminalName: string; + readonly #annotations = new Map>(); + + constructor(kind: K, terminalName: string) { + this.#kind = kind; + this.#terminalName = terminalName; + } + + get annotations(): ReadonlyMap> { + return this.#annotations; + } + + annotate( + annotation: K extends Kinds ? AnnotationValue : never, + ): this { + // The conditional parameter is opaque inside the body; widen to the + // structural shape. Both possible resolutions of the conditional — + // `AnnotationValue` (assignable up via covariance) and + // `never` (assignable to anything) — satisfy the wider type at runtime. + const value = annotation as AnnotationValue; + assertAnnotationsApplicable([value], this.#kind, this.#terminalName); + this.#annotations.set(value.namespace, value); + return this; + } +} + +/** + * Construct a lane-side meta builder for a terminal of operation kind `K`. + * + * Lane terminals call this with their `kind` (`'read'` or `'write'`) and a + * `terminalName` for error messages, hand the resulting builder to the + * user-supplied configurator callback (typed as `MetaBuilder`, the + * narrower public view), and read `meta.annotations` afterwards to thread + * the recorded values into `plan.meta.annotations`. + */ +export function createMetaBuilder( + kind: K, + terminalName: string, +): LaneMetaBuilder { + return new MetaBuilderImpl(kind, terminalName); +} diff --git a/packages/1-framework/1-core/framework-components/test/meta-builder.test.ts b/packages/1-framework/1-core/framework-components/test/meta-builder.test.ts new file mode 100644 index 0000000000..e86b2e3ab0 --- /dev/null +++ b/packages/1-framework/1-core/framework-components/test/meta-builder.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest'; +import { defineAnnotation } from '../src/annotations'; +import { createMetaBuilder } from '../src/meta-builder'; + +const cacheAnnotation = defineAnnotation<{ ttl: number }>()({ + namespace: 'cache', + applicableTo: ['read'], +}); + +const auditAnnotation = defineAnnotation<{ actor: string }>()({ + namespace: 'audit', + applicableTo: ['write'], +}); + +const otelAnnotation = defineAnnotation<{ traceId: string }>()({ + namespace: 'otel', + applicableTo: ['read', 'write'], +}); + +describe('createMetaBuilder', () => { + it('starts with an empty annotations map', () => { + const meta = createMetaBuilder('read', 'all'); + expect(meta.annotations.size).toBe(0); + }); + + it('records an applied annotation under its namespace', () => { + const meta = createMetaBuilder('read', 'all'); + meta.annotate(cacheAnnotation({ ttl: 60 })); + expect(meta.annotations.size).toBe(1); + expect(meta.annotations.get('cache')?.value).toEqual({ ttl: 60 }); + }); + + it('annotate returns the builder for chaining', () => { + const meta = createMetaBuilder('read', 'all'); + const chained = meta + .annotate(cacheAnnotation({ ttl: 60 })) + .annotate(otelAnnotation({ traceId: 't-1' })); + expect(chained).toBe(meta); + expect(meta.annotations.size).toBe(2); + }); + + it('last-write-wins on duplicate namespaces', () => { + const meta = createMetaBuilder('read', 'all'); + meta.annotate(cacheAnnotation({ ttl: 60 })); + meta.annotate(cacheAnnotation({ ttl: 120 })); + expect(meta.annotations.size).toBe(1); + expect(meta.annotations.get('cache')?.value).toEqual({ ttl: 120 }); + }); + + it('a both-kind annotation lands on a read builder', () => { + const meta = createMetaBuilder('read', 'all'); + meta.annotate(otelAnnotation({ traceId: 't-1' })); + expect(meta.annotations.get('otel')?.value).toEqual({ traceId: 't-1' }); + }); + + it('a both-kind annotation lands on a write builder', () => { + const meta = createMetaBuilder('write', 'create'); + meta.annotate(otelAnnotation({ traceId: 't-1' })); + expect(meta.annotations.get('otel')?.value).toEqual({ traceId: 't-1' }); + }); + + it('runtime gate rejects a write-only annotation forced through a cast on a read builder', () => { + const meta = createMetaBuilder('read', 'all'); + const annotateAny = meta.annotate as (annotation: unknown) => unknown; + expect(() => annotateAny.call(meta, auditAnnotation({ actor: 'system' }))).toThrow( + expect.objectContaining({ + code: 'RUNTIME.ANNOTATION_INAPPLICABLE', + category: 'RUNTIME', + }), + ); + }); + + it('runtime gate rejects a read-only annotation forced through a cast on a write builder', () => { + const meta = createMetaBuilder('write', 'create'); + const annotateAny = meta.annotate as (annotation: unknown) => unknown; + expect(() => annotateAny.call(meta, cacheAnnotation({ ttl: 60 }))).toThrow( + expect.objectContaining({ + code: 'RUNTIME.ANNOTATION_INAPPLICABLE', + category: 'RUNTIME', + }), + ); + }); + + it('runtime gate names the offending namespace and terminal in the error', () => { + const meta = createMetaBuilder('write', 'create'); + const annotateAny = meta.annotate as (annotation: unknown) => unknown; + try { + annotateAny.call(meta, cacheAnnotation({ ttl: 60 })); + } catch (error) { + expect(error).toMatchObject({ + code: 'RUNTIME.ANNOTATION_INAPPLICABLE', + details: { + namespace: 'cache', + terminalName: 'create', + kind: 'write', + }, + }); + return; + } + throw new Error('expected runtime gate to throw'); + }); + + it('rejected annotations are not recorded', () => { + const meta = createMetaBuilder('read', 'all'); + const annotateAny = meta.annotate as (annotation: unknown) => unknown; + try { + annotateAny.call(meta, auditAnnotation({ actor: 'system' })); + } catch { + // expected + } + expect(meta.annotations.size).toBe(0); + }); +}); diff --git a/packages/1-framework/1-core/framework-components/test/meta-builder.types.test-d.ts b/packages/1-framework/1-core/framework-components/test/meta-builder.types.test-d.ts new file mode 100644 index 0000000000..61eb6cb5c3 --- /dev/null +++ b/packages/1-framework/1-core/framework-components/test/meta-builder.types.test-d.ts @@ -0,0 +1,141 @@ +import { describe, expectTypeOf, test } from 'vitest'; +import { type AnnotationValue, defineAnnotation } from '../src/annotations'; +import { createMetaBuilder, type LaneMetaBuilder, type MetaBuilder } from '../src/meta-builder'; + +const readOnly = defineAnnotation<{ ttl: number }>()({ + namespace: 'cache', + applicableTo: ['read'], +}); + +const writeOnly = defineAnnotation<{ actor: string }>()({ + namespace: 'audit', + applicableTo: ['write'], +}); + +const both = defineAnnotation<{ traceId: string }>()({ + namespace: 'otel', + applicableTo: ['read', 'write'], +}); + +describe('MetaBuilder annotate', () => { + test('read meta builder accepts read-only annotations', () => { + const meta = createMetaBuilder('read', 'all'); + meta.annotate(readOnly({ ttl: 60 })); + }); + + test('read meta builder accepts both-kind annotations', () => { + const meta = createMetaBuilder('read', 'all'); + meta.annotate(both({ traceId: 't' })); + }); + + test('read meta builder rejects write-only annotations (negative)', () => { + const meta = createMetaBuilder('read', 'all'); + // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' + meta.annotate(writeOnly({ actor: 'system' })); + }); + + test('write meta builder accepts write-only annotations', () => { + const meta = createMetaBuilder('write', 'create'); + meta.annotate(writeOnly({ actor: 'system' })); + }); + + test('write meta builder accepts both-kind annotations', () => { + const meta = createMetaBuilder('write', 'create'); + meta.annotate(both({ traceId: 't' })); + }); + + test('write meta builder rejects read-only annotations (negative)', () => { + const meta = createMetaBuilder('write', 'create'); + // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' + meta.annotate(readOnly({ ttl: 60 })); + }); + + test('annotate returns the builder for chaining', () => { + const meta = createMetaBuilder('read', 'all'); + const result = meta.annotate(readOnly({ ttl: 60 })); + expectTypeOf(result).toEqualTypeOf(); + }); + + test('chaining annotate calls preserves the lane meta builder type', () => { + const meta = createMetaBuilder('read', 'all'); + const result = meta.annotate(readOnly({ ttl: 60 })).annotate(both({ traceId: 't' })); + expectTypeOf(result).toEqualTypeOf(); + }); +}); + +describe('createMetaBuilder return type', () => { + test('returns a LaneMetaBuilder exposing the annotations map', () => { + const meta = createMetaBuilder('read', 'all'); + expectTypeOf(meta).toEqualTypeOf>(); + expectTypeOf(meta.annotations).toEqualTypeOf< + ReadonlyMap> + >(); + }); + + test('LaneMetaBuilder is assignable to MetaBuilder (the user-callback view)', () => { + const lane = createMetaBuilder('read', 'all'); + const view: MetaBuilder<'read'> = lane; + void view; + }); + + test('MetaBuilder does not expose the annotations map (negative)', () => { + const view: MetaBuilder<'read'> = createMetaBuilder('read', 'all'); + // @ts-expect-error - annotations is not part of the public MetaBuilder surface + void view.annotations; + }); +}); + +describe('configurator-callback shape on lane terminals', () => { + /** + * Mimics the shape lane terminals adopt: an optional final + * `configure: (meta: MetaBuilder) => void` argument. The builder's + * operation kind `K` is fixed by the terminal; `meta.annotate` accepts + * any annotation whose declared kinds include `K`. + */ + function readTerminal(configure?: (meta: MetaBuilder<'read'>) => void): void { + const meta = createMetaBuilder('read', 'readTerminal'); + configure?.(meta); + } + + function writeTerminal(configure?: (meta: MetaBuilder<'write'>) => void): void { + const meta = createMetaBuilder('write', 'writeTerminal'); + configure?.(meta); + } + + test('read terminal accepts a configurator with read-applicable annotations', () => { + readTerminal((meta) => { + meta.annotate(readOnly({ ttl: 60 })); + meta.annotate(both({ traceId: 't' })); + }); + }); + + test('read terminal rejects a configurator that applies a write-only annotation (negative)', () => { + readTerminal((meta) => { + // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' + meta.annotate(writeOnly({ actor: 'system' })); + }); + }); + + test('write terminal accepts a configurator with write-applicable annotations', () => { + writeTerminal((meta) => { + meta.annotate(writeOnly({ actor: 'system' })); + meta.annotate(both({ traceId: 't' })); + }); + }); + + test('write terminal rejects a configurator that applies a read-only annotation (negative)', () => { + writeTerminal((meta) => { + // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' + meta.annotate(readOnly({ ttl: 60 })); + }); + }); + + test('terminals accept omitted configurator (the parameter is optional)', () => { + readTerminal(); + writeTerminal(); + }); + + test('expression-body callback that returns the builder is accepted (return type ignored)', () => { + readTerminal((meta) => meta.annotate(readOnly({ ttl: 60 }))); + }); +}); diff --git a/packages/3-extensions/middleware-cache/README.md b/packages/3-extensions/middleware-cache/README.md index 5782a43a48..d8072f5b1d 100644 --- a/packages/3-extensions/middleware-cache/README.md +++ b/packages/3-extensions/middleware-cache/README.md @@ -40,16 +40,14 @@ const db = postgres({ }); // First call: hits the database, caches the raw rows. -const first = await db.orm.User.first( - { id: 1 }, - cacheAnnotation({ ttl: 60_000 }), +const first = await db.orm.User.first({ id: 1 }, (meta) => + meta.annotate(cacheAnnotation({ ttl: 60_000 })), ); // Second call with the identical plan: served from cache, driver // not invoked. -const second = await db.orm.User.first( - { id: 1 }, - cacheAnnotation({ ttl: 60_000 }), +const second = await db.orm.User.first({ id: 1 }, (meta) => + meta.annotate(cacheAnnotation({ ttl: 60_000 })), ); // Un-annotated queries are never cached — caching is strictly opt-in. @@ -71,11 +69,11 @@ The cache middleware acts only on plans that carry a `cacheAnnotation` payload w The annotation is **read-only**: it declares `applicableTo: ['read']`, so the lane gate (TML-2143 M2) rejects passing it to write terminals at both type and runtime levels. "Cache a mutation" is structurally impossible without an `as any` cast bypass at both the type and runtime levels — the cache middleware itself ships without any mutation classifier. ```typescript -// ✓ ORM read terminal accepts the read-only annotation. -await db.orm.User.first({ id }, cacheAnnotation({ ttl: 60_000 })); +// ✓ ORM read terminal accepts the read-only annotation via the meta callback. +await db.orm.User.first({ id }, (meta) => meta.annotate(cacheAnnotation({ ttl: 60_000 }))); // ✗ Type error: write terminal rejects read-only annotation. -await db.orm.User.create(input, cacheAnnotation({ ttl: 60_000 })); +await db.orm.User.create(input, (meta) => meta.annotate(cacheAnnotation({ ttl: 60_000 }))); // ✓ SQL DSL: chainable on select / grouped builders. const plan = db.sql diff --git a/packages/3-extensions/sql-orm-client/src/collection.ts b/packages/3-extensions/sql-orm-client/src/collection.ts index 05445abbb6..c506c0baaf 100644 --- a/packages/3-extensions/sql-orm-client/src/collection.ts +++ b/packages/3-extensions/sql-orm-client/src/collection.ts @@ -1,13 +1,10 @@ import type { Contract } from '@prisma-next/contract/types'; import type { AnnotationValue, + MetaBuilder, OperationKind, - ValidAnnotations, -} from '@prisma-next/framework-components/runtime'; -import { - AsyncIterableResult, - assertAnnotationsApplicable, } from '@prisma-next/framework-components/runtime'; +import { AsyncIterableResult, createMetaBuilder } from '@prisma-next/framework-components/runtime'; import type { SqlStorage } from '@prisma-next/sql-contract/types'; import { BinaryExpr, @@ -630,73 +627,56 @@ export class Collection< /** * Read terminal: stream all rows matching the current state. * - * Accepts an optional variadic of read-typed user annotations. The - * `As & ValidAnnotations<'read', As>` gate rejects write-only - * annotations at the call site; the runtime check fails closed for - * callers that bypass the type gate. Annotations are merged into - * `plan.meta.annotations` at compile time. + * Accepts an optional `configure` callback that receives a + * `MetaBuilder<'read'>` so the caller can attach typed user + * annotations to the executed plan. `meta.annotate(...)` enforces + * applicability at the type level and at runtime; annotations are + * merged into `plan.meta.annotations` at compile time. */ - all[]>( - ...annotations: As & ValidAnnotations<'read', As> - ): AsyncIterableResult { - return this.#withAnnotations( - annotations as readonly AnnotationValue[], - 'read', - 'all', - ).#dispatch(); + all(configure?: (meta: MetaBuilder<'read'>) => void): AsyncIterableResult { + return this.#withAnnotationsFromMeta(configure, 'all').#dispatch(); } async first(): Promise; async first( - filter: (model: ModelAccessor) => WhereArg, + filter: undefined, + configure: (meta: MetaBuilder<'read'>) => void, ): Promise; - async first(filter: ShorthandWhereFilter): Promise; - async first[]>( - ...annotations: As & ValidAnnotations<'read', As> - ): Promise; - async first[]>( + async first( filter: (model: ModelAccessor) => WhereArg, - ...annotations: As & ValidAnnotations<'read', As> + configure?: (meta: MetaBuilder<'read'>) => void, ): Promise; - async first[]>( + async first( filter: ShorthandWhereFilter, - ...annotations: As & ValidAnnotations<'read', As> + configure?: (meta: MetaBuilder<'read'>) => void, ): Promise; /** * Read terminal: return the first matching row, or `null`. * - * Accepts an optional `filter` (function, shorthand, or annotation), - * followed by an optional variadic of read-typed user annotations. - * The first positional arg is interpreted as a filter only when it is - * a function or a non-`AnnotationValue` shorthand record; an - * `AnnotationValue` first arg is treated as the leading annotation. + * Accepts an optional `filter` (function or shorthand) followed by an + * optional `configure` callback that receives a `MetaBuilder<'read'>` + * for attaching typed user annotations. To attach annotations without + * narrowing further, pass `undefined` as the filter (or chain + * `.where(...)` first): + * + * ```typescript + * await db.User.first({ id }, (meta) => meta.annotate(cacheAnnotation({ ttl: 60 }))); + * await db.User.first(undefined, (meta) => meta.annotate(cacheAnnotation({ ttl: 60 }))); + * ``` */ async first( - filterOrFirstAnnotation?: + filter?: | ((model: ModelAccessor) => WhereArg) - | ShorthandWhereFilter - | AnnotationValue, - ...rest: readonly AnnotationValue[] + | ShorthandWhereFilter, + configure?: (meta: MetaBuilder<'read'>) => void, ): Promise { - let filter: - | ((model: ModelAccessor) => WhereArg) - | ShorthandWhereFilter - | undefined; - let annotations: readonly AnnotationValue[]; - if (isAnnotationValue(filterOrFirstAnnotation)) { - filter = undefined; - annotations = [filterOrFirstAnnotation, ...rest]; - } else { - filter = filterOrFirstAnnotation; - annotations = rest; - } const scoped = filter === undefined ? this : typeof filter === 'function' ? this.where(filter) : this.where(filter); - const limited = scoped.take(1).#withAnnotations(annotations, 'read', 'first'); + const limited = scoped.take(1).#withAnnotationsFromMeta(configure, 'first'); const rows = await limited.#dispatch().toArray(); return rows[0] ?? null; } @@ -705,18 +685,13 @@ export class Collection< * Read terminal: run an aggregate query (count, sum, avg, min, max) * built via the `AggregateBuilder` callback. * - * Accepts an optional variadic of read-typed user annotations after - * the builder callback. The `As & ValidAnnotations<'read', As>` gate - * rejects write-only annotations at the call site; the runtime check - * fails closed for callers that bypass the type gate. Annotations - * are merged into the compiled plan's `meta.annotations`. + * Accepts an optional `configure` callback that receives a + * `MetaBuilder<'read'>` for attaching typed user annotations. + * Annotations are merged into the compiled plan's `meta.annotations`. */ - async aggregate< - Spec extends AggregateSpec, - As extends readonly AnnotationValue[], - >( + async aggregate( fn: (aggregate: AggregateBuilder) => Spec, - ...annotations: As & ValidAnnotations<'read', As> + configure?: (meta: MetaBuilder<'read'>) => void, ): Promise> { const aggregateSpec = fn(createAggregateBuilder(this.contract, this.modelName)); const entries = Object.entries(aggregateSpec); @@ -730,11 +705,7 @@ export class Collection< } } - const annotationsMap = this.#buildAnnotationsMap( - annotations as readonly AnnotationValue[], - 'read', - 'aggregate', - ); + const annotationsMap = this.#collectAnnotationsFromMeta(configure, 'read', 'aggregate'); const compiled = mergeUserAnnotations( compileAggregate(this.contract, this.tableName, this.state.filters, aggregateSpec), @@ -747,22 +718,21 @@ export class Collection< return normalizeAggregateResult(aggregateSpec, rows[0] ?? {}); } - async create[]>( + async create( data: ResolvedCreateInput, - ...annotations: As & ValidAnnotations<'write', As> + configure?: (meta: MetaBuilder<'write'>) => void, ): Promise; - async create[]>( + async create( data: MutationCreateInputWithRelations, - ...annotations: As & ValidAnnotations<'write', As> + configure?: (meta: MetaBuilder<'write'>) => void, ): Promise; /** * Write terminal: insert one row and return it. * - * Accepts an optional variadic of write-typed user annotations after - * the input. The `As & ValidAnnotations<'write', As>` gate rejects - * read-only annotations at the call site; the runtime check fails - * closed for callers that bypass the type gate. Annotations are - * merged into the compiled mutation plan's `meta.annotations`. + * Accepts an optional `configure` callback that receives a + * `MetaBuilder<'write'>` for attaching typed user annotations. + * Annotations are merged into the compiled mutation plan's + * `meta.annotations`. * * Note: when the input contains nested-mutation callbacks, the * operation is executed as a graph of internal queries via @@ -774,10 +744,10 @@ export class Collection< data: | ResolvedCreateInput | MutationCreateInputWithRelations, - ...annotations: readonly AnnotationValue[] + configure?: (meta: MetaBuilder<'write'>) => void, ): Promise { assertReturningCapability(this.contract, 'create()'); - const annotationsMap = this.#buildAnnotationsMap(annotations, 'write', 'create'); + const annotationsMap = this.#collectAnnotationsFromMeta(configure, 'write', 'create'); if ( hasNestedMutationCallbacks(this.contract, this.modelName, data as Record) @@ -809,17 +779,13 @@ export class Collection< throw new Error(`create() for model "${this.modelName}" did not return a row`); } - createAll[]>( + createAll( data: readonly ResolvedCreateInput[], - ...annotations: As & ValidAnnotations<'write', As> + configure?: (meta: MetaBuilder<'write'>) => void, ): AsyncIterableResult { return this.#createAllWithAnnotations( data, - this.#buildAnnotationsMap( - annotations as readonly AnnotationValue[], - 'write', - 'createAll', - ), + this.#collectAnnotationsFromMeta(configure, 'write', 'createAll'), ); } @@ -1034,20 +1000,16 @@ export class Collection< }); } - async createCount[]>( + async createCount( data: readonly ResolvedCreateInput[], - ...annotations: As & ValidAnnotations<'write', As> + configure?: (meta: MetaBuilder<'write'>) => void, ): Promise { if (data.length === 0) { return 0; } this.#assertNotMtiVariant('createCount()'); - const annotationsMap = this.#buildAnnotationsMap( - annotations as readonly AnnotationValue[], - 'write', - 'createCount', - ); + const annotationsMap = this.#collectAnnotationsFromMeta(configure, 'write', 'createCount'); const rows = data as readonly Record[]; const mappedRows = this.#mapCreateRows(rows); @@ -1076,21 +1038,17 @@ export class Collection< * On conflict, `ON CONFLICT DO NOTHING RETURNING ...` may return zero rows, * so this method may issue a follow-up reload query to return the existing row. */ - async upsert[]>( + async upsert( input: { create: ResolvedCreateInput; update: Partial>; conflictOn?: UniqueConstraintCriterion; }, - ...annotations: As & ValidAnnotations<'write', As> + configure?: (meta: MetaBuilder<'write'>) => void, ): Promise { assertReturningCapability(this.contract, 'upsert()'); this.#assertNotMtiVariant('upsert()'); - const annotationsMap = this.#buildAnnotationsMap( - annotations as readonly AnnotationValue[], - 'write', - 'upsert', - ); + const annotationsMap = this.#collectAnnotationsFromMeta(configure, 'write', 'upsert'); const mappedCreateRows = this.#mapCreateRows([input.create as Record]); const createValues = mappedCreateRows[0] ?? {}; @@ -1154,10 +1112,8 @@ export class Collection< * Write terminal: update matching rows and return the first one (or * null when no row matched). * - * Accepts an optional variadic of write-typed user annotations after - * the input. The `As & ValidAnnotations<'write', As>` gate rejects - * read-only annotations at the call site; the runtime check fails - * closed for callers that bypass the type gate. + * Accepts an optional `configure` callback that receives a + * `MetaBuilder<'write'>` for attaching typed user annotations. * * Note: when the input contains nested-mutation callbacks, the * operation is executed as a graph of internal queries via @@ -1165,16 +1121,12 @@ export class Collection< * `update()` call but do not currently flow into each constituent SQL * statement — see `projects/middleware-intercept-and-cache/follow-ups.md`. */ - async update[]>( + async update( data: State['hasWhere'] extends true ? MutationUpdateInput : never, - ...annotations: As & ValidAnnotations<'write', As> + configure?: (meta: MetaBuilder<'write'>) => void, ): Promise { assertReturningCapability(this.contract, 'update()'); - const annotationsMap = this.#buildAnnotationsMap( - annotations as readonly AnnotationValue[], - 'write', - 'update', - ); + const annotationsMap = this.#collectAnnotationsFromMeta(configure, 'write', 'update'); if ( hasNestedMutationCallbacks(this.contract, this.modelName, data as Record) @@ -1203,17 +1155,13 @@ export class Collection< return rows[0] ?? null; } - updateAll[]>( + updateAll( data: State['hasWhere'] extends true ? Partial> : never, - ...annotations: As & ValidAnnotations<'write', As> + configure?: (meta: MetaBuilder<'write'>) => void, ): AsyncIterableResult { return this.#updateAllWithAnnotations( data, - this.#buildAnnotationsMap( - annotations as readonly AnnotationValue[], - 'write', - 'updateAll', - ), + this.#collectAnnotationsFromMeta(configure, 'write', 'updateAll'), ); } @@ -1255,9 +1203,9 @@ export class Collection< }); } - async updateCount[]>( + async updateCount( data: State['hasWhere'] extends true ? Partial> : never, - ...annotations: As & ValidAnnotations<'write', As> + configure?: (meta: MetaBuilder<'write'>) => void, ): Promise { const mappedData = mapModelDataToStorageRow(this.contract, this.modelName, data); if (Object.keys(mappedData).length === 0) { @@ -1265,11 +1213,7 @@ export class Collection< } // Annotations attach to the write, not the matching read. - const annotationsMap = this.#buildAnnotationsMap( - annotations as readonly AnnotationValue[], - 'write', - 'updateCount', - ); + const annotationsMap = this.#collectAnnotationsFromMeta(configure, 'write', 'updateCount'); const primaryKeyColumn = resolvePrimaryKeyColumn(this.contract, this.tableName); const countState: CollectionState = { @@ -1296,40 +1240,28 @@ export class Collection< * Write terminal: delete matching rows and return the first one (or * null when no row matched). * - * Accepts an optional variadic of write-typed user annotations. - * The `As & ValidAnnotations<'write', As>` gate rejects read-only - * annotations at the call site; the runtime check fails closed for - * callers that bypass the type gate. + * Accepts an optional `configure` callback that receives a + * `MetaBuilder<'write'>` for attaching typed user annotations. */ - async delete[]>( + async delete( this: State['hasWhere'] extends true ? Collection : never, - ...annotations: As & ValidAnnotations<'write', As> + configure?: (meta: MetaBuilder<'write'>) => void, ): Promise { assertReturningCapability(this.contract, 'delete()'); // The `this`-typed receiver narrows when the `where()` gate is // satisfied, so we can call `deleteAll()` on it directly. const rows = await (this as Collection) - .#deleteAllWithAnnotations( - this.#buildAnnotationsMap( - annotations as readonly AnnotationValue[], - 'write', - 'delete', - ), - ) + .#deleteAllWithAnnotations(this.#collectAnnotationsFromMeta(configure, 'write', 'delete')) .toArray(); return rows[0] ?? null; } - deleteAll[]>( + deleteAll( this: State['hasWhere'] extends true ? Collection : never, - ...annotations: As & ValidAnnotations<'write', As> + configure?: (meta: MetaBuilder<'write'>) => void, ): AsyncIterableResult { return (this as Collection).#deleteAllWithAnnotations( - this.#buildAnnotationsMap( - annotations as readonly AnnotationValue[], - 'write', - 'deleteAll', - ), + this.#collectAnnotationsFromMeta(configure, 'write', 'deleteAll'), ); } @@ -1358,16 +1290,12 @@ export class Collection< }); } - async deleteCount[]>( + async deleteCount( this: State['hasWhere'] extends true ? Collection : never, - ...annotations: As & ValidAnnotations<'write', As> + configure?: (meta: MetaBuilder<'write'>) => void, ): Promise { // Annotations attach to the write, not the matching read. - const annotationsMap = this.#buildAnnotationsMap( - annotations as readonly AnnotationValue[], - 'write', - 'deleteCount', - ); + const annotationsMap = this.#collectAnnotationsFromMeta(configure, 'write', 'deleteCount'); const primaryKeyColumn = resolvePrimaryKeyColumn(this.contract, this.tableName); const countState: CollectionState = { @@ -1518,69 +1446,58 @@ export class Collection< } /** - * Validates the annotation kinds against the terminal's operation - * kind and returns a clone whose `state.userAnnotations` carries the - * accumulated map. The runtime gate fails closed via - * `assertAnnotationsApplicable` when callers bypass the type gate - * through casts or `any`. + * Invokes the user-supplied configurator (if any) against a freshly + * constructed read meta builder, and returns a clone whose + * `state.userAnnotations` carries the recorded map. Used by read + * terminals that flow annotations through state (`all`, `first`). * - * Empty `annotations` returns the receiver unchanged. + * Returns the receiver unchanged when no configurator was supplied + * or when the configurator did not call `meta.annotate(...)`. The + * meta builder's `annotate` method enforces applicability at the + * type level and at runtime, so terminal code does not need to + * re-validate. */ - #withAnnotations( - annotations: readonly AnnotationValue[], - kind: OperationKind, + #withAnnotationsFromMeta( + configure: ((meta: MetaBuilder<'read'>) => void) | undefined, terminalName: string, ): this { - if (annotations.length === 0) { + if (configure === undefined) { + return this; + } + const meta = createMetaBuilder('read', terminalName); + configure(meta); + if (meta.annotations.size === 0) { return this; } - assertAnnotationsApplicable(annotations, kind, terminalName); const next = new Map(this.state.userAnnotations); - for (const annotation of annotations) { - next.set(annotation.namespace, annotation); + for (const [namespace, value] of meta.annotations) { + next.set(namespace, value); } return this.#clone({ userAnnotations: next }) as this; } /** - * Validates the annotation kinds against a terminal's operation kind - * and returns a `Map` ready to be passed - * to `mergeUserAnnotations`. Returns `undefined` for an empty variadic - * so callers can skip the rewrap entirely. - * - * Used by terminals where annotations don't flow through `state` — - * the compiled plan is post-wrapped with the annotations map - * instead. (Read terminals like `all` and `first` instead populate - * `state.userAnnotations` via `#withAnnotations`; aggregate is the - * one read terminal that uses the post-wrap path because its compile - * function doesn't take `state`.) The runtime gate fails closed via - * `assertAnnotationsApplicable`. + * Invokes the user-supplied configurator (if any) against a freshly + * constructed meta builder of the given operation kind, and returns + * the recorded annotation map (or `undefined` when empty). Used by + * terminals where annotations don't flow through `state` — the + * compiled plan is post-wrapped via `mergeUserAnnotations` instead. + * Read terminals `all` and `first` populate `state.userAnnotations` + * via `#withAnnotationsFromMeta` instead; `aggregate` uses this + * post-wrap path because its compile function doesn't take `state`. + * The meta builder's `annotate` method enforces applicability at the + * type level and at runtime. */ - #buildAnnotationsMap( - annotations: readonly AnnotationValue[], - kind: OperationKind, + #collectAnnotationsFromMeta( + configure: ((meta: MetaBuilder) => void) | undefined, + kind: K, terminalName: string, ): ReadonlyMap> | undefined { - if (annotations.length === 0) { + if (configure === undefined) { return undefined; } - assertAnnotationsApplicable(annotations, kind, terminalName); - const next = new Map>(); - for (const annotation of annotations) { - next.set(annotation.namespace, annotation); - } - return next; - } -} - -/** - * Type guard identifying branded `AnnotationValue` objects. Used by - * terminals like `first()` whose leading argument may be either a - * filter or an annotation. - */ -function isAnnotationValue(value: unknown): value is AnnotationValue { - if (value === null || typeof value !== 'object') { - return false; + const meta = createMetaBuilder(kind, terminalName); + configure(meta); + return meta.annotations.size === 0 ? undefined : meta.annotations; } - return (value as { readonly __annotation?: unknown }).__annotation === true; } diff --git a/packages/3-extensions/sql-orm-client/src/grouped-collection.ts b/packages/3-extensions/sql-orm-client/src/grouped-collection.ts index bda025f14c..4186668d7d 100644 --- a/packages/3-extensions/sql-orm-client/src/grouped-collection.ts +++ b/packages/3-extensions/sql-orm-client/src/grouped-collection.ts @@ -1,10 +1,10 @@ import type { Contract } from '@prisma-next/contract/types'; import type { AnnotationValue, + MetaBuilder, OperationKind, - ValidAnnotations, } from '@prisma-next/framework-components/runtime'; -import { assertAnnotationsApplicable } from '@prisma-next/framework-components/runtime'; +import { createMetaBuilder } from '@prisma-next/framework-components/runtime'; import type { SqlStorage } from '@prisma-next/sql-contract/types'; import { AggregateExpr, @@ -91,18 +91,13 @@ export class GroupedCollection< /** * Read terminal: run a grouped aggregate query. * - * Accepts an optional variadic of read-typed user annotations after - * the builder callback. The `As & ValidAnnotations<'read', As>` gate - * rejects write-only annotations at the call site; the runtime check - * fails closed for callers that bypass the type gate. Annotations - * are merged into the compiled plan's `meta.annotations`. + * Accepts an optional `configure` callback that receives a + * `MetaBuilder<'read'>` for attaching typed user annotations. + * Annotations are merged into the compiled plan's `meta.annotations`. */ - async aggregate< - Spec extends AggregateSpec, - As extends readonly AnnotationValue[], - >( + async aggregate( fn: (aggregate: AggregateBuilder) => Spec, - ...annotations: As & ValidAnnotations<'read', As> + configure?: (meta: MetaBuilder<'read'>) => void, ): Promise< Array< SimplifyDeep< @@ -122,15 +117,13 @@ export class GroupedCollection< } } - const annotationsAsValues = annotations as readonly AnnotationValue[]; let annotationsMap: ReadonlyMap> | undefined; - if (annotationsAsValues.length > 0) { - assertAnnotationsApplicable(annotationsAsValues, 'read', 'groupBy.aggregate'); - const next = new Map>(); - for (const annotation of annotationsAsValues) { - next.set(annotation.namespace, annotation); + if (configure !== undefined) { + const meta = createMetaBuilder('read', 'groupBy.aggregate'); + configure(meta); + if (meta.annotations.size > 0) { + annotationsMap = meta.annotations; } - annotationsMap = next; } const compiled = mergeUserAnnotations( diff --git a/packages/3-extensions/sql-orm-client/test/annotations.test.ts b/packages/3-extensions/sql-orm-client/test/annotations.test.ts index 8a2d1de156..93cc8b72af 100644 --- a/packages/3-extensions/sql-orm-client/test/annotations.test.ts +++ b/packages/3-extensions/sql-orm-client/test/annotations.test.ts @@ -26,7 +26,7 @@ describe('Collection.all annotations', () => { const { collection, runtime } = createCollection(); runtime.setNextResults([[]]); - await collection.all(cacheAnnotation({ ttl: 60 })).toArray(); + await collection.all((meta) => meta.annotate(cacheAnnotation({ ttl: 60 }))).toArray(); expect(runtime.executions).toHaveLength(1); const stored = runtime.executions[0]!.plan.meta.annotations?.['cache']; @@ -41,7 +41,7 @@ describe('Collection.all annotations', () => { const { collection, runtime } = createCollection(); runtime.setNextResults([[]]); - await collection.all(cacheAnnotation({ ttl: 60 })).toArray(); + await collection.all((meta) => meta.annotate(cacheAnnotation({ ttl: 60 }))).toArray(); const plan = runtime.executions[0]!.plan; expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); @@ -62,7 +62,9 @@ describe('Collection.all annotations', () => { runtime.setNextResults([[]]); await collection - .all(cacheAnnotation({ ttl: 60 }), otelAnnotation({ traceId: 't-1' })) + .all((meta) => + meta.annotate(cacheAnnotation({ ttl: 60 })).annotate(otelAnnotation({ traceId: 't-1' })), + ) .toArray(); const plan = runtime.executions[0]!.plan; @@ -70,7 +72,7 @@ describe('Collection.all annotations', () => { expect(otelAnnotation.read(plan)).toEqual({ traceId: 't-1' }); }); - it('zero annotations is a no-op for user annotations (empty variadic)', async () => { + it('omitting the configurator is a no-op for user annotations', async () => { const { collection, runtime } = createCollection(); runtime.setNextResults([[]]); @@ -81,6 +83,17 @@ describe('Collection.all annotations', () => { expect(otelAnnotation.read(plan)).toBeUndefined(); }); + it('a configurator that records nothing is a no-op for user annotations', async () => { + const { collection, runtime } = createCollection(); + runtime.setNextResults([[]]); + + await collection.all(() => {}).toArray(); + + const plan = runtime.executions[0]!.plan; + expect(cacheAnnotation.read(plan)).toBeUndefined(); + expect(otelAnnotation.read(plan)).toBeUndefined(); + }); + it('annotations survive across .where() and .take() chaining', async () => { const { collection, runtime } = createCollection(); runtime.setNextResults([[]]); @@ -88,7 +101,7 @@ describe('Collection.all annotations', () => { await collection .where((user) => user.name.eq('Alice')) .take(10) - .all(cacheAnnotation({ ttl: 60 })) + .all((meta) => meta.annotate(cacheAnnotation({ ttl: 60 }))) .toArray(); const plan = runtime.executions[0]!.plan; @@ -97,8 +110,15 @@ describe('Collection.all annotations', () => { it('runtime gate rejects a write-only annotation forced through a cast', () => { const { collection } = createCollection(); - const allFn = collection.all as unknown as (annotation: unknown) => unknown; - expect(() => allFn.call(collection, auditAnnotation({ actor: 'system' }))).toThrow( + expect(() => + collection + .all((meta) => { + // Cast bypasses the type-level applicability gate. + const annotateAny = meta.annotate as (annotation: unknown) => unknown; + annotateAny.call(meta, auditAnnotation({ actor: 'system' })); + }) + .toArray(), + ).toThrow( expect.objectContaining({ code: 'RUNTIME.ANNOTATION_INAPPLICABLE', category: 'RUNTIME', @@ -112,7 +132,7 @@ describe('Collection.first annotations', () => { const { collection, runtime } = createCollection(); runtime.setNextResults([[]]); - await collection.first(cacheAnnotation({ ttl: 60 })); + await collection.first(undefined, (meta) => meta.annotate(cacheAnnotation({ ttl: 60 }))); expect(runtime.executions).toHaveLength(1); const plan = runtime.executions[0]!.plan; @@ -123,7 +143,10 @@ describe('Collection.first annotations', () => { const { collection, runtime } = createCollection(); runtime.setNextResults([[]]); - await collection.first((user) => user.name.eq('Alice'), cacheAnnotation({ ttl: 60 })); + await collection.first( + (user) => user.name.eq('Alice'), + (meta) => meta.annotate(cacheAnnotation({ ttl: 60 })), + ); const plan = runtime.executions[0]!.plan; expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); @@ -133,28 +156,26 @@ describe('Collection.first annotations', () => { const { collection, runtime } = createCollection(); runtime.setNextResults([[]]); - await collection.first({ name: 'Alice' }, cacheAnnotation({ ttl: 60 })); + await collection.first({ name: 'Alice' }, (meta) => + meta.annotate(cacheAnnotation({ ttl: 60 })), + ); const plan = runtime.executions[0]!.plan; expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); }); - it('disambiguates a leading AnnotationValue from a shorthand filter', async () => { + it('a single function arg is interpreted as a filter (not a configurator)', async () => { const { collection, runtime } = createCollection(); runtime.setNextResults([[]]); - // The leading argument is an AnnotationValue, not a filter. The terminal - // must treat it as the leading annotation, not as a where shorthand. - await collection.first(cacheAnnotation({ ttl: 60 })); + // Passing a single function is treated as a filter callback, matching the + // existing `first(filterFn)` semantics. To attach an annotation without a + // filter, pass `undefined` explicitly as the first arg. + await collection.first((user) => user.name.eq('Alice')); expect(runtime.executions).toHaveLength(1); const plan = runtime.executions[0]!.plan; - expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); - // No filter should have been derived from the annotation. - // (Verify indirectly: the executed plan should not contain a where - // clause derived from the annotation's payload.) - const annotationKeys = Object.keys(plan.meta.annotations ?? {}); - expect(annotationKeys).toContain('cache'); + expect(cacheAnnotation.read(plan)).toBeUndefined(); }); it('multiple annotations coexist under different namespaces', async () => { @@ -163,8 +184,8 @@ describe('Collection.first annotations', () => { await collection.first( (user) => user.name.eq('Alice'), - cacheAnnotation({ ttl: 60 }), - otelAnnotation({ traceId: 't-1' }), + (meta) => + meta.annotate(cacheAnnotation({ ttl: 60 })).annotate(otelAnnotation({ traceId: 't-1' })), ); const plan = runtime.executions[0]!.plan; @@ -174,9 +195,11 @@ describe('Collection.first annotations', () => { it('runtime gate rejects a write-only annotation forced through a cast', async () => { const { collection } = createCollection(); - const firstFn = collection.first as unknown as (annotation: unknown) => Promise; await expect( - firstFn.call(collection, auditAnnotation({ actor: 'system' })), + collection.first(undefined, (meta) => { + const annotateAny = meta.annotate as (annotation: unknown) => unknown; + annotateAny.call(meta, auditAnnotation({ actor: 'system' })); + }), ).rejects.toMatchObject({ code: 'RUNTIME.ANNOTATION_INAPPLICABLE', category: 'RUNTIME', @@ -189,7 +212,7 @@ describe('Collection annotations alongside framework-internal codecs metadata', const { collection, runtime } = createCollection(); runtime.setNextResults([[]]); - await collection.all(cacheAnnotation({ ttl: 60 })).toArray(); + await collection.all((meta) => meta.annotate(cacheAnnotation({ ttl: 60 }))).toArray(); const plan = runtime.executions[0]!.plan; // User annotation lives under its own namespace. @@ -208,9 +231,8 @@ describe('Collection.create annotations', () => { const { collection, runtime } = createReturningCollectionFor('User'); runtime.setNextResults([[{ id: 1, name: 'Alice', email: 'a@b.com' }]]); - await collection.create( - { id: 1, name: 'Alice', email: 'a@b.com' }, - auditAnnotation({ actor: 'system' }), + await collection.create({ id: 1, name: 'Alice', email: 'a@b.com' }, (meta) => + meta.annotate(auditAnnotation({ actor: 'system' })), ); expect(runtime.executions).toHaveLength(1); @@ -222,16 +244,15 @@ describe('Collection.create annotations', () => { const { collection, runtime } = createReturningCollectionFor('User'); runtime.setNextResults([[{ id: 1, name: 'Alice', email: 'a@b.com' }]]); - await collection.create( - { id: 1, name: 'Alice', email: 'a@b.com' }, - otelAnnotation({ traceId: 't-1' }), + await collection.create({ id: 1, name: 'Alice', email: 'a@b.com' }, (meta) => + meta.annotate(otelAnnotation({ traceId: 't-1' })), ); const plan = runtime.executions[0]!.plan; expect(otelAnnotation.read(plan)).toEqual({ traceId: 't-1' }); }); - it('zero annotations leaves the plan without user annotations', async () => { + it('omitting the configurator leaves the plan without user annotations', async () => { const { collection, runtime } = createReturningCollectionFor('User'); runtime.setNextResults([[{ id: 1, name: 'Alice', email: 'a@b.com' }]]); @@ -244,16 +265,11 @@ describe('Collection.create annotations', () => { it('runtime gate rejects a read-only annotation forced through a cast', async () => { const { collection } = createReturningCollectionFor('User'); - const createFn = collection.create as unknown as ( - data: unknown, - annotation: unknown, - ) => Promise; await expect( - createFn.call( - collection, - { id: 1, name: 'Alice', email: 'a@b.com' }, - cacheAnnotation({ ttl: 60 }), - ), + collection.create({ id: 1, name: 'Alice', email: 'a@b.com' }, (meta) => { + const annotateAny = meta.annotate as (annotation: unknown) => unknown; + annotateAny.call(meta, cacheAnnotation({ ttl: 60 })); + }), ).rejects.toMatchObject({ code: 'RUNTIME.ANNOTATION_INAPPLICABLE', category: 'RUNTIME', @@ -275,7 +291,7 @@ describe('Collection.createAll annotations', () => { { id: 1, name: 'A', email: 'a@b.com' }, { id: 2, name: 'B', email: 'b@b.com' }, ], - auditAnnotation({ actor: 'system' }), + (meta) => meta.annotate(auditAnnotation({ actor: 'system' })), ) .toArray(); @@ -291,9 +307,8 @@ describe('Collection.createCount annotations', () => { const { collection, runtime } = createReturningCollectionFor('User'); runtime.setNextResults([[]]); - await collection.createCount( - [{ id: 1, name: 'A', email: 'a@b.com' }], - auditAnnotation({ actor: 'system' }), + await collection.createCount([{ id: 1, name: 'A', email: 'a@b.com' }], (meta) => + meta.annotate(auditAnnotation({ actor: 'system' })), ); expect(runtime.executions.length).toBeGreaterThan(0); @@ -314,7 +329,7 @@ describe('Collection.upsert annotations', () => { update: { name: 'Alice' }, conflictOn: { id: 1 }, }, - auditAnnotation({ actor: 'system' }), + (meta) => meta.annotate(auditAnnotation({ actor: 'system' })), ); const plan = runtime.executions[0]!.plan; @@ -323,19 +338,17 @@ describe('Collection.upsert annotations', () => { it('runtime gate rejects a read-only annotation forced through a cast', async () => { const { collection } = createReturningCollectionFor('User'); - const upsertFn = collection.upsert as unknown as ( - input: unknown, - annotation: unknown, - ) => Promise; await expect( - upsertFn.call( - collection, + collection.upsert( { create: { id: 1, name: 'Alice', email: 'a@b.com' }, update: { name: 'Alice' }, conflictOn: { id: 1 }, }, - cacheAnnotation({ ttl: 60 }), + (meta) => { + const annotateAny = meta.annotate as (annotation: unknown) => unknown; + annotateAny.call(meta, cacheAnnotation({ ttl: 60 })); + }, ), ).rejects.toMatchObject({ code: 'RUNTIME.ANNOTATION_INAPPLICABLE', @@ -350,7 +363,7 @@ describe('Collection.update annotations', () => { await collection .where({ id: 1 }) - .update({ name: 'Alice' }, auditAnnotation({ actor: 'system' })); + .update({ name: 'Alice' }, (meta) => meta.annotate(auditAnnotation({ actor: 'system' }))); const plan = runtime.executions[0]!.plan; expect(auditAnnotation.read(plan)).toEqual({ actor: 'system' }); @@ -359,12 +372,11 @@ describe('Collection.update annotations', () => { it('runtime gate rejects a read-only annotation forced through a cast', async () => { const { collection } = createReturningCollectionFor('User'); const filtered = collection.where({ id: 1 }); - const updateFn = filtered.update as unknown as ( - data: unknown, - annotation: unknown, - ) => Promise; await expect( - updateFn.call(filtered, { name: 'Alice' }, cacheAnnotation({ ttl: 60 })), + filtered.update({ name: 'Alice' }, (meta) => { + const annotateAny = meta.annotate as (annotation: unknown) => unknown; + annotateAny.call(meta, cacheAnnotation({ ttl: 60 })); + }), ).rejects.toMatchObject({ code: 'RUNTIME.ANNOTATION_INAPPLICABLE', }); @@ -378,7 +390,7 @@ describe('Collection.updateAll annotations', () => { await collection .where({ id: 1 }) - .updateAll({ name: 'Alice' }, auditAnnotation({ actor: 'system' })) + .updateAll({ name: 'Alice' }, (meta) => meta.annotate(auditAnnotation({ actor: 'system' }))) .toArray(); const plan = runtime.executions[0]!.plan; @@ -394,7 +406,9 @@ describe('Collection.updateCount annotations', () => { await collection .where({ id: 1 }) - .updateCount({ name: 'Alice' }, auditAnnotation({ actor: 'system' })); + .updateCount({ name: 'Alice' }, (meta) => + meta.annotate(auditAnnotation({ actor: 'system' })), + ); expect(runtime.executions).toHaveLength(2); const matchingPlan = runtime.executions[0]!.plan; @@ -411,7 +425,9 @@ describe('Collection.delete annotations', () => { const { collection, runtime } = createReturningCollectionFor('User'); runtime.setNextResults([[{ id: 1, name: 'Alice', email: 'a@b.com' }]]); - await collection.where({ id: 1 }).delete(auditAnnotation({ actor: 'system' })); + await collection + .where({ id: 1 }) + .delete((meta) => meta.annotate(auditAnnotation({ actor: 'system' }))); const plan = runtime.executions[0]!.plan; expect(auditAnnotation.read(plan)).toEqual({ actor: 'system' }); @@ -420,8 +436,12 @@ describe('Collection.delete annotations', () => { it('runtime gate rejects a read-only annotation forced through a cast', async () => { const { collection } = createReturningCollectionFor('User'); const filtered = collection.where({ id: 1 }); - const deleteFn = filtered.delete as unknown as (annotation: unknown) => Promise; - await expect(deleteFn.call(filtered, cacheAnnotation({ ttl: 60 }))).rejects.toMatchObject({ + await expect( + filtered.delete((meta) => { + const annotateAny = meta.annotate as (annotation: unknown) => unknown; + annotateAny.call(meta, cacheAnnotation({ ttl: 60 })); + }), + ).rejects.toMatchObject({ code: 'RUNTIME.ANNOTATION_INAPPLICABLE', }); }); @@ -434,7 +454,7 @@ describe('Collection.deleteAll annotations', () => { await collection .where({ id: 1 }) - .deleteAll(auditAnnotation({ actor: 'system' })) + .deleteAll((meta) => meta.annotate(auditAnnotation({ actor: 'system' }))) .toArray(); const plan = runtime.executions[0]!.plan; @@ -448,7 +468,9 @@ describe('Collection.deleteCount annotations', () => { // Two execute calls: matching select first, then the delete. runtime.setNextResults([[{ id: 1 }], []]); - await collection.where({ id: 1 }).deleteCount(auditAnnotation({ actor: 'system' })); + await collection + .where({ id: 1 }) + .deleteCount((meta) => meta.annotate(auditAnnotation({ actor: 'system' }))); expect(runtime.executions).toHaveLength(2); const matchingPlan = runtime.executions[0]!.plan; @@ -467,7 +489,7 @@ describe('Collection.aggregate annotations', () => { await collection.aggregate( (aggregate) => ({ count: aggregate.count() }), - cacheAnnotation({ ttl: 60 }), + (meta) => meta.annotate(cacheAnnotation({ ttl: 60 })), ); expect(runtime.executions).toHaveLength(1); @@ -481,14 +503,14 @@ describe('Collection.aggregate annotations', () => { await collection.aggregate( (aggregate) => ({ count: aggregate.count() }), - otelAnnotation({ traceId: 't-1' }), + (meta) => meta.annotate(otelAnnotation({ traceId: 't-1' })), ); const plan = runtime.executions[0]!.plan; expect(otelAnnotation.read(plan)).toEqual({ traceId: 't-1' }); }); - it('zero annotations leaves the plan without user annotations', async () => { + it('omitting the configurator leaves the plan without user annotations', async () => { const { collection, runtime } = createCollectionFor('Post'); runtime.setNextResults([[{ count: '5' }]]); @@ -501,15 +523,13 @@ describe('Collection.aggregate annotations', () => { it('runtime gate rejects a write-only annotation forced through a cast', async () => { const { collection } = createCollectionFor('Post'); - const aggregateFn = collection.aggregate as unknown as ( - fn: unknown, - annotation: unknown, - ) => Promise; await expect( - aggregateFn.call( - collection, - (aggregate: { count: () => unknown }) => ({ count: aggregate.count() }), - auditAnnotation({ actor: 'system' }), + collection.aggregate( + (aggregate) => ({ count: aggregate.count() }), + (meta) => { + const annotateAny = meta.annotate as (annotation: unknown) => unknown; + annotateAny.call(meta, auditAnnotation({ actor: 'system' })); + }, ), ).rejects.toMatchObject({ code: 'RUNTIME.ANNOTATION_INAPPLICABLE', @@ -523,9 +543,10 @@ describe('GroupedCollection.aggregate annotations', () => { const { collection, runtime } = createCollectionFor('Post'); runtime.setNextResults([[{ user_id: 1, count: '2' }]]); - await collection - .groupBy('userId') - .aggregate((aggregate) => ({ count: aggregate.count() }), cacheAnnotation({ ttl: 60 })); + await collection.groupBy('userId').aggregate( + (aggregate) => ({ count: aggregate.count() }), + (meta) => meta.annotate(cacheAnnotation({ ttl: 60 })), + ); expect(runtime.executions).toHaveLength(1); const plan = runtime.executions[0]!.plan; @@ -536,15 +557,16 @@ describe('GroupedCollection.aggregate annotations', () => { const { collection, runtime } = createCollectionFor('Post'); runtime.setNextResults([[{ user_id: 1, count: '2' }]]); - await collection - .groupBy('userId') - .aggregate((aggregate) => ({ count: aggregate.count() }), otelAnnotation({ traceId: 't-1' })); + await collection.groupBy('userId').aggregate( + (aggregate) => ({ count: aggregate.count() }), + (meta) => meta.annotate(otelAnnotation({ traceId: 't-1' })), + ); const plan = runtime.executions[0]!.plan; expect(otelAnnotation.read(plan)).toEqual({ traceId: 't-1' }); }); - it('zero annotations leaves the plan without user annotations', async () => { + it('omitting the configurator leaves the plan without user annotations', async () => { const { collection, runtime } = createCollectionFor('Post'); runtime.setNextResults([[{ user_id: 1, count: '2' }]]); @@ -557,13 +579,13 @@ describe('GroupedCollection.aggregate annotations', () => { it('runtime gate rejects a write-only annotation forced through a cast', async () => { const { collection } = createCollectionFor('Post'); - const grouped = collection.groupBy('userId') as unknown as { - aggregate(fn: unknown, annotation: unknown): Promise; - }; await expect( - grouped.aggregate( - (aggregate: { count: () => unknown }) => ({ count: aggregate.count() }), - auditAnnotation({ actor: 'system' }), + collection.groupBy('userId').aggregate( + (aggregate) => ({ count: aggregate.count() }), + (meta) => { + const annotateAny = meta.annotate as (annotation: unknown) => unknown; + annotateAny.call(meta, auditAnnotation({ actor: 'system' })); + }, ), ).rejects.toMatchObject({ code: 'RUNTIME.ANNOTATION_INAPPLICABLE', diff --git a/packages/3-extensions/sql-orm-client/test/annotations.types.test-d.ts b/packages/3-extensions/sql-orm-client/test/annotations.types.test-d.ts index 5a48f87c16..9088e697c4 100644 --- a/packages/3-extensions/sql-orm-client/test/annotations.types.test-d.ts +++ b/packages/3-extensions/sql-orm-client/test/annotations.types.test-d.ts @@ -1,7 +1,7 @@ import { type AnnotationValue, defineAnnotation, - type OperationKind, + type MetaBuilder, } from '@prisma-next/framework-components/runtime'; import { describe, expectTypeOf, test } from 'vitest'; import type { Collection } from '../src/collection'; @@ -12,13 +12,17 @@ import type { TestContract } from './helpers'; * Type-level tests for the ORM `Collection` terminal annotations. * * Verifies: - * - Read terminals (`all`, `first`) accept read-typed and both-kind - * annotations and reject write-only ones via the - * `As & ValidAnnotations<'read', As>` gate. - * - The variadic position does not widen the terminal's return type. - * - `first(filter, ...annotations)` and `first(...annotations)` both - * typecheck (the leading argument disambiguates between filter and - * annotation). + * - Read terminals (`all`, `first`, `aggregate`) accept a configurator + * callback whose `meta.annotate(...)` admits read-applicable + * annotations and rejects write-only ones at the type level. + * - Write terminals (`create`, `createAll`, `createCount`, `upsert`, + * `update`, `updateAll`, `updateCount`, `delete`, `deleteAll`, + * `deleteCount`) accept a configurator whose `meta.annotate(...)` + * admits write-applicable annotations and rejects read-only ones. + * - The configurator does not widen the terminal's return type. + * - `first(filter, configure?)` overloads dispatch by argument shape; + * no runtime ambiguity (configurator-only without filter requires + * an explicit `undefined` first argument). */ declare const userCollection: Collection; @@ -39,91 +43,112 @@ const otelAnnotation = defineAnnotation<{ traceId: string }>()({ }); describe('Collection.all (read-typed)', () => { - test('accepts a read-only annotation', () => { - userCollection.all(cacheAnnotation({ ttl: 60 })); + test('accepts a configurator that applies a read-only annotation', () => { + userCollection.all((meta) => meta.annotate(cacheAnnotation({ ttl: 60 }))); }); - test('accepts a both-kind annotation', () => { - userCollection.all(otelAnnotation({ traceId: 't' })); + test('accepts a configurator that applies a both-kind annotation', () => { + userCollection.all((meta) => meta.annotate(otelAnnotation({ traceId: 't' }))); }); - test('accepts multiple compatible annotations in a single call', () => { - userCollection.all(cacheAnnotation({ ttl: 60 }), otelAnnotation({ traceId: 't' })); + test('accepts a configurator that chains multiple compatible annotations', () => { + userCollection.all((meta) => + meta.annotate(cacheAnnotation({ ttl: 60 })).annotate(otelAnnotation({ traceId: 't' })), + ); }); - test('accepts zero annotations (empty variadic)', () => { + test('accepts an omitted configurator', () => { userCollection.all(); }); - test('rejects a write-only annotation (negative)', () => { - // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' - userCollection.all(auditAnnotation({ actor: 'system' })); + test('rejects a configurator that applies a write-only annotation (negative)', () => { + userCollection.all((meta) => + // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' + meta.annotate(auditAnnotation({ actor: 'system' })), + ); }); - test('rejects a mix containing a write-only annotation (negative)', () => { - // biome-ignore format: keep on one line so @ts-expect-error attaches to the call - // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' - userCollection.all(cacheAnnotation({ ttl: 60 }), auditAnnotation({ actor: 'system' })); + test('rejects a configurator that mixes in a write-only annotation (negative)', () => { + userCollection.all((meta) => { + meta.annotate(cacheAnnotation({ ttl: 60 })); + // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' + meta.annotate(auditAnnotation({ actor: 'system' })); + }); }); - test('the return type is not widened by the variadic argument', () => { - const result = userCollection.all(cacheAnnotation({ ttl: 60 })); - // The return type is AsyncIterableResult regardless of annotations. + test('the configurator does not widen the terminal return type', () => { + const result = userCollection.all((meta) => meta.annotate(cacheAnnotation({ ttl: 60 }))); expectTypeOf(result).toHaveProperty('toArray'); expectTypeOf(result.toArray).returns.toMatchTypeOf>(); }); + + test('configurator parameter is typed as MetaBuilder<"read">', () => { + userCollection.all((meta) => { + expectTypeOf(meta).toEqualTypeOf>(); + }); + }); }); describe('Collection.first (read-typed)', () => { - test('accepts a read-only annotation with no filter', () => { - userCollection.first(cacheAnnotation({ ttl: 60 })); + test('accepts a configurator after a function filter', () => { + userCollection.first( + (user) => user.name.eq('Alice'), + (meta) => meta.annotate(cacheAnnotation({ ttl: 60 })), + ); }); - test('accepts a read-only annotation after a function filter', () => { - userCollection.first((user) => user.name.eq('Alice'), cacheAnnotation({ ttl: 60 })); + test('accepts a configurator after a shorthand filter', () => { + userCollection.first({ name: 'Alice' }, (meta) => meta.annotate(cacheAnnotation({ ttl: 60 }))); }); - test('accepts a read-only annotation after a shorthand filter', () => { - userCollection.first({ name: 'Alice' }, cacheAnnotation({ ttl: 60 })); + test('accepts a configurator with explicit undefined filter', () => { + userCollection.first(undefined, (meta) => meta.annotate(cacheAnnotation({ ttl: 60 }))); }); - test('accepts multiple compatible annotations after a filter', () => { + test('accepts a configurator that chains multiple compatible annotations after a filter', () => { userCollection.first( (user) => user.name.eq('Alice'), - cacheAnnotation({ ttl: 60 }), - otelAnnotation({ traceId: 't' }), + (meta) => + meta.annotate(cacheAnnotation({ ttl: 60 })).annotate(otelAnnotation({ traceId: 't' })), ); }); - test('accepts zero annotations (empty variadic, no filter)', () => { + test('accepts no arguments at all', () => { userCollection.first(); }); - test('accepts a function filter without annotations', () => { + test('accepts a function filter without a configurator', () => { userCollection.first((user) => user.name.eq('Alice')); }); - test('rejects a write-only annotation (negative)', () => { - // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' - userCollection.first(auditAnnotation({ actor: 'system' })); + test('rejects a configurator that applies a write-only annotation (negative)', () => { + userCollection.first({ name: 'Alice' }, (meta) => + // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' + meta.annotate(auditAnnotation({ actor: 'system' })), + ); }); - test('rejects a write-only annotation after a shorthand filter (negative)', () => { - // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' - userCollection.first({ name: 'Alice' }, auditAnnotation({ actor: 'system' })); + test('rejects a configurator with explicit undefined filter that applies a write-only annotation (negative)', () => { + userCollection.first(undefined, (meta) => + // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' + meta.annotate(auditAnnotation({ actor: 'system' })), + ); }); test('the return type is Promise', () => { - const result = userCollection.first(cacheAnnotation({ ttl: 60 })); + const result = userCollection.first({ name: 'Alice' }, (meta) => + meta.annotate(cacheAnnotation({ ttl: 60 })), + ); expectTypeOf(result).resolves.toMatchTypeOf | null>(); }); }); describe('Collection has no chainable .annotate (intentional scope cut)', () => { - // Annotations attach via terminal arguments only — there is no - // chainable `.annotate(...)` on Collection. This is the spec OQ 1 - // resolution: the applicability gate at the terminal makes a - // chainable form fight the per-terminal kind binding. + // Annotations attach via the configurator argument only — there is no + // chainable `.annotate(...)` on Collection. The configurator lives on + // `MetaBuilder` constructed by the terminal; the kind is bound by + // the terminal's operation kind, so a chainable form on Collection + // would have fought the per-terminal kind binding. test('Collection does not expose an annotate method', () => { type Keys = keyof Collection; type HasAnnotate = 'annotate' extends Keys ? true : false; @@ -132,38 +157,22 @@ describe('Collection has no chainable .annotate (intentional scope cut)', () => }); describe('annotation handle types are preserved through the lane', () => { - // The handle's payload type survives the gate — this is the same - // property exercised in the framework-components type-d tests, but - // verified here at the ORM lane to ensure no widening happens - // through the Collection.all/first signature. - test('cacheAnnotation construction is assignable through to the terminal', () => { + // The handle's payload type survives the gate — same property the + // framework-components type-d tests verify, exercised here at the ORM + // lane to ensure no widening through the configurator argument. + test('cacheAnnotation construction is assignable through the configurator', () => { const value = cacheAnnotation({ ttl: 60 }); expectTypeOf(value).toMatchTypeOf>(); - userCollection.all(value); - }); - - test('an inferred annotation tuple preserves per-element typing', () => { - function passthrough[]>( - ...annotations: As - ): As { - return annotations; - } - const tuple = passthrough(cacheAnnotation({ ttl: 60 }), otelAnnotation({ traceId: 't' })); - expectTypeOf(tuple).toMatchTypeOf< - readonly [ - AnnotationValue<{ ttl: number; skip?: boolean }, 'read'>, - AnnotationValue<{ traceId: string }, 'read' | 'write'>, - ] - >(); + userCollection.all((meta) => meta.annotate(value)); }); }); // --------------------------------------------------------------------------- // Write terminals // -// The contract is symmetrical to the read terminals: each write terminal -// accepts write-only and both-kind annotations, rejects read-only ones at -// the type level, preserves its return type, and accepts an empty variadic. +// Symmetrical contract: each write terminal accepts a configurator whose +// `meta.annotate(...)` admits write-only and both-kind annotations and +// rejects read-only ones at the type level. Return types are preserved. // --------------------------------------------------------------------------- declare const userCollectionWithWhere: Collection< @@ -179,106 +188,106 @@ declare const userCollectionWithWhere: Collection< >; describe('Collection.create (write-typed)', () => { - test('accepts a write-only annotation', () => { - userCollection.create( - { id: 1, name: 'Alice', email: 'a@b.com' }, - auditAnnotation({ actor: 'system' }), + test('accepts a configurator that applies a write-only annotation', () => { + userCollection.create({ id: 1, name: 'Alice', email: 'a@b.com' }, (meta) => + meta.annotate(auditAnnotation({ actor: 'system' })), ); }); - test('accepts a both-kind annotation', () => { - userCollection.create( - { id: 1, name: 'Alice', email: 'a@b.com' }, - otelAnnotation({ traceId: 't' }), + test('accepts a configurator that applies a both-kind annotation', () => { + userCollection.create({ id: 1, name: 'Alice', email: 'a@b.com' }, (meta) => + meta.annotate(otelAnnotation({ traceId: 't' })), ); }); - test('accepts zero annotations (empty variadic)', () => { + test('accepts an omitted configurator', () => { userCollection.create({ id: 1, name: 'Alice', email: 'a@b.com' }); }); - test('rejects a read-only annotation (negative)', () => { - userCollection.create( - { id: 1, name: 'Alice', email: 'a@b.com' }, + test('rejects a configurator that applies a read-only annotation (negative)', () => { + userCollection.create({ id: 1, name: 'Alice', email: 'a@b.com' }, (meta) => // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' - cacheAnnotation({ ttl: 60 }), + meta.annotate(cacheAnnotation({ ttl: 60 })), ); }); - test('rejects a mix containing a read-only annotation (negative)', () => { - // biome-ignore format: keep on one line so @ts-expect-error attaches to the call - // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' - userCollection.create({ id: 1, name: 'Alice', email: 'a@b.com' }, auditAnnotation({ actor: 'system' }), cacheAnnotation({ ttl: 60 })); + test('rejects a configurator that mixes in a read-only annotation (negative)', () => { + userCollection.create({ id: 1, name: 'Alice', email: 'a@b.com' }, (meta) => { + meta.annotate(auditAnnotation({ actor: 'system' })); + // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' + meta.annotate(cacheAnnotation({ ttl: 60 })); + }); }); test('the return type is Promise', () => { - const result = userCollection.create( - { id: 1, name: 'Alice', email: 'a@b.com' }, - auditAnnotation({ actor: 'system' }), + const result = userCollection.create({ id: 1, name: 'Alice', email: 'a@b.com' }, (meta) => + meta.annotate(auditAnnotation({ actor: 'system' })), ); expectTypeOf(result).resolves.toMatchTypeOf>(); }); + + test('configurator parameter is typed as MetaBuilder<"write">', () => { + userCollection.create({ id: 1, name: 'Alice', email: 'a@b.com' }, (meta) => { + expectTypeOf(meta).toEqualTypeOf>(); + }); + }); }); describe('Collection.createAll (write-typed)', () => { - test('accepts a write-only annotation', () => { - userCollection.createAll( - [{ id: 1, name: 'Alice', email: 'a@b.com' }], - auditAnnotation({ actor: 'system' }), + test('accepts a configurator that applies a write-only annotation', () => { + userCollection.createAll([{ id: 1, name: 'Alice', email: 'a@b.com' }], (meta) => + meta.annotate(auditAnnotation({ actor: 'system' })), ); }); - test('accepts zero annotations (empty variadic)', () => { + test('accepts an omitted configurator', () => { userCollection.createAll([{ id: 1, name: 'Alice', email: 'a@b.com' }]); }); - test('rejects a read-only annotation (negative)', () => { - userCollection.createAll( - [{ id: 1, name: 'Alice', email: 'a@b.com' }], + test('rejects a configurator that applies a read-only annotation (negative)', () => { + userCollection.createAll([{ id: 1, name: 'Alice', email: 'a@b.com' }], (meta) => // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' - cacheAnnotation({ ttl: 60 }), + meta.annotate(cacheAnnotation({ ttl: 60 })), ); }); }); describe('Collection.createCount (write-typed)', () => { - test('accepts a write-only annotation', () => { - userCollection.createCount( - [{ id: 1, name: 'Alice', email: 'a@b.com' }], - auditAnnotation({ actor: 'system' }), + test('accepts a configurator that applies a write-only annotation', () => { + userCollection.createCount([{ id: 1, name: 'Alice', email: 'a@b.com' }], (meta) => + meta.annotate(auditAnnotation({ actor: 'system' })), ); }); - test('rejects a read-only annotation (negative)', () => { - userCollection.createCount( - [{ id: 1, name: 'Alice', email: 'a@b.com' }], + test('rejects a configurator that applies a read-only annotation (negative)', () => { + userCollection.createCount([{ id: 1, name: 'Alice', email: 'a@b.com' }], (meta) => // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' - cacheAnnotation({ ttl: 60 }), + meta.annotate(cacheAnnotation({ ttl: 60 })), ); }); test('the return type is Promise', () => { const result = userCollection.createCount( [{ id: 1, name: 'Alice', email: 'a@b.com' }], - auditAnnotation({ actor: 'system' }), + (meta) => meta.annotate(auditAnnotation({ actor: 'system' })), ); expectTypeOf(result).resolves.toBeNumber(); }); }); describe('Collection.upsert (write-typed)', () => { - test('accepts a write-only annotation', () => { + test('accepts a configurator that applies a write-only annotation', () => { userCollection.upsert( { create: { id: 1, name: 'Alice', email: 'a@b.com' }, update: { name: 'Alice' }, conflictOn: { id: 1 }, }, - auditAnnotation({ actor: 'system' }), + (meta) => meta.annotate(auditAnnotation({ actor: 'system' })), ); }); - test('rejects a read-only annotation (negative)', () => { + test('rejects a configurator that applies a read-only annotation (negative)', () => { userCollection.upsert( { create: { id: 1, name: 'Alice', email: 'a@b.com' }, @@ -286,7 +295,7 @@ describe('Collection.upsert (write-typed)', () => { conflictOn: { id: 1 }, }, // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' - cacheAnnotation({ ttl: 60 }), + (meta) => meta.annotate(cacheAnnotation({ ttl: 60 })), ); }); }); @@ -295,126 +304,133 @@ describe('Collection.update / .updateAll / .updateCount (write-typed)', () => { // Update terminals require the receiver to satisfy the // `State['hasWhere'] extends true` gate, so we use a separately- // declared `userCollectionWithWhere` whose State is post-where. - test('update accepts a write-only annotation', () => { - userCollectionWithWhere.update({ name: 'Alice' }, auditAnnotation({ actor: 'system' })); + test('update accepts a configurator that applies a write-only annotation', () => { + userCollectionWithWhere.update({ name: 'Alice' }, (meta) => + meta.annotate(auditAnnotation({ actor: 'system' })), + ); }); - test('update rejects a read-only annotation (negative)', () => { - userCollectionWithWhere.update( - { name: 'Alice' }, + test('update rejects a configurator that applies a read-only annotation (negative)', () => { + userCollectionWithWhere.update({ name: 'Alice' }, (meta) => // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' - cacheAnnotation({ ttl: 60 }), + meta.annotate(cacheAnnotation({ ttl: 60 })), ); }); - test('updateAll accepts a write-only annotation', () => { - userCollectionWithWhere.updateAll({ name: 'Alice' }, auditAnnotation({ actor: 'system' })); + test('updateAll accepts a configurator that applies a write-only annotation', () => { + userCollectionWithWhere.updateAll({ name: 'Alice' }, (meta) => + meta.annotate(auditAnnotation({ actor: 'system' })), + ); }); - test('updateAll rejects a read-only annotation (negative)', () => { - userCollectionWithWhere.updateAll( - { name: 'Alice' }, + test('updateAll rejects a configurator that applies a read-only annotation (negative)', () => { + userCollectionWithWhere.updateAll({ name: 'Alice' }, (meta) => // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' - cacheAnnotation({ ttl: 60 }), + meta.annotate(cacheAnnotation({ ttl: 60 })), ); }); - test('updateCount accepts a write-only annotation', () => { - userCollectionWithWhere.updateCount({ name: 'Alice' }, auditAnnotation({ actor: 'system' })); + test('updateCount accepts a configurator that applies a write-only annotation', () => { + userCollectionWithWhere.updateCount({ name: 'Alice' }, (meta) => + meta.annotate(auditAnnotation({ actor: 'system' })), + ); }); - test('updateCount rejects a read-only annotation (negative)', () => { - userCollectionWithWhere.updateCount( - { name: 'Alice' }, + test('updateCount rejects a configurator that applies a read-only annotation (negative)', () => { + userCollectionWithWhere.updateCount({ name: 'Alice' }, (meta) => // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' - cacheAnnotation({ ttl: 60 }), + meta.annotate(cacheAnnotation({ ttl: 60 })), ); }); test('updateCount returns Promise', () => { - const result = userCollectionWithWhere.updateCount( - { name: 'Alice' }, - auditAnnotation({ actor: 'system' }), + const result = userCollectionWithWhere.updateCount({ name: 'Alice' }, (meta) => + meta.annotate(auditAnnotation({ actor: 'system' })), ); expectTypeOf(result).resolves.toBeNumber(); }); }); describe('Collection.delete / .deleteAll / .deleteCount (write-typed)', () => { - test('delete accepts a write-only annotation', () => { - userCollectionWithWhere.delete(auditAnnotation({ actor: 'system' })); + test('delete accepts a configurator that applies a write-only annotation', () => { + userCollectionWithWhere.delete((meta) => meta.annotate(auditAnnotation({ actor: 'system' }))); }); - test('delete rejects a read-only annotation (negative)', () => { - // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' - userCollectionWithWhere.delete(cacheAnnotation({ ttl: 60 })); + test('delete rejects a configurator that applies a read-only annotation (negative)', () => { + userCollectionWithWhere.delete((meta) => + // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' + meta.annotate(cacheAnnotation({ ttl: 60 })), + ); }); - test('deleteAll accepts a write-only annotation', () => { - userCollectionWithWhere.deleteAll(auditAnnotation({ actor: 'system' })); + test('deleteAll accepts a configurator that applies a write-only annotation', () => { + userCollectionWithWhere.deleteAll((meta) => + meta.annotate(auditAnnotation({ actor: 'system' })), + ); }); - test('deleteAll rejects a read-only annotation (negative)', () => { - // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' - userCollectionWithWhere.deleteAll(cacheAnnotation({ ttl: 60 })); + test('deleteAll rejects a configurator that applies a read-only annotation (negative)', () => { + userCollectionWithWhere.deleteAll((meta) => + // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' + meta.annotate(cacheAnnotation({ ttl: 60 })), + ); }); - test('deleteCount accepts a write-only annotation', () => { - userCollectionWithWhere.deleteCount(auditAnnotation({ actor: 'system' })); + test('deleteCount accepts a configurator that applies a write-only annotation', () => { + userCollectionWithWhere.deleteCount((meta) => + meta.annotate(auditAnnotation({ actor: 'system' })), + ); }); - test('deleteCount rejects a read-only annotation (negative)', () => { - // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' - userCollectionWithWhere.deleteCount(cacheAnnotation({ ttl: 60 })); + test('deleteCount rejects a configurator that applies a read-only annotation (negative)', () => { + userCollectionWithWhere.deleteCount((meta) => + // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' + meta.annotate(cacheAnnotation({ ttl: 60 })), + ); }); }); // --------------------------------------------------------------------------- // Aggregate terminals (read-typed) // -// Both `Collection.aggregate(fn, ...annotations)` and -// `GroupedCollection.aggregate(fn, ...annotations)` are read terminals that -// run a single SQL aggregation query and accept user annotations after the +// Both `Collection.aggregate(fn, configure?)` and +// `GroupedCollection.aggregate(fn, configure?)` are read terminals that run +// a single SQL aggregation query and accept a configurator after the // builder callback. // --------------------------------------------------------------------------- describe('Collection.aggregate (read-typed)', () => { - test('accepts a read-only annotation', () => { + test('accepts a configurator that applies a read-only annotation', () => { userCollection.aggregate( (aggregate) => ({ count: aggregate.count() }), - cacheAnnotation({ ttl: 60 }), + (meta) => meta.annotate(cacheAnnotation({ ttl: 60 })), ); }); - test('accepts a both-kind annotation', () => { + test('accepts a configurator that applies a both-kind annotation', () => { userCollection.aggregate( (aggregate) => ({ count: aggregate.count() }), - otelAnnotation({ traceId: 't' }), + (meta) => meta.annotate(otelAnnotation({ traceId: 't' })), ); }); - test('accepts zero annotations (empty variadic)', () => { + test('accepts an omitted configurator', () => { userCollection.aggregate((aggregate) => ({ count: aggregate.count() })); }); - test('rejects a write-only annotation (negative)', () => { + test('rejects a configurator that applies a write-only annotation (negative)', () => { userCollection.aggregate( (aggregate) => ({ count: aggregate.count() }), - // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' - auditAnnotation({ actor: 'system' }), + (meta) => + // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' + meta.annotate(auditAnnotation({ actor: 'system' })), ); }); - test('rejects a mix containing a write-only annotation (negative)', () => { - // biome-ignore format: keep on one line so @ts-expect-error attaches to the call - // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' - userCollection.aggregate((aggregate) => ({ count: aggregate.count() }), cacheAnnotation({ ttl: 60 }), auditAnnotation({ actor: 'system' })); - }); - test('the aggregation spec type is preserved through the gate', () => { const result = userCollection.aggregate( (aggregate) => ({ count: aggregate.count() }), - cacheAnnotation({ ttl: 60 }), + (meta) => meta.annotate(cacheAnnotation({ ttl: 60 })), ); expectTypeOf(result).resolves.toMatchTypeOf<{ count: number }>(); }); @@ -423,29 +439,30 @@ describe('Collection.aggregate (read-typed)', () => { declare const userGroupedCollection: GroupedCollection; describe('GroupedCollection.aggregate (read-typed)', () => { - test('accepts a read-only annotation', () => { + test('accepts a configurator that applies a read-only annotation', () => { userGroupedCollection.aggregate( (aggregate) => ({ count: aggregate.count() }), - cacheAnnotation({ ttl: 60 }), + (meta) => meta.annotate(cacheAnnotation({ ttl: 60 })), ); }); - test('accepts a both-kind annotation', () => { + test('accepts a configurator that applies a both-kind annotation', () => { userGroupedCollection.aggregate( (aggregate) => ({ count: aggregate.count() }), - otelAnnotation({ traceId: 't' }), + (meta) => meta.annotate(otelAnnotation({ traceId: 't' })), ); }); - test('accepts zero annotations (empty variadic)', () => { + test('accepts an omitted configurator', () => { userGroupedCollection.aggregate((aggregate) => ({ count: aggregate.count() })); }); - test('rejects a write-only annotation (negative)', () => { + test('rejects a configurator that applies a write-only annotation (negative)', () => { userGroupedCollection.aggregate( (aggregate) => ({ count: aggregate.count() }), - // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' - auditAnnotation({ actor: 'system' }), + (meta) => + // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' + meta.annotate(auditAnnotation({ actor: 'system' })), ); }); }); diff --git a/projects/middleware-intercept-and-cache/api-revision-meta-callback.md b/projects/middleware-intercept-and-cache/api-revision-meta-callback.md index f6cd51ef4f..fca3b63b17 100644 --- a/projects/middleware-intercept-and-cache/api-revision-meta-callback.md +++ b/projects/middleware-intercept-and-cache/api-revision-meta-callback.md @@ -1,6 +1,6 @@ # API revision: ORM terminal annotations as a meta-callback -**Status:** planned. Supersedes the variadic `...annotations` shape on ORM terminals shipped in M2 and pinned by `spec.md` Functional Requirement #6. +**Status:** landed. Supersedes the variadic `...annotations` shape on ORM terminals shipped in M2 and pinned by `spec.md` Functional Requirement #6. **Scope:** ORM `Collection` and `GroupedCollection` terminals only. SQL DSL `.annotate(...)` is unaffected (see "Why SQL DSL is out of scope" below). @@ -11,17 +11,21 @@ Replace the variadic `...annotations: As & ValidAnnotations` last argumen ```typescript // Before (shipped in M2) await db.orm.User.all(cacheAnnotation({ ttl })); -await db.orm.User.where({ id }).find(cacheAnnotation({ ttl })); -await db.orm.User.find({ id }, cacheAnnotation({ ttl })); +await db.orm.User.first({ id }, cacheAnnotation({ ttl })); +await db.orm.User.where({ id }).first(cacheAnnotation({ ttl })); // After await db.orm.User.all((meta) => meta.annotate(cacheAnnotation({ ttl }))); -await db.orm.User.where({ id }).find((meta) => meta.annotate(cacheAnnotation({ ttl }))); -await db.orm.User.find({ id }, (meta) => meta.annotate(cacheAnnotation({ ttl }))); +await db.orm.User.first({ id }, (meta) => meta.annotate(cacheAnnotation({ ttl }))); +await db.orm.User.where({ id }).first(undefined, (meta) => + meta.annotate(cacheAnnotation({ ttl })), +); ``` The callback receives a `MetaBuilder` whose `annotate(annotation)` method takes one annotation, validates it eagerly against the terminal's operation kind `K`, records it, and returns the builder for chaining. +**Note on `first(...)` shape.** `first` keeps its existing positional dispatch — a single function arg is interpreted as a filter callback (`first((model) => model.field.eq(...))`), matching shipped semantics. To attach a configurator without a filter, pass `undefined` as the first argument: `first(undefined, (meta) => …)`. The runtime cannot disambiguate "single function = filter vs. single function = configurator" without invoking it; explicit `undefined` keeps positional dispatch unambiguous and side-effect-free. The original spec example `where({ id }).first((meta) => …)` therefore becomes `where({ id }).first(undefined, (meta) => …)` — a minor ergonomic concession compared to a probe-based dispatcher, but the implementation is simpler and the semantics are predictable. + ## Why **The variadic forecloses on growth.** A terminal whose last positional argument is a variadic of annotations cannot grow new positional or named per-query options without a breaking change. There is no future shape `db.orm.User.find(input, { … }, ann1, ann2)` we can evolve into without churning every call site, and the variadic-tuple inference rules make alternatives like `find(input, { annotations: [ann1, ann2], timeout: 5_000 })` lose the applicability gate that the `As & ValidAnnotations` intersection currently enforces. The callback shape sidesteps both: per-query options become methods on `MetaBuilder`, and adding a method (`meta.timeout(...)`, `meta.tag(...)`, `meta.cancellation(signal)`) is a non-breaking surface extension. @@ -77,7 +81,7 @@ all(configure?: (meta: MetaBuilder<'read'>) => void): AsyncIterableResult; ``` ```typescript -// Before — three overloads with variadic annotations + a runtime branch on isAnnotationValue +// Before — six overloads with variadic annotations + a runtime branch on isAnnotationValue async first(): Promise; async first(filter: (model: ModelAccessor<…>) => WhereArg): Promise; async first(filter: ShorthandWhereFilter<…>): Promise; @@ -85,10 +89,18 @@ async first(...annotations: As & ValidAnnotations<'read', As>): async first(filter: (model: …) => WhereArg, ...annotations: As & ValidAnnotations<'read', As>): Promise; async first(filter: ShorthandWhereFilter<…>, ...annotations: As & ValidAnnotations<'read', As>): Promise; -// After — two overloads, no runtime ambiguity -async first(configure?: (meta: MetaBuilder<'read'>) => void): Promise; +// After — four overloads, no runtime ambiguity (positional dispatch only) +async first(): Promise; +async first( + filter: undefined, + configure: (meta: MetaBuilder<'read'>) => void, +): Promise; +async first( + filter: (model: ModelAccessor<…>) => WhereArg, + configure?: (meta: MetaBuilder<'read'>) => void, +): Promise; async first( - filter: ((model: ModelAccessor<…>) => WhereArg) | ShorthandWhereFilter<…>, + filter: ShorthandWhereFilter<…>, configure?: (meta: MetaBuilder<'read'>) => void, ): Promise; ```