@cosmicdrift/kumiko-framework 0.338.0 → 0.340.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/api/auth-middleware.d.ts +1 -0
  2. package/dist/api/auth-middleware.js +54 -10
  3. package/dist/api/routes.d.ts +2 -1
  4. package/dist/api/routes.js +29 -6
  5. package/dist/api/server.js +9 -1
  6. package/dist/bun-db/index.d.ts +2 -2
  7. package/dist/bun-db/index.js +1 -1
  8. package/dist/bun-db/query.d.ts +25 -1
  9. package/dist/bun-db/query.js +144 -17
  10. package/dist/crypto/event-pii.js +13 -4
  11. package/dist/crypto/index.d.ts +1 -1
  12. package/dist/crypto/index.js +1 -1
  13. package/dist/crypto/pii-ciphertext-format.d.ts +4 -0
  14. package/dist/crypto/pii-ciphertext-format.js +11 -0
  15. package/dist/crypto/pii-field-encryption.d.ts +4 -2
  16. package/dist/crypto/pii-field-encryption.js +50 -21
  17. package/dist/crypto/system-event-pii.js +2 -2
  18. package/dist/db/queries/backfill-pii.js +4 -0
  19. package/dist/db/query-api.d.ts +1 -1
  20. package/dist/db/query.d.ts +1 -1
  21. package/dist/db/tenant-db.js +16 -12
  22. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  23. package/dist/engine/boot-validator/entity-handler.js +19 -0
  24. package/dist/engine/boot-validator/pii-retention.js +30 -0
  25. package/dist/engine/define-handler.js +1 -0
  26. package/dist/engine/extensions/user-data.d.ts +1 -1
  27. package/dist/engine/feature-ast/extractors/handlers.js +1 -0
  28. package/dist/engine/feature-entity-handlers.js +1 -0
  29. package/dist/engine/feature-manifest.d.ts +7 -0
  30. package/dist/engine/feature-manifest.js +8 -0
  31. package/dist/engine/index.d.ts +2 -1
  32. package/dist/engine/index.js +1 -0
  33. package/dist/engine/registry-ingest.d.ts +1 -0
  34. package/dist/engine/registry-ingest.js +9 -0
  35. package/dist/engine/registry.js +2 -1
  36. package/dist/engine/steps/retry.js +0 -1
  37. package/dist/engine/steps/unsafe-projection-upsert.js +1 -1
  38. package/dist/engine/steps/wait-for-event.js +0 -1
  39. package/dist/engine/steps/wait.js +0 -4
  40. package/dist/engine/steps/workflow-system-events.d.ts +2 -0
  41. package/dist/engine/steps/workflow-system-events.js +89 -0
  42. package/dist/pipeline/append-event-core.d.ts +3 -1
  43. package/dist/pipeline/append-event-core.js +8 -4
  44. package/dist/pipeline/dispatch-shared.js +5 -2
  45. package/package.json +4 -4
  46. package/src/changes.json +48 -0
@@ -23,6 +23,7 @@ export type AuthMiddlewareOptions = {
23
23
  readonly anonymousAccess?: AnonymousAccessResolved;
24
24
  readonly resolveTenantLifecycleStatus?: TenantLifecycleStatusResolver;
25
25
  readonly foreignCookieOrigins?: ReadonlySet<string>;
26
+ readonly isTenantlessAnonymousWrite?: (handlerQn: string) => boolean;
26
27
  };
27
28
  export type TenantResolver = (c: Context) => Promise<TenantId | null> | TenantId | null;
28
29
  export type TenantExists = (tenantId: TenantId) => Promise<boolean> | boolean;
@@ -1,7 +1,7 @@
1
1
  import { getCookie } from "hono/cookie";
2
2
  import { TENANT_TEARDOWN_STATUSES } from "../engine/active-membership.js";
3
3
  import { createAnonymousUser } from "../engine/system-user.js";
4
- import { parseTenantId } from "../engine/types/identifiers.js";
4
+ import { parseTenantId, SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
5
5
  import { isSessionRequiredApiPath, TENANT_COOKIE_NAME, TENANT_HEADER_NAME, } from "./api-constants.js";
6
6
  import { isForeignCookieOrigin } from "./origin-middleware.js";
7
7
  const USER_KEY = "pipelineUser";
@@ -78,7 +78,7 @@ function extractToken(c) {
78
78
  return { error: "missing" };
79
79
  }
80
80
  export function authMiddleware(jwt, options = {}) {
81
- const { sessionChecker, anonymousAccess, tokenVerifier, resolveTenantLifecycleStatus, foreignCookieOrigins, } = options;
81
+ const { sessionChecker, anonymousAccess, tokenVerifier, resolveTenantLifecycleStatus, foreignCookieOrigins, isTenantlessAnonymousWrite, } = options;
82
82
  // Fail loud at boot, not silently at request time: a tenantResolver
83
83
  // without a declared resolverTrust is an ambiguous trust decision no
84
84
  // sane default can make on the app's behalf (see AnonymousAccessConfig).
@@ -87,6 +87,7 @@ export function authMiddleware(jwt, options = {}) {
87
87
  'declare "authoritative" (resolver wins over client header/cookie) or ' +
88
88
  '"fallback-only" (client wins, resolver is a last resort).');
89
89
  }
90
+ const anonymousExtras = { isTenantlessAnonymousWrite };
90
91
  return async (c, next) => {
91
92
  const extracted = extractToken(c);
92
93
  if ("error" in extracted) {
@@ -117,7 +118,7 @@ export function authMiddleware(jwt, options = {}) {
117
118
  i18nKey: "auth.errors.missingToken",
118
119
  });
119
120
  }
120
- return await handleAnonymous(c, anonymousAccess, next, resolveTenantLifecycleStatus);
121
+ return await handleAnonymous(c, anonymousAccess, next, resolveTenantLifecycleStatus, anonymousExtras);
121
122
  }
122
123
  return middlewareReject(c, {
123
124
  code: "missing_token",
@@ -136,7 +137,7 @@ export function authMiddleware(jwt, options = {}) {
136
137
  !c.req.path.startsWith("/api/auth/") &&
137
138
  foreignCookieOrigins &&
138
139
  isForeignCookieOrigin(c, foreignCookieOrigins)) {
139
- return await handleAnonymous(c, anonymousAccess, next, resolveTenantLifecycleStatus);
140
+ return await handleAnonymous(c, anonymousAccess, next, resolveTenantLifecycleStatus, anonymousExtras);
140
141
  }
141
142
  // Generic bearer-verifier path: try the wired tokenVerifier (PAT, future
142
143
  // JWT-provider, ...) BEFORE jwt.verify. A hit short-circuits the JWT path
@@ -153,7 +154,7 @@ export function authMiddleware(jwt, options = {}) {
153
154
  const rejectInvalidCredential = async (rejection) => {
154
155
  if (isStaleCookieDowngradable(c, transport, anonymousAccess)) {
155
156
  return await handleAnonymous(c, anonymousAccess, next, resolveTenantLifecycleStatus, {
156
- rejectionIfTenantUnresolved: rejection,
157
+ staleCredential: { rejectionIfTenantUnresolved: rejection },
157
158
  });
158
159
  }
159
160
  return middlewareReject(c, rejection);
@@ -271,7 +272,8 @@ async function handleVerifiedBearerUser(c, user, next, resolveTenantLifecycleSta
271
272
  // 5. Synthesise the anonymous SessionUser. The transport flag stays unset
272
273
  // so csrf-middleware skips the double-submit check (no auth-cookie ⇒
273
274
  // no CSRF vector to defend against).
274
- async function handleAnonymous(c, config, next, resolveTenantLifecycleStatus, staleCredential) {
275
+ async function handleAnonymous(c, config, next, resolveTenantLifecycleStatus, extras = {}) {
276
+ const { staleCredential, isTenantlessAnonymousWrite } = extras;
275
277
  // A downgraded stale credential whose anonymous tenant can't be resolved or
276
278
  // is torn down answers with the credential's own 401: a tenant error would
277
279
  // hide the session end from the client.
@@ -292,11 +294,14 @@ async function handleAnonymous(c, config, next, resolveTenantLifecycleStatus, st
292
294
  ? { id: cookieCheck.tenantId, source: "cookie" }
293
295
  : null;
294
296
  // Step 3: pick the authoritative tenant.
295
- const resolved = await resolveTenant(c, config, clientTenant);
297
+ const resolved = await resolveTenantOrTenantless(c, config, clientTenant, {
298
+ allowTenantless: staleCredential === undefined,
299
+ isTenantlessAnonymousWrite,
300
+ });
296
301
  if ("error" in resolved)
297
302
  return rejectTenant(resolved.error);
298
303
  // Step 4: existence check for untrusted sources.
299
- if (resolved.source !== "default" && config.tenantExists) {
304
+ if (resolved.source !== "default" && resolved.source !== "tenantless" && config.tenantExists) {
300
305
  const exists = await config.tenantExists(resolved.tenantId);
301
306
  if (!exists) {
302
307
  return rejectTenant({
@@ -308,8 +313,10 @@ async function handleAnonymous(c, config, next, resolveTenantLifecycleStatus, st
308
313
  });
309
314
  }
310
315
  }
311
- // Step 5: synthesise + continue.
312
- const lifecycleReject = await rejectIfTenantTeardown(c, resolved.tenantId, resolveTenantLifecycleStatus);
316
+ // Step 5: synthesise + continue. SYSTEM_TENANT_ID is no lifecycle-managed tenant.
317
+ const lifecycleReject = resolved.source === "tenantless"
318
+ ? undefined
319
+ : await rejectIfTenantTeardown(c, resolved.tenantId, resolveTenantLifecycleStatus);
313
320
  if (lifecycleReject) {
314
321
  return staleCredential
315
322
  ? middlewareReject(c, staleCredential.rejectionIfTenantUnresolved)
@@ -321,6 +328,43 @@ async function handleAnonymous(c, config, next, resolveTenantLifecycleStatus, st
321
328
  // when next() ran; explicit return makes the union return-type honest.
322
329
  return;
323
330
  }
331
+ // The exemption applies only where resolveTenant answers tenant_required:
332
+ // never on tenant_mismatch, never when the client named a tenant via X-Tenant,
333
+ // never for a downgraded stale credential. An ambient tenant cookie does not
334
+ // block it: tenant_required with a client tenant only happens when an
335
+ // authoritative resolver stayed silent, and that path ignores the cookie
336
+ // anyway, so a customer who also uses the app on this host can still cancel.
337
+ async function resolveTenantOrTenantless(c, config, clientTenant, tenantless) {
338
+ const resolved = await resolveTenant(c, config, clientTenant);
339
+ const { isTenantlessAnonymousWrite } = tenantless;
340
+ if ("error" in resolved &&
341
+ resolved.error.code === "tenant_required" &&
342
+ clientTenant?.source !== "header" &&
343
+ tenantless.allowTenantless &&
344
+ isTenantlessAnonymousWrite !== undefined &&
345
+ (await isFlaggedTenantlessWrite(c, isTenantlessAnonymousWrite))) {
346
+ return { tenantId: SYSTEM_TENANT_ID, source: "tenantless" };
347
+ }
348
+ return resolved;
349
+ }
350
+ // Body peek for the tenantless exemption: only a single-command POST
351
+ // /api/write qualifies, never /api/batch. Parsing the clone leaves the body
352
+ // intact for the dispatcher; the body-size limit already ran upstream.
353
+ async function isFlaggedTenantlessWrite(c, isTenantlessAnonymousWrite) {
354
+ if (c.req.method !== "POST" || c.req.path !== "/api/write")
355
+ return false;
356
+ try {
357
+ const body = await c.req.raw.clone().json();
358
+ return (typeof body === "object" &&
359
+ body !== null &&
360
+ "type" in body &&
361
+ typeof body.type === "string" &&
362
+ isTenantlessAnonymousWrite(body.type));
363
+ }
364
+ catch {
365
+ return false;
366
+ }
367
+ }
324
368
  // Validates an X-Tenant / cookie value against the tenantId format. Returns
325
369
  // `{tenantId: null}` when absent, `{tenantId: TenantId}` when valid, or
326
370
  // `{error: …}` when the value is non-empty junk (so the caller can return
@@ -1,4 +1,4 @@
1
- import { Hono } from "hono";
1
+ import { type Context, Hono } from "hono";
2
2
  import type { Dispatcher } from "../pipeline/dispatcher.js";
3
3
  export declare const StreamFrame: {
4
4
  readonly chunk: "chunk";
@@ -18,3 +18,4 @@ export type SseWriter = {
18
18
  };
19
19
  export declare function pumpStream(stream: SseWriter, generator: AsyncGenerator<unknown>, heartbeatMs: number, firstPull?: Promise<IteratorResult<unknown>>): Promise<void>;
20
20
  export declare const MAX_PAYLOAD_DEPTH = 20;
21
+ export declare function handleUncaughtRouteError(err: Error, c: Context): Response | Promise<Response>;
@@ -1,4 +1,5 @@
1
1
  import { Hono } from "hono";
2
+ import { HTTPException } from "hono/http-exception";
2
3
  import { streamSSE } from "hono/streaming";
3
4
  import { AccessDeniedError, reraiseAsKumikoError, serializeError, toKumikoError, ValidationError, } from "../errors/index.js";
4
5
  import { createFallbackLogger } from "../logging/index.js";
@@ -356,17 +357,39 @@ function writeErrorResponse(c, err, type) {
356
357
  const { error } = serializeError(err, requestId);
357
358
  return c.json({ isSuccess: false, error }, err.httpStatus); // @cast-boundary engine-payload
358
359
  }
360
+ function clientAbortResponse(c, requestId, type) {
361
+ logClientWarn("request aborted by client", requestId, type, {
362
+ status: CLIENT_CLOSED_REQUEST_STATUS,
363
+ });
364
+ return c.body(null, CLIENT_CLOSED_REQUEST_STATUS); // @cast-boundary non-standard client-closed-request status, Hono's union doesn't include it
365
+ }
359
366
  // For /query + /command: no isSuccess on success (just { data } / {ok}), so we
360
367
  // keep the same lean shape on failure — only the `error` key.
361
368
  function queryErrorResponse(c, err, type) {
362
369
  const requestId = requestContext.get()?.requestId;
363
- if (isClientAbort(err)) {
364
- logClientWarn("request aborted by client", requestId, type, {
365
- status: CLIENT_CLOSED_REQUEST_STATUS,
366
- });
367
- return c.body(null, CLIENT_CLOSED_REQUEST_STATUS); // @cast-boundary non-standard client-closed-request status, Hono's union doesn't include it
368
- }
370
+ if (isClientAbort(err))
371
+ return clientAbortResponse(c, requestId, type);
369
372
  logServerFault(err, requestId, type);
370
373
  const body = serializeError(err, requestId);
371
374
  return c.json(body, err.httpStatus); // @cast-boundary engine-payload
372
375
  }
376
+ // A raw throw under the request signal (signal.throwIfAborted(), an aborted
377
+ // fetch) is the signal's own reason or wraps it as `cause`.
378
+ function isRequestAbortError(err, signal) {
379
+ return signal.aborted && (err === signal.reason || err.cause === signal.reason);
380
+ }
381
+ // Root `app.onError`: whatever a route rethrows instead of shaping itself.
382
+ // Without it Hono's default handler console.error()s the bare error and
383
+ // answers an unclassified 500 — including for a client that simply hung up.
384
+ // requestContext only exists under /api/*, so the abort check reads the raw
385
+ // request signal.
386
+ export function handleUncaughtRouteError(err, c) {
387
+ if (err instanceof HTTPException)
388
+ return err.getResponse();
389
+ const requestId = requestContext.get()?.requestId;
390
+ if (isRequestAbortError(err, c.req.raw.signal))
391
+ return clientAbortResponse(c, requestId);
392
+ const kumikoError = toKumiko(err);
393
+ logServerFault(kumikoError, requestId);
394
+ return c.json(serializeError(kumikoError, requestId), kumikoError.httpStatus); // @cast-boundary engine-payload
395
+ }
@@ -39,7 +39,7 @@ import { createDefaultSseBroker } from "./redis-sse-broker.js";
39
39
  import { requestContext } from "./request-context.js";
40
40
  import { buildRequestContextData, requestIdMiddleware } from "./request-id-middleware.js";
41
41
  import { DEFAULT_MAX_REQUEST_BYTES, registerBodyLimit, registerHealthRoutes, registerMetricsRoute, registerVersionRoute, } from "./route-registrars.js";
42
- import { createApiRoutes } from "./routes.js";
42
+ import { createApiRoutes, handleUncaughtRouteError } from "./routes.js";
43
43
  import { createSseRoute } from "./sse-route.js";
44
44
  import { buildWebSocketSessionRevalidator, createWebSocketConnectionLimiter, hasWebSocketUpgradeServer, isWebSocketUpgradeRequest, WEBSOCKET_DEFAULT_MAX_CONNECTIONS_PER_USER, WEBSOCKET_DEFAULT_MAX_MESSAGE_BYTES, } from "./websocket-route.js";
45
45
  // The per-tenant file-provider resolver, built once for a registry+context so
@@ -380,6 +380,8 @@ export function buildServer(options) {
380
380
  });
381
381
  }
382
382
  const app = new Hono();
383
+ // Sub-apps mounted via app.route() without their own handler inherit this one.
384
+ app.onError(handleUncaughtRouteError);
383
385
  // Only entry:"signature" bypasses jwtGuard (verify() authenticates
384
386
  // itself); entry:"anonymous" still needs the anonymousAccess fallthrough
385
387
  // (tenant-by-host), entry:"user" needs c.get("user") populated.
@@ -453,7 +455,13 @@ export function buildServer(options) {
453
455
  const normalizedForeignCookieOrigins = options.auth?.allowedOrigins?.length
454
456
  ? new Set(options.auth.allowedOrigins.map(normalizeOrigin))
455
457
  : undefined;
458
+ const tenantlessAnonymousQns = new Set([...options.registry.getAllWriteHandlers()]
459
+ .filter(([, handler]) => handler.tenantlessAnonymous === true)
460
+ .map(([qn]) => qn));
456
461
  const jwtGuard = authMiddleware(jwt, {
462
+ ...(tenantlessAnonymousQns.size > 0
463
+ ? { isTenantlessAnonymousWrite: (qn) => tenantlessAnonymousQns.has(qn) }
464
+ : {}),
457
465
  ...(options.auth?.sessionChecker ? { sessionChecker: options.auth.sessionChecker } : {}),
458
466
  ...(options.auth?.tokenVerifier ? { tokenVerifier: options.auth.tokenVerifier } : {}),
459
467
  ...(tenantLifecycleResolver ? { resolveTenantLifecycleStatus: tenantLifecycleResolver } : {}),
@@ -1,4 +1,4 @@
1
1
  export type { BunDbConnection, BunDbConnectionOptions, BunDbRunner, BunDbTx, PgListenClient, } from "./connection.js";
2
2
  export { bunDbConnectionOptionsFromEnv, createBunDbConnection } from "./connection.js";
3
- export type { SelectOptions, TableInfo, WhereObject, WhereOperator, WhereValue } from "./query.js";
4
- export { aggregateWhere, asEntityTableMeta, asRawClient, countWhere, type DeleteManyBatchedOptions, type DeleteManyBatchedResult, deleteMany, deleteManyBatched, extractTableInfo, fetchOne, type IncrementCounterOptions, incrementCounter, insertMany, insertOne, isTimestamptzType, requireEntityTableMeta, runInSavepoint, runInSavepointIfSupported, selectMany, transaction, type UpsertOnConflictOptions, unsafeReadRetrying, updateMany, upsertByPk, upsertOnConflict, } from "./query.js";
3
+ export type { InnerJoinRow, InnerJoinSpec, InsertOnConflictDoNothingOptions, JoinColumnPair, JsonTextMatch, SelectOptions, TableInfo, WhereObject, WhereOperator, WhereValue, } from "./query.js";
4
+ export { aggregateWhere, asEntityTableMeta, asRawClient, countWhere, type DeleteManyBatchedOptions, type DeleteManyBatchedResult, deleteMany, deleteManyBatched, extractTableInfo, fetchOne, type IncrementCounterOptions, incrementCounter, insertMany, insertOnConflictDoNothing, insertOne, isTimestamptzType, requireEntityTableMeta, runInSavepoint, runInSavepointIfSupported, selectInnerJoin, selectMany, transaction, type UpsertOnConflictOptions, unsafeReadRetrying, updateMany, upsertByPk, upsertOnConflict, } from "./query.js";
@@ -1,4 +1,4 @@
1
1
  // bun-db: Bun.sql-basierte DB-API ohne drizzle.
2
2
  // Production-Pfad nach drizzle-removal.
3
3
  export { bunDbConnectionOptionsFromEnv, createBunDbConnection } from "./connection.js";
4
- export { aggregateWhere, asEntityTableMeta, asRawClient, countWhere, deleteMany, deleteManyBatched, extractTableInfo, fetchOne, incrementCounter, insertMany, insertOne, isTimestamptzType, requireEntityTableMeta, runInSavepoint, runInSavepointIfSupported, selectMany, transaction, unsafeReadRetrying, updateMany, upsertByPk, upsertOnConflict, } from "./query.js";
4
+ export { aggregateWhere, asEntityTableMeta, asRawClient, countWhere, deleteMany, deleteManyBatched, extractTableInfo, fetchOne, incrementCounter, insertMany, insertOnConflictDoNothing, insertOne, isTimestamptzType, requireEntityTableMeta, runInSavepoint, runInSavepointIfSupported, selectInnerJoin, selectMany, transaction, unsafeReadRetrying, updateMany, upsertByPk, upsertOnConflict, } from "./query.js";
@@ -14,7 +14,7 @@ export declare function asRawClient(db: unknown): RawClient;
14
14
  export declare function runInSavepoint<T>(tx: unknown, fn: (sp: unknown) => Promise<T>): Promise<T>;
15
15
  export declare function runInSavepointIfSupported<T>(db: unknown, fn: (sp: DbRunner) => Promise<T>): Promise<T>;
16
16
  export type AnyDb = BunDbRunner | unknown;
17
- export type { OrderByClause, SelectOptions, WhereObject, WhereOperator, WhereValue, } from "@cosmicdrift/kumiko-types/where-clause-types";
17
+ export type { JsonTextMatch, OrderByClause, SelectOptions, WhereObject, WhereOperator, WhereValue, } from "@cosmicdrift/kumiko-types/where-clause-types";
18
18
  type TableLike = EntityTableMeta | any;
19
19
  type WritableTable = EntityTableMeta & NotExecutorOnly;
20
20
  export type TableInfo = {
@@ -32,6 +32,26 @@ export declare function describeClosedConnectionFailure(raw: unknown, err: unkno
32
32
  export declare function unsafeReadRetrying<TRow>(db: AnyDb, sqlText: string, params: readonly unknown[]): Promise<readonly TRow[]>;
33
33
  export declare function selectMany<TRow = any>(db: AnyDb, table: TableLike, where?: WhereObject, options?: SelectOptions): Promise<readonly TRow[]>;
34
34
  export declare function fetchOne<TRow = any>(db: AnyDb, table: TableLike, where: WhereObject): Promise<TRow | undefined>;
35
+ export type JoinColumnPair = {
36
+ readonly left: string;
37
+ readonly right: string;
38
+ };
39
+ export type InnerJoinSpec = {
40
+ readonly left: TableLike;
41
+ readonly right: TableLike;
42
+ readonly on: readonly [JoinColumnPair, ...JoinColumnPair[]];
43
+ readonly leftWhere?: WhereObject;
44
+ readonly rightWhere?: WhereObject;
45
+ /** Fields to return per side; default: all columns of `left`, none of `right`. */
46
+ readonly leftFields?: readonly string[];
47
+ readonly rightFields?: readonly string[];
48
+ readonly limit?: number;
49
+ };
50
+ export type InnerJoinRow<TLeft, TRight> = {
51
+ readonly left: TLeft;
52
+ readonly right: TRight;
53
+ };
54
+ export declare function selectInnerJoin<TLeft = Record<string, unknown>, TRight = Record<string, unknown>>(db: AnyDb, spec: InnerJoinSpec): Promise<readonly InnerJoinRow<TLeft, TRight>[]>;
35
55
  export declare function insertMany<TRow = any>(db: AnyDb, table: WritableTable, rows: ReadonlyArray<Record<string, unknown>>): Promise<readonly TRow[]>;
36
56
  export declare function insertOne<TRow = any>(db: AnyDb, table: WritableTable, values: Record<string, unknown>): Promise<TRow | undefined>;
37
57
  export declare function updateMany<TRow = any>(db: AnyDb, table: WritableTable, set: Record<string, unknown>, where: WhereObject): Promise<readonly TRow[]>;
@@ -57,6 +77,10 @@ export declare function isTimestamptzType(pgType: string | undefined): boolean;
57
77
  export declare function aggregateWhere(db: AnyDb, table: TableLike, spec: AggregateSpec, where?: WhereObject): Promise<readonly AggregateRow[]>;
58
78
  export declare function upsertOnConflict<TRow = any>(db: AnyDb, table: WritableTable, values: Record<string, unknown>, options: UpsertOnConflictOptions): Promise<TRow | undefined>;
59
79
  export declare function upsertByPk<TRow = any>(db: AnyDb, table: WritableTable, values: Record<string, unknown>, updateOnConflict?: Record<string, unknown>): Promise<TRow | undefined>;
80
+ export type InsertOnConflictDoNothingOptions = {
81
+ readonly conflictKeys?: readonly string[];
82
+ };
83
+ export declare function insertOnConflictDoNothing<TRow = any>(db: AnyDb, table: WritableTable, values: Record<string, unknown>, options?: InsertOnConflictDoNothingOptions): Promise<TRow | undefined>;
60
84
  export type IncrementCounterOptions = {
61
85
  readonly conflictKeys?: readonly string[];
62
86
  /** Extra SET clauses on conflict (e.g. lastUpdatedAt: sql\`NOW()\`). */
@@ -24,6 +24,7 @@ import { KUMIKO_META_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types
24
24
  import { Temporal } from "temporal-polyfill";
25
25
  import { requestContext } from "../api/request-context.js";
26
26
  import { computeBlindIndex, configuredBlindIndexKey } from "../crypto/blind-index.js";
27
+ import { isPiiCiphertext } from "../crypto/pii-ciphertext-format.js";
27
28
  import { SQL_EXPR_BRAND } from "../db/dialect.js";
28
29
  import { extractPgError } from "../db/pg-error.js";
29
30
  import { toSnakeCase } from "../db/table-builder.js";
@@ -88,7 +89,7 @@ export function asRawClient(db) {
88
89
  // closed instead of silently unwrapping a tenant-scoped handle.
89
90
  if (isTenantDbShape(db)) {
90
91
  throw new Error("asRawClient: received a tenant-scoped TenantDb (ctx.db). Raw SQL helpers do not apply the " +
91
- "tenant filter — use a ctx.db.<method>, or ctx.db.unsafeRaw(reason) with escapeHatch: " +
92
+ "tenant filter — use a ctx.db.<method>, or ctx.db.unsafeRaw() with escapeHatch: " +
92
93
  "{ reason } on the handler/hook (r.systemScope() features: ctx.systemDb.unsafeRaw(reason)).");
93
94
  }
94
95
  // Drizzle DbConnection (legacy compat): $client = postgres-js Sql.
@@ -174,11 +175,11 @@ function tenantDbDelegate(db) {
174
175
  function assertNotTenantScoped(db, fnName) {
175
176
  if (tenantDbDelegate(db) !== undefined) {
176
177
  throw new Error(`${fnName}: received a tenant-scoped db but this helper does not apply the tenant filter. ` +
177
- "Pass ctx.db.unsafeRaw(reason) (with escapeHatch: { reason } on the handler/hook) for an " +
178
+ "Pass ctx.db.unsafeRaw() (with escapeHatch: { reason } on the handler/hook) for an " +
178
179
  "explicit cross-tenant op, or use a scoped TenantDb method.");
179
180
  }
180
181
  }
181
- const WHERE_OPERATOR_KEYS = ["gt", "gte", "lt", "lte", "ne", "in", "like"];
182
+ const WHERE_OPERATOR_KEYS = ["gt", "gte", "lt", "lte", "ne", "in", "like", "jsonText"];
182
183
  const _whereOperatorKeysExhaustive = true;
183
184
  void _whereOperatorKeysExhaustive;
184
185
  function isWhereOperator(v) {
@@ -462,6 +463,11 @@ function prepareValue(value, pgType) {
462
463
  if (isSqlExpression(value)) {
463
464
  return { kind: "literal", literal: value.text };
464
465
  }
466
+ // A PII-encrypted jsonb field is a JSON string scalar; a raw string bound to
467
+ // ::jsonb would be parsed as JSON text (or double-encoded under Bun.SQL).
468
+ if (pgType === "jsonb" && isPiiCiphertext(value)) {
469
+ return { kind: "param", sql: "::text::jsonb", bound: JSON.stringify(value) };
470
+ }
465
471
  if (pgType === "jsonb" && value !== null) {
466
472
  const prepared = prepareJsonbValue(value);
467
473
  if (prepared)
@@ -477,10 +483,13 @@ function prepareValue(value, pgType) {
477
483
  }
478
484
  return { kind: "param", sql: "", bound: value };
479
485
  }
480
- function buildWhereClause(info, where, startIndex) {
486
+ function buildWhereClause(info, where, startIndex, qualifier) {
481
487
  const conditions = [];
482
488
  const values = [];
483
489
  let idx = startIndex;
490
+ // Single place that renders a column reference, so a join qualifier reaches
491
+ // every arm (blind index, jsonb containment, IS NULL, jsonText) alike.
492
+ const ref = (column) => qualifier === undefined ? quoteIdent(column) : `${quoteIdent(qualifier)}.${quoteIdent(column)}`;
484
493
  // multiSelect (and any other jsonb-array-of-scalars column) stores an
485
494
  // array — a filter value is one option, not the whole array, so eq/ne/in
486
495
  // must check array containment (`@>`) instead of scalar `=`/`<>`/`IN`
@@ -495,7 +504,7 @@ function buildWhereClause(info, where, startIndex) {
495
504
  // prepareJsonbValue never returns kind:"literal" (only prepareValue's
496
505
  // isSqlExpression branch does) — narrow anyway, PreparedValue is a union.
497
506
  if (p && p.kind === "param") {
498
- parts.push(`${quoteIdent(col)} @> $${idx++}${p.sql}`);
507
+ parts.push(`${ref(col)} @> $${idx++}${p.sql}`);
499
508
  values.push(p.bound);
500
509
  }
501
510
  }
@@ -508,7 +517,7 @@ function buildWhereClause(info, where, startIndex) {
508
517
  const col = info.columnOf(field);
509
518
  const pgType = info.pgTypeOf(col);
510
519
  if (value === null) {
511
- conditions.push(`${quoteIdent(col)} IS NULL`);
520
+ conditions.push(`${ref(col)} IS NULL`);
512
521
  }
513
522
  else if (Array.isArray(value)) {
514
523
  if (value.length === 0) {
@@ -529,12 +538,37 @@ function buildWhereClause(info, where, startIndex) {
529
538
  values.push(p.bound);
530
539
  }
531
540
  }
532
- conditions.push(`${quoteIdent(col)} IN (${parts.join(", ")})`);
541
+ conditions.push(`${ref(col)} IN (${parts.join(", ")})`);
533
542
  }
534
543
  }
535
544
  else if (isWhereOperator(value)) {
545
+ if (value.jsonText !== undefined) {
546
+ if (Object.keys(value).length > 1) {
547
+ throw new Error(`where: jsonText on column "${col}" cannot be combined with other operators`);
548
+ }
549
+ if (pgType !== "jsonb") {
550
+ throw new Error(`where: jsonText requires a jsonb column, "${col}" is ${pgType}`);
551
+ }
552
+ const matches = Array.isArray(value.jsonText) ? value.jsonText : [value.jsonText];
553
+ for (const match of matches) {
554
+ if (!Array.isArray(match.keys) ||
555
+ match.keys.length === 0 ||
556
+ !match.keys.every((k) => typeof k === "string") ||
557
+ typeof match.eq !== "string") {
558
+ throw new Error(`where: jsonText on column "${col}" needs non-empty string keys and a string eq`);
559
+ }
560
+ const lookups = match.keys.map((key) => {
561
+ values.push(key);
562
+ return `${ref(col)}->>$${idx++}::text`;
563
+ });
564
+ const lhs = lookups.length === 1 ? lookups[0] : `COALESCE(${lookups.join(", ")})`;
565
+ values.push(match.eq);
566
+ conditions.push(`(${lhs} = $${idx++})`);
567
+ }
568
+ continue;
569
+ }
536
570
  if (value.ne === null && Object.keys(value).length === 1) {
537
- conditions.push(`${quoteIdent(col)} IS NOT NULL`);
571
+ conditions.push(`${ref(col)} IS NOT NULL`);
538
572
  continue;
539
573
  }
540
574
  const opMap = {
@@ -558,17 +592,17 @@ function buildWhereClause(info, where, startIndex) {
558
592
  if (opKey === "ne" && pgType === "jsonb" && isJsonbScalar(opVal)) {
559
593
  const p = prepareJsonbValue([opVal]);
560
594
  if (p && p.kind === "param") {
561
- conditions.push(`(${quoteIdent(col)} IS NULL OR NOT (${quoteIdent(col)} @> $${idx++}${p.sql}))`);
595
+ conditions.push(`(${ref(col)} IS NULL OR NOT (${ref(col)} @> $${idx++}${p.sql}))`);
562
596
  values.push(p.bound);
563
597
  continue;
564
598
  }
565
599
  }
566
600
  const p = prepareValue(opVal, pgType);
567
601
  if (p.kind === "literal") {
568
- conditions.push(`${quoteIdent(col)} ${opSym} ${p.literal}`);
602
+ conditions.push(`${ref(col)} ${opSym} ${p.literal}`);
569
603
  }
570
604
  else {
571
- conditions.push(`${quoteIdent(col)} ${opSym} $${idx++}${p.sql}`);
605
+ conditions.push(`${ref(col)} ${opSym} $${idx++}${p.sql}`);
572
606
  values.push(p.bound);
573
607
  }
574
608
  }
@@ -592,7 +626,7 @@ function buildWhereClause(info, where, startIndex) {
592
626
  values.push(p.bound);
593
627
  }
594
628
  }
595
- conditions.push(`${quoteIdent(col)} IN (${parts.join(", ")})`);
629
+ conditions.push(`${ref(col)} IN (${parts.join(", ")})`);
596
630
  }
597
631
  }
598
632
  }
@@ -602,7 +636,7 @@ function buildWhereClause(info, where, startIndex) {
602
636
  else {
603
637
  const p = prepareValue(value, pgType);
604
638
  if (p.kind === "literal") {
605
- conditions.push(`${quoteIdent(col)} = ${p.literal}`);
639
+ conditions.push(`${ref(col)} = ${p.literal}`);
606
640
  }
607
641
  else {
608
642
  // Blind-index OR rewrite (#818): if the column has a bidx
@@ -617,11 +651,11 @@ function buildWhereClause(info, where, startIndex) {
617
651
  typeof value === "string" &&
618
652
  !col.endsWith("_bidx") &&
619
653
  info.hasColumn(bidxCol)) {
620
- conditions.push(`(${quoteIdent(col)} = $${idx++} OR ${quoteIdent(bidxCol)} = $${idx++})`);
654
+ conditions.push(`(${ref(col)} = $${idx++} OR ${ref(bidxCol)} = $${idx++})`);
621
655
  values.push(p.bound, computeBlindIndex(bidxKey, value));
622
656
  }
623
657
  else {
624
- conditions.push(`${quoteIdent(col)} = $${idx++}${p.sql}`);
658
+ conditions.push(`${ref(col)} = $${idx++}${p.sql}`);
625
659
  values.push(p.bound);
626
660
  }
627
661
  }
@@ -814,6 +848,76 @@ export async function fetchOne(db, table, where) {
814
848
  const rows = await selectMany(db, table, where, { limit: 1 });
815
849
  return rows[0];
816
850
  }
851
+ const JOIN_LEFT_ALIAS = "l";
852
+ const JOIN_RIGHT_ALIAS = "r";
853
+ function requireJoinColumn(info, field, role) {
854
+ if (!info.hasColumn(field)) {
855
+ throw new Error(`selectInnerJoin: ${role} "${field}" is not a column of "${info.name}"`);
856
+ }
857
+ return info.columnOf(field);
858
+ }
859
+ export async function selectInnerJoin(db, spec) {
860
+ assertNotTenantScoped(db, "selectInnerJoin");
861
+ const leftInfo = extractTableInfo(spec.left);
862
+ const rightInfo = extractTableInfo(spec.right);
863
+ if (spec.limit !== undefined && (!Number.isInteger(spec.limit) || spec.limit < 0)) {
864
+ throw new Error(`selectInnerJoin: limit must be a non-negative integer, got ${spec.limit}`);
865
+ }
866
+ const leftColumns = (spec.leftFields ??
867
+ requireEntityTableMeta(spec.left, "selectInnerJoin").columns.map((c) => c.name)).map((field) => requireJoinColumn(leftInfo, field, "leftFields entry"));
868
+ const rightColumns = (spec.rightFields ?? []).map((field) => requireJoinColumn(rightInfo, field, "rightFields entry"));
869
+ // Positional aliases: a `<side>__<column>` scheme would hit Postgres' 63-byte
870
+ // identifier truncation for long column names.
871
+ const projected = [
872
+ ...leftColumns.map((column) => ({ side: "left", column })),
873
+ ...rightColumns.map((column) => ({ side: "right", column })),
874
+ ];
875
+ if (projected.length === 0) {
876
+ throw new Error("selectInnerJoin: no columns selected");
877
+ }
878
+ const selectList = projected
879
+ .map(({ side, column }, index) => `${quoteIdent(side === "left" ? JOIN_LEFT_ALIAS : JOIN_RIGHT_ALIAS)}.${quoteIdent(column)} AS "c${index}"`)
880
+ .join(", ");
881
+ const onPairs = spec.on.map((pair) => {
882
+ const leftColumn = requireJoinColumn(leftInfo, pair.left, "on.left");
883
+ const rightColumn = requireJoinColumn(rightInfo, pair.right, "on.right");
884
+ const leftRef = `${quoteIdent(JOIN_LEFT_ALIAS)}.${quoteIdent(leftColumn)}`;
885
+ const rightRef = `${quoteIdent(JOIN_RIGHT_ALIAS)}.${quoteIdent(rightColumn)}`;
886
+ return leftInfo.pgTypeOf(leftColumn) === rightInfo.pgTypeOf(rightColumn)
887
+ ? `${leftRef} = ${rightRef}`
888
+ : `${leftRef}::text = ${rightRef}::text`;
889
+ });
890
+ let sqlText = `SELECT ${selectList} FROM ${quoteIdent(leftInfo.name)} AS ${quoteIdent(JOIN_LEFT_ALIAS)} ` +
891
+ `INNER JOIN ${quoteIdent(rightInfo.name)} AS ${quoteIdent(JOIN_RIGHT_ALIAS)} ON ${onPairs.join(" AND ")}`;
892
+ const conditions = [];
893
+ const values = [];
894
+ if (spec.leftWhere && Object.keys(spec.leftWhere).length > 0) {
895
+ const w = buildWhereClause(leftInfo, spec.leftWhere, values.length + 1, JOIN_LEFT_ALIAS);
896
+ conditions.push(w.sqlText);
897
+ values.push(...w.values);
898
+ }
899
+ if (spec.rightWhere && Object.keys(spec.rightWhere).length > 0) {
900
+ const w = buildWhereClause(rightInfo, spec.rightWhere, values.length + 1, JOIN_RIGHT_ALIAS);
901
+ conditions.push(w.sqlText);
902
+ values.push(...w.values);
903
+ }
904
+ if (conditions.length > 0)
905
+ sqlText += ` WHERE ${conditions.join(" AND ")}`;
906
+ if (spec.limit !== undefined)
907
+ sqlText += ` LIMIT ${spec.limit}`;
908
+ const raw = (await unsafeReadRetrying(db, sqlText, values));
909
+ return raw.map((row) => {
910
+ const leftRow = {};
911
+ const rightRow = {};
912
+ projected.forEach(({ side, column }, index) => {
913
+ (side === "left" ? leftRow : rightRow)[column] = row[`c${index}`];
914
+ });
915
+ return {
916
+ left: coerceRow(leftRow, leftInfo),
917
+ right: coerceRow(rightRow, rightInfo),
918
+ };
919
+ });
920
+ }
817
921
  // Bulk INSERT — same shape as insertOne but takes an array of rows and
818
922
  // produces one multi-VALUES statement. Mirrors drizzle's
819
923
  // `db.insert(t).values(rows[])`. Empty input is a no-op.
@@ -1058,15 +1162,20 @@ function assertValidAggregateLimits(spec) {
1058
1162
  if (spec.orderByValue !== undefined && !AGGREGATE_ORDER_DIRECTIONS.includes(spec.orderByValue)) {
1059
1163
  throw new Error(`aggregateWhere: unknown orderByValue "${String(spec.orderByValue)}"`);
1060
1164
  }
1165
+ if (spec.orderByKeys !== undefined && !AGGREGATE_ORDER_DIRECTIONS.includes(spec.orderByKeys)) {
1166
+ throw new Error(`aggregateWhere: unknown orderByKeys "${String(spec.orderByKeys)}"`);
1167
+ }
1061
1168
  }
1062
1169
  function aggregateGroupOrderLimitSql(spec, dimensionCount) {
1063
1170
  let sqlText = "";
1064
1171
  if (dimensionCount > 0) {
1065
1172
  const positions = Array.from({ length: dimensionCount }, (_, index) => String(index + 1));
1066
1173
  sqlText += ` GROUP BY ${positions.join(", ")}`;
1174
+ const keyDirection = spec.orderByKeys === "desc" ? "DESC" : "ASC";
1175
+ const keyParts = positions.map((position) => `${position} ${keyDirection}`);
1067
1176
  const orderParts = spec.orderByValue === undefined
1068
- ? positions
1069
- : [`${dimensionCount + 1} ${spec.orderByValue === "desc" ? "DESC" : "ASC"}`, ...positions];
1177
+ ? keyParts
1178
+ : [`${dimensionCount + 1} ${spec.orderByValue === "desc" ? "DESC" : "ASC"}`, ...keyParts];
1070
1179
  sqlText += ` ORDER BY ${orderParts.join(", ")}`;
1071
1180
  }
1072
1181
  if (spec.limit !== undefined)
@@ -1156,6 +1265,24 @@ export async function upsertByPk(db, table, values, updateOnConflict) {
1156
1265
  });
1157
1266
  }
1158
1267
  // biome-ignore lint/suspicious/noExplicitAny: see selectMany default
1268
+ export async function insertOnConflictDoNothing(db, table, values, options) {
1269
+ assertNotTenantScoped(db, "insertOnConflictDoNothing");
1270
+ const info = extractTableInfo(table);
1271
+ const entries = insertEntries(info, values);
1272
+ const conflictCols = resolveConflictColumns(table, info, options?.conflictKeys);
1273
+ const { sqlPrefix, params } = buildInsertSql(info, entries);
1274
+ const conflictList = conflictCols.map((c) => quoteIdent(c)).join(", ");
1275
+ const sqlText = `${sqlPrefix} ON CONFLICT (${conflictList}) DO NOTHING RETURNING *`;
1276
+ const rows = (await unsafeLoggingClosedConnection(db, sqlText, params, {
1277
+ operation: "insertOnConflictDoNothing",
1278
+ table: info.name,
1279
+ }));
1280
+ const first = rows[0];
1281
+ if (!first)
1282
+ return undefined;
1283
+ return coerceRow(first, info);
1284
+ }
1285
+ // biome-ignore lint/suspicious/noExplicitAny: see selectMany default
1159
1286
  export async function incrementCounter(db, table, values, increments, options = {}) {
1160
1287
  assertNotTenantScoped(db, "incrementCounter");
1161
1288
  const info = extractTableInfo(table);
@@ -14,7 +14,7 @@
14
14
  // silent no-op (forgetting the option) is now either an explicit "none" or a
15
15
  // missing subject KMS at encrypt-time, both visible states, not a gap.
16
16
  import { requestContext } from "../api/request-context.js";
17
- import { configuredPiiSubjectKms, encryptPiiValueForSubject } from "./pii-field-encryption.js";
17
+ import { configuredPiiSubjectKms, encryptPiiJsonValueForSubject, encryptPiiValueForSubject, } from "./pii-field-encryption.js";
18
18
  import { resolveEventSubject } from "./subject-resolver.js";
19
19
  import { SYSTEM_EVENT_PII_STANCES, SYSTEM_EVENT_PREFIX } from "./system-event-pii.js";
20
20
  // Boot-injected like configurePiiSubjectKms — createRegistry calls this with
@@ -41,6 +41,12 @@ function resolvePiiStance(eventType) {
41
41
  }
42
42
  return stance;
43
43
  }
44
+ // The catalog carries no field types: strings keep the plain format, objects
45
+ // and arrays (jsonb payload fields) use the self-describing JSON format.
46
+ // Bare numbers/booleans stay a loud error so a mistyped string field is caught.
47
+ function isStructuredJsonValue(value) {
48
+ return typeof value === "object" && value !== null;
49
+ }
44
50
  // Encrypts catalogued payload fields under the declared subject's DEK
45
51
  // (user/tenant/self — resolveEventSubject, fw#2801). No-op when the event
46
52
  // type is uncatalogued or no subject KMS is configured (plaintext rollout
@@ -63,13 +69,16 @@ export async function encryptEventPayloadPii(eventType, payload, envelope) {
63
69
  const value = payload[field];
64
70
  if (value === null || value === undefined)
65
71
  continue;
66
- if (typeof value !== "string") {
67
- throw new Error(`Event "${eventType}" piiFields."${field}" must be a string payload field, got ${typeof value}`);
72
+ if (typeof value !== "string" && !isStructuredJsonValue(value)) {
73
+ throw new Error(`Event "${eventType}" piiFields."${field}" must be a string payload field (or an object/array for jsonb), got ${typeof value}`);
68
74
  }
69
75
  const subject = resolveEventSubject(field, spec, payload, envelope);
70
76
  if (subject === null)
71
77
  continue;
72
- const encrypted = await encryptPiiValueForSubject(kms, subject, value, { requestId: requestContext.get()?.requestId ?? "append-event" }, field);
78
+ const kmsCtx = { requestId: requestContext.get()?.requestId ?? "append-event" };
79
+ const encrypted = typeof value === "string"
80
+ ? await encryptPiiValueForSubject(kms, subject, value, kmsCtx, field)
81
+ : await encryptPiiJsonValueForSubject(kms, subject, value, kmsCtx, field);
73
82
  if (encrypted !== value) {
74
83
  out ??= { ...payload };
75
84
  out[field] = encrypted;