@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.
- package/dist/api/auth-middleware.d.ts +1 -0
- package/dist/api/auth-middleware.js +54 -10
- package/dist/api/routes.d.ts +2 -1
- package/dist/api/routes.js +29 -6
- package/dist/api/server.js +9 -1
- package/dist/bun-db/index.d.ts +2 -2
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +25 -1
- package/dist/bun-db/query.js +144 -17
- package/dist/crypto/event-pii.js +13 -4
- package/dist/crypto/index.d.ts +1 -1
- package/dist/crypto/index.js +1 -1
- package/dist/crypto/pii-ciphertext-format.d.ts +4 -0
- package/dist/crypto/pii-ciphertext-format.js +11 -0
- package/dist/crypto/pii-field-encryption.d.ts +4 -2
- package/dist/crypto/pii-field-encryption.js +50 -21
- package/dist/crypto/system-event-pii.js +2 -2
- package/dist/db/queries/backfill-pii.js +4 -0
- package/dist/db/query-api.d.ts +1 -1
- package/dist/db/query.d.ts +1 -1
- package/dist/db/tenant-db.js +16 -12
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +19 -0
- package/dist/engine/boot-validator/pii-retention.js +30 -0
- package/dist/engine/define-handler.js +1 -0
- package/dist/engine/extensions/user-data.d.ts +1 -1
- package/dist/engine/feature-ast/extractors/handlers.js +1 -0
- package/dist/engine/feature-entity-handlers.js +1 -0
- package/dist/engine/feature-manifest.d.ts +7 -0
- package/dist/engine/feature-manifest.js +8 -0
- package/dist/engine/index.d.ts +2 -1
- package/dist/engine/index.js +1 -0
- package/dist/engine/registry-ingest.d.ts +1 -0
- package/dist/engine/registry-ingest.js +9 -0
- package/dist/engine/registry.js +2 -1
- package/dist/engine/steps/retry.js +0 -1
- package/dist/engine/steps/unsafe-projection-upsert.js +1 -1
- package/dist/engine/steps/wait-for-event.js +0 -1
- package/dist/engine/steps/wait.js +0 -4
- package/dist/engine/steps/workflow-system-events.d.ts +2 -0
- package/dist/engine/steps/workflow-system-events.js +89 -0
- package/dist/pipeline/append-event-core.d.ts +3 -1
- package/dist/pipeline/append-event-core.js +8 -4
- package/dist/pipeline/dispatch-shared.js +5 -2
- package/package.json +4 -4
- 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,
|
|
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
|
|
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 =
|
|
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
|
package/dist/api/routes.d.ts
CHANGED
|
@@ -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>;
|
package/dist/api/routes.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/dist/api/server.js
CHANGED
|
@@ -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 } : {}),
|
package/dist/bun-db/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/bun-db/index.js
CHANGED
|
@@ -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";
|
package/dist/bun-db/query.d.ts
CHANGED
|
@@ -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()\`). */
|
package/dist/bun-db/query.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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(`${
|
|
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(`${
|
|
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(`${
|
|
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(`${
|
|
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(`(${
|
|
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(`${
|
|
602
|
+
conditions.push(`${ref(col)} ${opSym} ${p.literal}`);
|
|
569
603
|
}
|
|
570
604
|
else {
|
|
571
|
-
conditions.push(`${
|
|
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(`${
|
|
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(`${
|
|
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(`(${
|
|
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(`${
|
|
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
|
-
?
|
|
1069
|
-
: [`${dimensionCount + 1} ${spec.orderByValue === "desc" ? "DESC" : "ASC"}`, ...
|
|
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);
|
package/dist/crypto/event-pii.js
CHANGED
|
@@ -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
|
|
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;
|