@cosmicdrift/kumiko-framework 0.371.0 → 0.372.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/AGENTS.md +4 -1
- package/agents/examples/custom-handlers.md +3 -5
- package/agents/examples/embedded-entity-form.md +1 -1
- package/agents/examples/pdf-audit-export.md +1 -1
- package/agents/examples/user-data-rights.md +176 -0
- package/dist/api/jwt.js +2 -1
- package/dist/api/observability-middleware.d.ts +1 -2
- package/dist/api/observability-middleware.js +9 -8
- package/dist/api/pii-leak-guard.js +2 -1
- package/dist/api/route-label.d.ts +3 -0
- package/dist/api/route-label.js +17 -0
- package/dist/api/server.d.ts +1 -2
- package/dist/api/server.js +3 -5
- package/dist/config/index.d.ts +5 -0
- package/dist/db/event-store-executor.d.ts +2 -1
- package/dist/db/index.d.ts +2 -2
- package/dist/db/index.js +1 -1
- package/dist/db/located-timestamp.d.ts +7 -0
- package/dist/db/query.d.ts +1 -1
- package/dist/db/query.js +8 -2
- package/dist/db/table-builder.d.ts +45 -11
- package/dist/db/tenant-db.js +3 -1
- package/dist/engine/entity-handlers.d.ts +3 -3
- package/dist/engine/factories.d.ts +2 -1
- package/dist/engine/factories.js +2 -0
- package/dist/engine/membership-roles.d.ts +1 -0
- package/dist/engine/membership-roles.js +8 -0
- package/dist/engine/strip-disabled-features-from-app-schema.js +30 -2
- package/dist/entrypoint/index.d.ts +1 -0
- package/dist/entrypoint/index.js +0 -2
- package/dist/files/assert-file-refs-belong-to-tenant.js +27 -1
- package/dist/files/create-file-ref-from-bytes.js +12 -6
- package/dist/files/create-file-ref.d.ts +20 -2
- package/dist/files/create-file-ref.js +10 -1
- package/dist/files/feature.d.ts +1 -0
- package/dist/files/feature.js +25 -0
- package/dist/files/file-handle.d.ts +4 -1
- package/dist/files/file-handle.js +30 -4
- package/dist/files/file-ref-entity.d.ts +7 -7
- package/dist/files/file-ref-table.d.ts +7 -7
- package/dist/files/file-routes.js +6 -1
- package/dist/files/index.d.ts +3 -1
- package/dist/files/index.js +3 -1
- package/dist/files/provider-resolver.js +3 -1
- package/dist/files/sweep-orphaned-uploads.d.ts +16 -0
- package/dist/files/sweep-orphaned-uploads.js +53 -0
- package/dist/files/upload-ledger.d.ts +10 -0
- package/dist/files/upload-ledger.js +47 -0
- package/dist/i18n/index.d.ts +1 -1
- package/dist/i18n/index.js +1 -1
- package/dist/i18n/request-locale.d.ts +15 -4
- package/dist/i18n/request-locale.js +26 -9
- package/dist/jobs/job-runner.js +2 -4
- package/dist/pipeline/dispatch-shared.js +2 -4
- package/dist/pipeline/index.d.ts +1 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/rate-limit/index.d.ts +1 -0
- package/dist/rate-limit/index.js +1 -0
- package/dist/rate-limit/unlimited-resolver.d.ts +2 -0
- package/dist/rate-limit/unlimited-resolver.js +21 -0
- package/dist/schema-cli.js +46 -5
- package/package.json +5 -5
- package/src/changes.json +42 -0
- package/src/scripts/codemod/README.md +22 -0
- package/src/scripts/codemod/remove-engine-payload-casts.ts +318 -0
package/AGENTS.md
CHANGED
|
@@ -57,7 +57,10 @@ verbs off with `verbs: { delete: false }` and register them yourself when
|
|
|
57
57
|
they need different access or logic. Custom logic goes into
|
|
58
58
|
`r.writeHandler({ name, schema, handler, access })` and
|
|
59
59
|
`r.queryHandler({ name, schema, handler, access })`; inside, use the entity
|
|
60
|
-
executor from `createEntityExecutor(name, entity)`.
|
|
60
|
+
executor from `createEntityExecutor(name, entity)`. Its `detail` and `list`
|
|
61
|
+
return rows typed from the entity (`EntityRow<typeof entity>`), so
|
|
62
|
+
`row.title` needs no cast; money and located-timestamp fields come back as
|
|
63
|
+
objects, and `create` / `update` payloads stay untyped.
|
|
61
64
|
|
|
62
65
|
**Screen.** A declarative page: `r.screen({ id, type, ... })`. Types include
|
|
63
66
|
`entityList`, `entityEdit`, `projectionList`, `projectionDetail`,
|
|
@@ -326,9 +326,6 @@ import {
|
|
|
326
326
|
import { failNotFound } from "@cosmicdrift/kumiko-framework/errors";
|
|
327
327
|
import { z } from "zod";
|
|
328
328
|
|
|
329
|
-
// detail() returns an untyped row; parse the two fields the increment needs once.
|
|
330
|
-
const counterVersionRowSchema = z.object({ count: z.number(), version: z.number() });
|
|
331
|
-
|
|
332
329
|
export const counterEntity = createEntity({
|
|
333
330
|
table: "read_sample_counters",
|
|
334
331
|
fields: {
|
|
@@ -365,8 +362,9 @@ export const counterFeature = defineFeature("counters", (r) => {
|
|
|
365
362
|
return failNotFound("counter", event.payload.id);
|
|
366
363
|
}
|
|
367
364
|
|
|
368
|
-
|
|
369
|
-
const
|
|
365
|
+
// detail() returns the entity's own row type; count is nullable because the field is not required.
|
|
366
|
+
const { count, version } = current;
|
|
367
|
+
const newCount = (count ?? 0) + event.payload.amount;
|
|
370
368
|
// Read-modify-write on counter: use the version we just read so two
|
|
371
369
|
// concurrent increments don't clobber each other's changes.
|
|
372
370
|
return counterExecutor.update(
|
|
@@ -427,7 +427,7 @@ export const prospectsFeature = defineFeature("prospects", (r) => {
|
|
|
427
427
|
const flipped = await suggestionExecutor.update(
|
|
428
428
|
{
|
|
429
429
|
id: event.payload.suggestionId,
|
|
430
|
-
version: suggestion["version"]
|
|
430
|
+
version: suggestion["version"],
|
|
431
431
|
changes: { status: "accepted" },
|
|
432
432
|
},
|
|
433
433
|
event.user,
|
|
@@ -14,7 +14,7 @@ A write handler exports tenant data as a PDF and keeps it as a FileRef.
|
|
|
14
14
|
|
|
15
15
|
- `renderTablePdf` (`@cosmicdrift/kumiko-bundled-features/renderer-pdf`) draws a table document in pure JavaScript: repeating header, weighted columns, page footer. The handler passes ready-formatted strings.
|
|
16
16
|
- The export is a `beforeTransaction` handler: it reads the rows (reads work in that phase), renders the PDF and calls `uploadFileBytesBeforeTransaction` (`@cosmicdrift/kumiko-framework/files`), which validates the bytes and writes them to the tenant-prefixed storage key. The upload runs before the transaction because storage I/O inside it would hold a pool connection for as long as the upload takes.
|
|
17
|
-
- The handler itself calls `createFileRefFromUploadedBytes(ctx, event.prepared)` and creates the `file_refs` row and the `fileRef.created` event in the transaction. If the transaction does not commit, the phase's `release` deletes the uploaded bytes again (only when no row exists for the storage key). A crash between upload and commit leaves the bytes in storage;
|
|
17
|
+
- The handler itself calls `createFileRefFromUploadedBytes(ctx, event.prepared)` and creates the `file_refs` row and the `fileRef.created` event in the transaction. If the transaction does not commit, the phase's `release` deletes the uploaded bytes again (only when no row exists for the storage key). A crash between upload and commit leaves the bytes in storage; the hourly `files:job:sweep-orphaned-uploads` job deletes them once they are older than the grace period (24 hours by default, `uploadSweepGraceHours`).
|
|
18
18
|
- The result is a normal FileRef: `GET /api/files/:id` serves it with `Content-Type: application/pdf`, other tenants get 404.
|
|
19
19
|
|
|
20
20
|
## Source
|
|
@@ -83,6 +83,28 @@ Step-by-step proof:
|
|
|
83
83
|
2. Deletion + expired grace → `runForgetCleanup` removes all notes
|
|
84
84
|
3. Strategy `anonymize` → `authorId=null`, title/body remain
|
|
85
85
|
|
|
86
|
+
## Retention with files
|
|
87
|
+
|
|
88
|
+
`runRetentionCleanup` is exported for app tests. A `hardDelete` entity with a file field loses
|
|
89
|
+
its bytes (and derivatives), its `file_refs` row and then the row itself; a storage error keeps
|
|
90
|
+
the row and reports `file_delete_failed` in `skipped`. `src/retained-docs.ts` holds the entity,
|
|
91
|
+
`src/__tests__/retention-files.integration.test.ts` drives the run with only public imports:
|
|
92
|
+
|
|
93
|
+
```ts illustration
|
|
94
|
+
import { runRetentionCleanup } from "@cosmicdrift/kumiko-bundled-features/data-retention";
|
|
95
|
+
import { createFileContext } from "@cosmicdrift/kumiko-framework/files";
|
|
96
|
+
|
|
97
|
+
const result = await runRetentionCleanup({
|
|
98
|
+
db: stack.db,
|
|
99
|
+
registry: stack.registry,
|
|
100
|
+
tenantId,
|
|
101
|
+
preloadedTenantPreset: null,
|
|
102
|
+
now: getTemporal().Now.instant().add({ hours: 31 * 24 }), // keepFor 30d + 1 day
|
|
103
|
+
files: createFileContext(() => Promise.resolve(provider)), // the stack's storage provider
|
|
104
|
+
});
|
|
105
|
+
// result.hardDeleted === 1, bytes and file_refs row gone
|
|
106
|
+
```
|
|
107
|
+
|
|
86
108
|
## Full app vs this recipe
|
|
87
109
|
|
|
88
110
|
| | This recipe | [user-data-rights-demo](/en/samples/apps-user-data-rights-demo/) |
|
|
@@ -300,6 +322,120 @@ describe("user-data-rights recipe :: EXT_USER_DATA-Hooks integrieren Notes-Domai
|
|
|
300
322
|
});
|
|
301
323
|
```
|
|
302
324
|
|
|
325
|
+
### src/__tests__/retention-files.integration.test.ts
|
|
326
|
+
|
|
327
|
+
```ts
|
|
328
|
+
// runRetentionCleanup is public API: an app test drives the retention run with its own `now`
|
|
329
|
+
// and the stack's storage provider, then checks that the file bytes, the file_refs row and the
|
|
330
|
+
// entity row are gone and the erasure is on the audit trail.
|
|
331
|
+
|
|
332
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
333
|
+
import {
|
|
334
|
+
createDataRetentionFeature,
|
|
335
|
+
runRetentionCleanup,
|
|
336
|
+
tenantRetentionOverrideEntity,
|
|
337
|
+
} from "@cosmicdrift/kumiko-bundled-features/data-retention";
|
|
338
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/db";
|
|
339
|
+
import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
340
|
+
import {
|
|
341
|
+
createFileContext,
|
|
342
|
+
createFilesFeature,
|
|
343
|
+
createInMemoryFileProvider,
|
|
344
|
+
fileRefsTable,
|
|
345
|
+
} from "@cosmicdrift/kumiko-framework/files";
|
|
346
|
+
import { createTestUser, testTenantId } from "@cosmicdrift/kumiko-framework/seeding";
|
|
347
|
+
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
348
|
+
import {
|
|
349
|
+
buildMultipartBody,
|
|
350
|
+
patchFileInstanceofForBunTest,
|
|
351
|
+
} from "@cosmicdrift/kumiko-testing/helpers";
|
|
352
|
+
import {
|
|
353
|
+
setupTestStack,
|
|
354
|
+
type TestStack,
|
|
355
|
+
unsafeCreateEntityTable,
|
|
356
|
+
} from "@cosmicdrift/kumiko-testing/stack";
|
|
357
|
+
import { retainedDocEntity, retainedDocsFeature } from "../retained-docs";
|
|
358
|
+
|
|
359
|
+
const CREATE = "retained-docs:write:retained-doc:create";
|
|
360
|
+
const DETAIL = "retained-docs:query:retained-doc:detail";
|
|
361
|
+
const tenantId = testTenantId(61);
|
|
362
|
+
const admin = createTestUser({ id: 61, tenantId, roles: ["Admin"] });
|
|
363
|
+
const provider = createInMemoryFileProvider();
|
|
364
|
+
|
|
365
|
+
let stack: TestStack;
|
|
366
|
+
|
|
367
|
+
beforeAll(async () => {
|
|
368
|
+
patchFileInstanceofForBunTest();
|
|
369
|
+
stack = await setupTestStack({
|
|
370
|
+
features: [createFilesFeature(), createDataRetentionFeature(), retainedDocsFeature],
|
|
371
|
+
files: { storageProvider: provider },
|
|
372
|
+
});
|
|
373
|
+
await unsafeCreateEntityTable(stack.db, retainedDocEntity, "retained-doc");
|
|
374
|
+
await unsafeCreateEntityTable(stack.db, tenantRetentionOverrideEntity);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
afterAll(async () => {
|
|
378
|
+
await stack.cleanup();
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
async function upload(): Promise<string> {
|
|
382
|
+
const fd = new FormData();
|
|
383
|
+
fd.append("entityType", "retained-doc");
|
|
384
|
+
fd.append("fieldName", "attachment");
|
|
385
|
+
fd.append(
|
|
386
|
+
"file",
|
|
387
|
+
new File([Buffer.from("%PDF-1.4 minimal")], "doc.pdf", { type: "application/pdf" }),
|
|
388
|
+
);
|
|
389
|
+
const { body, contentType } = await buildMultipartBody(fd);
|
|
390
|
+
const res = await stack.app.request("/api/files", {
|
|
391
|
+
method: "POST",
|
|
392
|
+
headers: {
|
|
393
|
+
Authorization: `Bearer ${await stack.jwt.sign(admin)}`,
|
|
394
|
+
"Content-Type": contentType,
|
|
395
|
+
},
|
|
396
|
+
body,
|
|
397
|
+
});
|
|
398
|
+
expect(res.status).toBe(201);
|
|
399
|
+
return ((await res.json()) as { id: string }).id;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
describe("user-data-rights recipe :: retention run removes a hardDelete entity's files", () => {
|
|
403
|
+
test("after keepFor + 1 day the row, its bytes and its file_refs row are gone; the erasure is audited", async () => {
|
|
404
|
+
const fileRefId = await upload();
|
|
405
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
406
|
+
CREATE,
|
|
407
|
+
{ title: "contract", attachment: fileRefId },
|
|
408
|
+
admin,
|
|
409
|
+
);
|
|
410
|
+
const [fileRef] = await selectMany<{ storageKey: string }>(stack.db, fileRefsTable, {
|
|
411
|
+
id: fileRefId,
|
|
412
|
+
});
|
|
413
|
+
const storageKey = String(fileRef?.storageKey);
|
|
414
|
+
expect(await provider.exists(storageKey)).toBe(true);
|
|
415
|
+
|
|
416
|
+
const result = await runRetentionCleanup({
|
|
417
|
+
db: stack.db,
|
|
418
|
+
registry: stack.registry,
|
|
419
|
+
tenantId,
|
|
420
|
+
preloadedTenantPreset: null,
|
|
421
|
+
now: getTemporal()
|
|
422
|
+
.Now.instant()
|
|
423
|
+
.add({ hours: 31 * 24 }),
|
|
424
|
+
files: createFileContext(() => Promise.resolve(provider)),
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
expect(result.hardDeleted).toBe(1);
|
|
428
|
+
expect(result.skipped).toEqual([]);
|
|
429
|
+
expect(await provider.exists(storageKey)).toBe(false);
|
|
430
|
+
expect(await selectMany(stack.db, fileRefsTable, { id: fileRefId })).toHaveLength(0);
|
|
431
|
+
expect(await stack.http.queryOk(DETAIL, { id: created.id }, admin)).toBeNull();
|
|
432
|
+
|
|
433
|
+
const forgotten = await selectMany(stack.db, eventsTable, { aggregateId: created.id });
|
|
434
|
+
expect(forgotten.map((e) => (e as { type: string }).type)).toContain("retained-doc.forgotten");
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
```
|
|
438
|
+
|
|
303
439
|
### src/feature.ts
|
|
304
440
|
|
|
305
441
|
```ts
|
|
@@ -405,3 +541,43 @@ export const notesFeature = defineFeature("notes", (r) => {
|
|
|
405
541
|
});
|
|
406
542
|
});
|
|
407
543
|
```
|
|
544
|
+
|
|
545
|
+
### src/retained-docs.ts
|
|
546
|
+
|
|
547
|
+
```ts
|
|
548
|
+
// Retention with files: a hardDelete entity whose file field is cleaned up together with the
|
|
549
|
+
// row. runRetentionCleanup removes the bytes (and derivatives) and the file_refs row first,
|
|
550
|
+
// then the row itself; the cron in data-retention does the same with the tenant's FileContext.
|
|
551
|
+
|
|
552
|
+
import {
|
|
553
|
+
createEntity,
|
|
554
|
+
createFileField,
|
|
555
|
+
createTextField,
|
|
556
|
+
defineEntityCreateHandler,
|
|
557
|
+
defineEntityDetailHandler,
|
|
558
|
+
defineFeature,
|
|
559
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
560
|
+
|
|
561
|
+
export const retainedDocEntity = createEntity({
|
|
562
|
+
table: "store_retained_docs",
|
|
563
|
+
fields: {
|
|
564
|
+
title: createTextField({ personal: false, reason: "is_business_data", required: true }),
|
|
565
|
+
attachment: createFileField(),
|
|
566
|
+
},
|
|
567
|
+
retention: { keepFor: "30d", strategy: "hardDelete", reference: "createdAt" },
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
export const retainedDocsFeature = defineFeature("retained-docs", (r) => {
|
|
571
|
+
r.entity({ name: "retained-doc", ...retainedDocEntity });
|
|
572
|
+
r.writeHandler(
|
|
573
|
+
defineEntityCreateHandler("retained-doc", retainedDocEntity, {
|
|
574
|
+
access: { roles: ["Admin"] },
|
|
575
|
+
}),
|
|
576
|
+
);
|
|
577
|
+
r.queryHandler(
|
|
578
|
+
defineEntityDetailHandler("retained-doc", retainedDocEntity, {
|
|
579
|
+
access: { roles: ["Admin"] },
|
|
580
|
+
}),
|
|
581
|
+
);
|
|
582
|
+
});
|
|
583
|
+
```
|
package/dist/api/jwt.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as jose from "jose";
|
|
2
|
+
import { stripReservedTokenRoles } from "../engine/membership-roles.js";
|
|
2
3
|
import { parseTenantId } from "../engine/types/index.js";
|
|
3
4
|
import { InternalError } from "../errors/index.js";
|
|
4
5
|
function normalizeKeyring(secretOrKeyring) {
|
|
@@ -107,7 +108,7 @@ export function createJwtHelper(secretOrKeyring, issuer = "kumiko", ttlSeconds =
|
|
|
107
108
|
const result = {
|
|
108
109
|
sub: payload.sub,
|
|
109
110
|
tenantId,
|
|
110
|
-
roles,
|
|
111
|
+
roles: [...stripReservedTokenRoles(roles)],
|
|
111
112
|
};
|
|
112
113
|
if (typeof payload["timezone"] === "string") {
|
|
113
114
|
result.timezone = payload["timezone"];
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import type { Context, Next } from "hono";
|
|
2
|
-
import { type Meter, type
|
|
2
|
+
import { type Meter, type Tracer } from "../observability/index.ts";
|
|
3
3
|
export type ObservabilityMiddlewareOptions = {
|
|
4
4
|
readonly tracer: Tracer;
|
|
5
5
|
readonly meter: Meter;
|
|
6
|
-
readonly sensitiveConfig: SensitiveFilterConfig;
|
|
7
6
|
};
|
|
8
7
|
export declare function observabilityMiddleware(opts: ObservabilityMiddlewareOptions): (c: Context, next: Next) => Promise<void>;
|
|
@@ -1,20 +1,21 @@
|
|
|
1
|
-
import { emitHttpRequest, observabilityContext,
|
|
1
|
+
import { emitHttpRequest, observabilityContext, } from "../observability/index.js";
|
|
2
2
|
import { getUser } from "./auth-middleware.js";
|
|
3
3
|
import { requestContext } from "./request-context.js";
|
|
4
|
+
import { resolveRouteLabel } from "./route-label.js";
|
|
4
5
|
export function observabilityMiddleware(opts) {
|
|
5
|
-
const { tracer, meter
|
|
6
|
+
const { tracer, meter } = opts;
|
|
6
7
|
return async (c, next) => {
|
|
7
8
|
const method = c.req.method;
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
// The match list is fixed before the first middleware runs, so the label is known up front
|
|
10
|
+
// and also covers requests that a middleware answers or a handler throws on.
|
|
11
|
+
const route = resolveRouteLabel(c);
|
|
10
12
|
// Start the root span for this request. kind=server marks it as an
|
|
11
13
|
// incoming server-side span in OTel terms.
|
|
12
|
-
const span = tracer.startSpan(
|
|
14
|
+
const span = tracer.startSpan(`${method} ${route}`, {
|
|
13
15
|
kind: "server",
|
|
14
16
|
attributes: {
|
|
15
17
|
"http.method": method,
|
|
16
|
-
"http.route":
|
|
17
|
-
"http.target": target,
|
|
18
|
+
"http.route": route,
|
|
18
19
|
},
|
|
19
20
|
});
|
|
20
21
|
const reqCtx = requestContext.get();
|
|
@@ -61,7 +62,7 @@ export function observabilityMiddleware(opts) {
|
|
|
61
62
|
// c.res may be undefined on very early throws (before any route handler);
|
|
62
63
|
// fall back to 500 for the metric so the counter is always incremented.
|
|
63
64
|
const status = c.res?.status ?? 500;
|
|
64
|
-
emitHttpRequest(meter, { route
|
|
65
|
+
emitHttpRequest(meter, { route, method, status }, durationSec);
|
|
65
66
|
span.end();
|
|
66
67
|
}
|
|
67
68
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { PII_CIPHERTEXT_PREFIX } from "../crypto/index.js";
|
|
2
|
+
import { resolveRouteLabel } from "./route-label.js";
|
|
2
3
|
const isProductionEnv = () => process.env["NODE_ENV"] === "production";
|
|
3
4
|
// Version-agnostic: catches both the current PII_CIPHERTEXT_PREFIX and any
|
|
4
5
|
// older/decrypt-only format version still present in unmigrated rows.
|
|
@@ -27,7 +28,7 @@ export function piiCiphertextResponseGuard() {
|
|
|
27
28
|
// skip: clean response — the common case
|
|
28
29
|
if (!CIPHERTEXT_RE.test(text))
|
|
29
30
|
return;
|
|
30
|
-
const detail = `[api] JSON response for ${c.req.method} ${c
|
|
31
|
+
const detail = `[api] JSON response for ${c.req.method} ${resolveRouteLabel(c)} contains a PII ciphertext ` +
|
|
31
32
|
`("${PII_CIPHERTEXT_PREFIX}…") — a raw DB read leaked to the API surface. ` +
|
|
32
33
|
`Decrypt before returning (decryptStoredPii / executor read path).`;
|
|
33
34
|
if (!isProductionEnv()) {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { matchedRoutes } from "hono/route";
|
|
2
|
+
export const UNMATCHED_ROUTE_LABEL = "unmatched";
|
|
3
|
+
const MIDDLEWARE_METHOD = "ALL";
|
|
4
|
+
// Route pattern (`/api/heartbeat/:token`) instead of the raw path: raw paths carry path-param
|
|
5
|
+
// secrets and make every distinct id its own metric series. Middleware and the notFound fallback
|
|
6
|
+
// register as method "ALL", so only a method-specific match counts as a real route. Reading the
|
|
7
|
+
// match list instead of c.req.routeIndex keeps the label right when a middleware (401, 429)
|
|
8
|
+
// answers before the handler runs.
|
|
9
|
+
export function resolveRouteLabel(c) {
|
|
10
|
+
const routes = matchedRoutes(c);
|
|
11
|
+
for (let index = routes.length - 1; index >= 0; index--) {
|
|
12
|
+
const route = routes[index];
|
|
13
|
+
if (route && route.method !== MIDDLEWARE_METHOD)
|
|
14
|
+
return route.path;
|
|
15
|
+
}
|
|
16
|
+
return UNMATCHED_ROUTE_LABEL;
|
|
17
|
+
}
|
package/dist/api/server.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { PgListenClient } from "../db/connection.ts";
|
|
|
4
4
|
import { type AppContext, type Registry, type RunIn, type WriteResult } from "../engine/types/index.ts";
|
|
5
5
|
import type { ErrorDocsConfig } from "../errors/docs-url.ts";
|
|
6
6
|
import type { Lifecycle } from "../lifecycle/index.ts";
|
|
7
|
-
import { type
|
|
7
|
+
import { type ObservabilityProvider } from "../observability/index.ts";
|
|
8
8
|
import type { DispatcherOptions } from "../pipeline/dispatcher.ts";
|
|
9
9
|
import { type Dispatcher } from "../pipeline/dispatcher.ts";
|
|
10
10
|
import type { EventDedup } from "../pipeline/event-dedup.ts";
|
|
@@ -46,7 +46,6 @@ export type ServerOptions = {
|
|
|
46
46
|
pgClient?: PgListenClient;
|
|
47
47
|
};
|
|
48
48
|
observability?: ObservabilityProvider;
|
|
49
|
-
observabilityOptions?: ObservabilityOptions;
|
|
50
49
|
rateLimit?: {
|
|
51
50
|
readonly global?: Omit<GlobalIpRateLimitOptions, "resolver">;
|
|
52
51
|
readonly auth?: Omit<AuthEndpointRateLimitOptions, "resolver"> & {
|
package/dist/api/server.js
CHANGED
|
@@ -10,13 +10,13 @@ import { resolveDisabledFeatureNames, stripDisabledFeaturesFromAppSchema, } from
|
|
|
10
10
|
import { createAnonymousUser, createSystemUser, isAnonymousSessionUser, } from "../engine/system-user.js";
|
|
11
11
|
import { isFileField, } from "../engine/types/index.js";
|
|
12
12
|
import { assertSeedModeNotInProduction } from "../event-store/seed-clock.js";
|
|
13
|
-
import {
|
|
13
|
+
import { createTenantFileContext } from "../files/file-handle.js";
|
|
14
14
|
import { createFileRoutes, readFilesRouteOptions } from "../files/file-routes.js";
|
|
15
15
|
import { makeFileProviderResolver } from "../files/provider-resolver.js";
|
|
16
16
|
import { createRecordReadCheck } from "../files/record-read-check.js";
|
|
17
17
|
import { createRecordWriteCheck } from "../files/record-write-check.js";
|
|
18
18
|
import { createFallbackLogger } from "../logging/index.js";
|
|
19
|
-
import { createNoopProvider,
|
|
19
|
+
import { createNoopProvider, registerStandardMetrics, wrapRedisClient, } from "../observability/index.js";
|
|
20
20
|
import { resolveTenantLifecyclePlugin } from "../pipeline/active-membership.js";
|
|
21
21
|
import { createDispatcher } from "../pipeline/dispatcher.js";
|
|
22
22
|
import { SHARED_INSTANCE_SENTINEL } from "../pipeline/event-consumer-state.js";
|
|
@@ -389,7 +389,7 @@ export function buildServer(options) {
|
|
|
389
389
|
// cross-feature ctx.appendEvent calls at emit-site.
|
|
390
390
|
const mspOwner = msp.name.split(":")[0];
|
|
391
391
|
const mspFiles = fileProviderResolver
|
|
392
|
-
?
|
|
392
|
+
? createTenantFileContext(fileProviderResolver, event.tenantId)
|
|
393
393
|
: undefined;
|
|
394
394
|
const applyCtx = createMultiStreamApplyContext({
|
|
395
395
|
registry: options.registry,
|
|
@@ -467,7 +467,6 @@ export function buildServer(options) {
|
|
|
467
467
|
// (tenant-by-host), entry:"user" needs c.get("user") populated.
|
|
468
468
|
const extraRoutePublicMatchers = compileExtraRoutePublicMatchers(options.extraRoutes);
|
|
469
469
|
const isExtraRoutePublicPath = (c) => extraRoutePublicMatchers.some((m) => m.method === c.req.method && m.pattern.test(c.req.path));
|
|
470
|
-
const sensitiveConfig = mergeSensitiveConfig(options.observabilityOptions?.sensitiveFilter ?? DEFAULT_SENSITIVE_CONFIG);
|
|
471
470
|
registerHealthRoutes(app, {
|
|
472
471
|
lifecycle: options.lifecycle,
|
|
473
472
|
readiness: {
|
|
@@ -519,7 +518,6 @@ export function buildServer(options) {
|
|
|
519
518
|
app.use("/api/*", observabilityMiddleware({
|
|
520
519
|
tracer: observability.tracer,
|
|
521
520
|
meter: observability.meter,
|
|
522
|
-
sensitiveConfig,
|
|
523
521
|
}));
|
|
524
522
|
// Tripwire: a kumiko-pii: ciphertext in a JSON response is always a bug
|
|
525
523
|
// (raw read leaked past a decrypt) — dev fails loud, prod redacts (#820).
|
package/dist/config/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ValidateBootOptions } from "../engine/boot-validator.ts";
|
|
1
2
|
import type { FeatureDefinition } from "../engine/types/index.ts";
|
|
2
3
|
export type AppInfraPorts = {
|
|
3
4
|
readonly postgres?: number;
|
|
@@ -19,6 +20,8 @@ export type AppClientConfig = {
|
|
|
19
20
|
/** Client bundle entry files, relative to the app root (e.g. "src/client.tsx"). */
|
|
20
21
|
readonly entries: readonly string[];
|
|
21
22
|
};
|
|
23
|
+
/** The boot-validator options an app owns; `env` and the cipher are injected by the runners. */
|
|
24
|
+
export type AppValidateBootOptions = Pick<ValidateBootOptions, "navAllowlist" | "navAllowlistExempt" | "warnOnUniqueAccessRoles">;
|
|
22
25
|
export type AppConfig = {
|
|
23
26
|
/** Features mounted in every environment: dev, tests, prod. */
|
|
24
27
|
readonly features: readonly FeatureDefinition[];
|
|
@@ -26,6 +29,8 @@ export type AppConfig = {
|
|
|
26
29
|
readonly prodFeatures?: readonly FeatureDefinition[];
|
|
27
30
|
readonly client: AppClientConfig;
|
|
28
31
|
readonly ports: AppPorts;
|
|
32
|
+
/** Passed to validateBoot by `kumiko check` and, via the app's bin files, by the runners. */
|
|
33
|
+
readonly validateBootOptions?: AppValidateBootOptions;
|
|
29
34
|
};
|
|
30
35
|
export declare const APP_CONFIG_FILE = "kumiko.config.ts";
|
|
31
36
|
declare const INFRA_PORT_NAMES: readonly ["postgres", "redis", "meilisearch", "s3"];
|
|
@@ -5,6 +5,7 @@ import type { EntityCache } from "../pipeline/entity-cache.ts";
|
|
|
5
5
|
import type { SearchAdapter } from "../search/types.ts";
|
|
6
6
|
import type { EnvelopeCipher } from "../secrets/envelope-cipher.ts";
|
|
7
7
|
import { type Table } from "./event-store-executor-context.ts";
|
|
8
|
+
import type { EntityRow } from "./table-builder.ts";
|
|
8
9
|
export type { EventStoreExecutor, LockGuardOption, WritePurpose, } from "@cosmicdrift/kumiko-types/event-store-executor-types";
|
|
9
10
|
export { WritePurposes } from "@cosmicdrift/kumiko-types/event-store-executor-types";
|
|
10
11
|
export type { EntityLifecycleVerb } from "./event-store-executor-context.ts";
|
|
@@ -18,4 +19,4 @@ export type EventStoreExecutorOptions = {
|
|
|
18
19
|
/** Override the boot-injected subject KMS for pii-annotated fields. */
|
|
19
20
|
kms?: LocalKeyKmsAdapter;
|
|
20
21
|
};
|
|
21
|
-
export declare function createEventStoreExecutor(table: Table, entity:
|
|
22
|
+
export declare function createEventStoreExecutor<E extends EntityDefinition>(table: Table, entity: E, options: EventStoreExecutorOptions): EventStoreExecutor<EntityRow<E>>;
|
package/dist/db/index.d.ts
CHANGED
|
@@ -18,7 +18,7 @@ export { buildEntityTableMeta, defineUnmanagedTable, deriveEntityTableMeta, } fr
|
|
|
18
18
|
export type { EntityLifecycleVerb, EventStoreExecutor, EventStoreExecutorOptions, LockGuardOption, WritePurpose, } from "./event-store-executor.ts";
|
|
19
19
|
export { createEventStoreExecutor, entityEventName, WritePurposes, } from "./event-store-executor.ts";
|
|
20
20
|
export { enumerateFeatureTableSources, type FeatureTableSource, } from "./feature-table-sources.ts";
|
|
21
|
-
export { flattenLocatedTimestamp, rehydrateLocatedTimestamp } from "./located-timestamp.ts";
|
|
21
|
+
export { flattenLocatedTimestamp, type LocatedTimestampRead, rehydrateLocatedTimestamp, } from "./located-timestamp.ts";
|
|
22
22
|
export { assertUniqueTableNames, assertValidMigrationName, diffSnapshots, type GenerateMigrationInput, type GenerateMigrationOutput, generateMigration, loadSnapshotJson, renderMigrationSql, type SchemaDiff, type Snapshot, snapshotFromMetas, writeSnapshotJson, } from "./migrate-generator.ts";
|
|
23
23
|
export { type AppliedMigration, type ApplyResult, type BaselineResult, baselineMigrations, fetchAppliedMigrations, loadMigrationsFromDir, type Migration, MigrationChecksumMismatchError, readMigrationSqlTexts, runMigrations, runMigrationsFromDir, splitSqlStatements, } from "./migrate-runner.ts";
|
|
24
24
|
export { flattenMoney, type MoneyRead, moneyPayloadToMinorUnits, rehydrateMoney } from "./money.ts";
|
|
@@ -34,6 +34,6 @@ export { renderTableDdl, renderTablesDdl } from "./render-ddl.ts";
|
|
|
34
34
|
export { diffReplayAgainstSnapshot, type ReplayedSchema, type ReplayedTable, type ReplayMismatch, replayMigrationsDir, } from "./replay-migration-sql.ts";
|
|
35
35
|
export { findCommentedDropTables, isRetiredFrameworkTable, type RetiredFrameworkTable, retiredFrameworkTables, } from "./retired-framework-tables.ts";
|
|
36
36
|
export { tableExists } from "./schema-inspection.ts";
|
|
37
|
-
export { buildBaseColumns, buildEntityTable, declareGlobalTenancy, type EntityTable, physicalColumnName, toSnakeCase, toTableName, } from "./table-builder.ts";
|
|
37
|
+
export { buildBaseColumns, buildEntityTable, declareGlobalTenancy, type EntityRow, type EntityTable, physicalColumnName, toSnakeCase, toTableName, } from "./table-builder.ts";
|
|
38
38
|
export type { CreateTenantDbOptions, TenantDb, TenantDbGrants, TenantDbMode, UncheckedSystemDb, } from "./tenant-db.ts";
|
|
39
39
|
export { castTenantRows, createSystemDbView, createTenantDb, runInOwnTransaction, SYSTEM_SCOPE_CHECK_BRAND, scopeTenantDbToTenant, } from "./tenant-db.ts";
|
package/dist/db/index.js
CHANGED
|
@@ -12,7 +12,7 @@ export { entityTableFromRegistry } from "./entity-table-from-registry.js";
|
|
|
12
12
|
export { buildEntityTableMeta, defineUnmanagedTable, deriveEntityTableMeta, } from "./entity-table-meta.js";
|
|
13
13
|
export { createEventStoreExecutor, entityEventName, WritePurposes, } from "./event-store-executor.js";
|
|
14
14
|
export { enumerateFeatureTableSources, } from "./feature-table-sources.js";
|
|
15
|
-
export { flattenLocatedTimestamp, rehydrateLocatedTimestamp } from "./located-timestamp.js";
|
|
15
|
+
export { flattenLocatedTimestamp, rehydrateLocatedTimestamp, } from "./located-timestamp.js";
|
|
16
16
|
export { assertUniqueTableNames, assertValidMigrationName, diffSnapshots, generateMigration, loadSnapshotJson, renderMigrationSql, snapshotFromMetas, writeSnapshotJson, } from "./migrate-generator.js";
|
|
17
17
|
export { baselineMigrations, fetchAppliedMigrations, loadMigrationsFromDir, MigrationChecksumMismatchError, readMigrationSqlTexts, runMigrations, runMigrationsFromDir, splitSqlStatements, } from "./migrate-runner.js";
|
|
18
18
|
export { flattenMoney, moneyPayloadToMinorUnits, rehydrateMoney } from "./money.js";
|
|
@@ -10,6 +10,13 @@ import type { EntityDefinition } from "../engine/types/index.ts";
|
|
|
10
10
|
* Pure — mutiert nicht.
|
|
11
11
|
*/
|
|
12
12
|
export declare function flattenLocatedTimestamp(payload: Record<string, unknown>, entity: EntityDefinition): Record<string, unknown>;
|
|
13
|
+
/** Shape of a single rehydrated locatedTimestamp field. `at` is the wall clock in `tz`
|
|
14
|
+
* (no offset), `utc` the ISO instant string; both are strings, not Temporal values. */
|
|
15
|
+
export type LocatedTimestampRead = {
|
|
16
|
+
readonly at: string;
|
|
17
|
+
readonly tz: string;
|
|
18
|
+
readonly utc: string;
|
|
19
|
+
};
|
|
13
20
|
/**
|
|
14
21
|
* DB → API: zwei flache Spalten zu combined { at, tz, utc } rehydraten.
|
|
15
22
|
*
|
package/dist/db/query.d.ts
CHANGED
|
@@ -68,7 +68,7 @@ export declare function selectInnerJoin<TLeft = Record<string, unknown>, TRight
|
|
|
68
68
|
export declare function insertMany<TRow = any>(db: AnyDb, table: WritableTable, rows: ReadonlyArray<Record<string, unknown>>): Promise<readonly TRow[]>;
|
|
69
69
|
export declare function insertOne<TRow = any>(db: AnyDb, table: WritableTable, values: Record<string, unknown>): Promise<TRow | undefined>;
|
|
70
70
|
export declare function updateMany<TRow = any>(db: AnyDb, table: WritableTable, set: Record<string, unknown>, where: WhereObject): Promise<readonly TRow[]>;
|
|
71
|
-
export declare function deleteMany(db: AnyDb, table: WritableTable, where: WhereObject): Promise<
|
|
71
|
+
export declare function deleteMany(db: AnyDb, table: WritableTable, where: WhereObject): Promise<number>;
|
|
72
72
|
export declare function resolveConflictColumns(table: TableLike, info: TableInfo, conflictKeys: readonly string[] | undefined): readonly string[];
|
|
73
73
|
export type UpsertOnConflictOptions = {
|
|
74
74
|
readonly conflictKeys: readonly string[];
|
package/dist/db/query.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// fetchOne<T>(db, table, where) → T | undefined
|
|
6
6
|
// insertOne<T>(db, table, values) → T | undefined
|
|
7
7
|
// updateMany<T>(db, table, set, where) → readonly T[]
|
|
8
|
-
// deleteMany(db, table, where) →
|
|
8
|
+
// deleteMany(db, table, where) → number of deleted rows
|
|
9
9
|
// countWhere(db, table, where?) → number
|
|
10
10
|
// upsertByPk / upsertOnConflict / incrementCounter
|
|
11
11
|
// deleteManyBatched(db, table, where, { limit }) → { deleted, batches }
|
|
@@ -1108,10 +1108,16 @@ export async function deleteMany(db, table, where) {
|
|
|
1108
1108
|
let sqlText = `DELETE FROM ${quoteIdent(info.name)}`;
|
|
1109
1109
|
if (w.sqlText)
|
|
1110
1110
|
sqlText += ` WHERE ${w.sqlText}`;
|
|
1111
|
-
await unsafeLoggingClosedConnection(db, sqlText, w.values, {
|
|
1111
|
+
const result = await unsafeLoggingClosedConnection(db, sqlText, w.values, {
|
|
1112
1112
|
operation: "deleteMany",
|
|
1113
1113
|
table: info.name,
|
|
1114
1114
|
});
|
|
1115
|
+
// postgres-js reports the affected-row count of a DELETE on the result, no RETURNING needed.
|
|
1116
|
+
const count = propertyOf(result, "count");
|
|
1117
|
+
if (typeof count !== "number") {
|
|
1118
|
+
throw new Error(`deleteMany(${info.name}): the driver result carries no affected-row count`);
|
|
1119
|
+
}
|
|
1120
|
+
return count;
|
|
1115
1121
|
}
|
|
1116
1122
|
function insertEntries(info, values) {
|
|
1117
1123
|
return Object.entries(values)
|
|
@@ -3,6 +3,8 @@ import type { ExecutorOnly } from "@cosmicdrift/kumiko-types/executor-brand";
|
|
|
3
3
|
import type { TenancyBrand } from "@cosmicdrift/kumiko-types/tenancy-brand";
|
|
4
4
|
import type { EntityDefinition, EntityRelations, FieldDefinition, FieldsMap } from "../engine/types/index.ts";
|
|
5
5
|
import type { Table } from "./event-store-executor-context.ts";
|
|
6
|
+
import type { LocatedTimestampRead } from "./located-timestamp.ts";
|
|
7
|
+
import type { MoneyRead } from "./money.ts";
|
|
6
8
|
export { toSnakeCase } from "../utils/case.ts";
|
|
7
9
|
import { type ColumnBuilder, type ColumnHandle, type SchemaTable, type TableColumns } from "./dialect.ts";
|
|
8
10
|
export declare const SOFT_DELETE_LIVE_ROW_PREDICATE = "\"is_deleted\" = false";
|
|
@@ -10,25 +12,23 @@ export declare function physicalColumnName(table: Table, field: string): string;
|
|
|
10
12
|
export type AnyPgColumn = ColumnHandle;
|
|
11
13
|
export declare const READ_MODEL_PREFIX = "read_";
|
|
12
14
|
export declare function toTableName(entityName: string): string;
|
|
13
|
-
type Col<
|
|
15
|
+
type Col<T> = ColumnHandle & {
|
|
14
16
|
readonly __notNull: true;
|
|
17
|
+
readonly __value?: T;
|
|
15
18
|
};
|
|
16
|
-
type NullCol<
|
|
19
|
+
type NullCol<T> = ColumnHandle & {
|
|
17
20
|
readonly __notNull: false;
|
|
21
|
+
readonly __value?: T;
|
|
18
22
|
};
|
|
19
|
-
type ColumnsForField<K extends string, F extends FieldDefinition> = F
|
|
23
|
+
type ColumnsForField<K extends string, F extends FieldDefinition> = ValueColumnsForField<K, F> & BlindIndexColumnForField<K, F>;
|
|
24
|
+
type BlindIndexColumnForField<K extends string, F extends FieldDefinition> = F extends {
|
|
20
25
|
type: "text";
|
|
21
26
|
lookupable: true;
|
|
22
|
-
} ? (F extends {
|
|
23
|
-
required: true;
|
|
24
27
|
} ? {
|
|
25
|
-
readonly [P in K]: Col<string>;
|
|
26
|
-
} : {
|
|
27
|
-
readonly [P in K]: NullCol<string>;
|
|
28
|
-
}) & {
|
|
29
28
|
readonly [P in `${K}Bidx`]: NullCol<string>;
|
|
30
|
-
} :
|
|
31
|
-
|
|
29
|
+
} : unknown;
|
|
30
|
+
type ValueColumnsForField<K extends string, F extends FieldDefinition> = F extends {
|
|
31
|
+
type: "text" | "longText" | "select" | "tz";
|
|
32
32
|
} ? F extends {
|
|
33
33
|
required: true;
|
|
34
34
|
} ? {
|
|
@@ -160,6 +160,40 @@ type SoftDeleteColumnsType = {
|
|
|
160
160
|
readonly deletedAt: NullCol<Temporal.Instant>;
|
|
161
161
|
readonly deletedById: NullCol<string>;
|
|
162
162
|
};
|
|
163
|
+
type ColumnValue<C> = C extends {
|
|
164
|
+
readonly __notNull: true;
|
|
165
|
+
readonly __value?: infer T;
|
|
166
|
+
} ? T : C extends {
|
|
167
|
+
readonly __notNull: false;
|
|
168
|
+
readonly __value?: infer T;
|
|
169
|
+
} ? T | null : unknown;
|
|
170
|
+
type RowOfColumns<C> = {
|
|
171
|
+
readonly [K in keyof C]: ColumnValue<C[K]>;
|
|
172
|
+
};
|
|
173
|
+
type CompoundRowField<K extends string, V, F> = F extends {
|
|
174
|
+
required: true;
|
|
175
|
+
} ? {
|
|
176
|
+
readonly [P in K]: V;
|
|
177
|
+
} : {
|
|
178
|
+
readonly [P in K]?: V;
|
|
179
|
+
};
|
|
180
|
+
type RowForField<K extends string, F extends FieldDefinition> = F extends {
|
|
181
|
+
type: "money";
|
|
182
|
+
} ? CompoundRowField<K, MoneyRead, F> : F extends {
|
|
183
|
+
type: "locatedTimestamp";
|
|
184
|
+
} ? CompoundRowField<K, LocatedTimestampRead, F> : [ValueColumnsForField<K, F>] extends [never] ? {
|
|
185
|
+
readonly [P in K]: unknown;
|
|
186
|
+
} : RowOfColumns<ValueColumnsForField<K, F>>;
|
|
187
|
+
type RowForFields<F extends FieldsMap> = UnionToIntersection<{
|
|
188
|
+
[K in keyof F & string]: RowForField<K, F[K]>;
|
|
189
|
+
}[keyof F & string]>;
|
|
190
|
+
type SoftDeleteRow<E extends EntityDefinition> = E["softDelete"] extends true ? RowOfColumns<SoftDeleteColumnsType> : [E["softDelete"]] extends [false | undefined] ? unknown : Partial<RowOfColumns<SoftDeleteColumnsType>>;
|
|
191
|
+
type Flatten<T> = {
|
|
192
|
+
[K in keyof T]: T[K];
|
|
193
|
+
} & {};
|
|
194
|
+
/** The row `executor.list()` / `executor.detail()` return for entity `E`. A wide
|
|
195
|
+
* `EntityDefinition` (fields without literal keys) yields `Record<string, unknown>`. */
|
|
196
|
+
export type EntityRow<E extends EntityDefinition = EntityDefinition> = string extends keyof E["fields"] ? Record<string, unknown> : Flatten<RowOfColumns<BaseColumnsType<E>> & SoftDeleteRow<E> & RowForFields<E["fields"]>>;
|
|
163
197
|
export type { ExecutorOnly, NotExecutorOnly } from "@cosmicdrift/kumiko-types/executor-brand";
|
|
164
198
|
export type { EntityTenancy, TenancyBrand } from "@cosmicdrift/kumiko-types/tenancy-brand";
|
|
165
199
|
type EntityTableTenancy<E extends EntityDefinition> = [NonNullable<E["tenancy"]>] extends [never] ? "tenant" : [NonNullable<E["tenancy"]>] extends ["global"] ? "global" : "tenant";
|
package/dist/db/tenant-db.js
CHANGED
|
@@ -504,7 +504,9 @@ function buildTenantDb(options) {
|
|
|
504
504
|
return Promise.reject(new Error("db.global().deleteMany without where would mass-delete every tenant's rows. Pass at least one where condition."));
|
|
505
505
|
}
|
|
506
506
|
report("global-write", globalWriteReason());
|
|
507
|
-
return withDbSpan("delete", table, async () =>
|
|
507
|
+
return withDbSpan("delete", table, async () => {
|
|
508
|
+
await bunDeleteMany(db, table, where);
|
|
509
|
+
});
|
|
508
510
|
},
|
|
509
511
|
// @cast-boundary phantom-brand — GlobalWrites is only present in the type when TTable is not ExecutorOnly; the object above always carries the methods.
|
|
510
512
|
};
|