@cosmicdrift/kumiko-framework 0.371.1 → 0.373.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/rate-limiting.md +65 -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/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +47 -16
- 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/feature-ast/extractors/handlers.js +1 -1
- package/dist/engine/feature-ast/extractors/hooks.js +8 -8
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +150 -6
- package/dist/engine/feature-ast/pattern-change-schema.js +4 -3
- 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/engine/types/index.d.ts +2 -2
- package/dist/engine/types/index.js +1 -1
- 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/bind-pending-file-refs.d.ts +31 -1
- package/dist/files/bind-pending-file-refs.js +80 -6
- 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 +5 -1
- package/dist/files/index.js +4 -1
- package/dist/files/load-save-file-refs.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 +57 -39
- package/dist/pipeline/index.d.ts +1 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/rate-limit/index.d.ts +2 -1
- package/dist/rate-limit/index.js +1 -0
- package/dist/rate-limit/resolver.d.ts +1 -1
- package/dist/rate-limit/resolver.js +104 -2
- package/dist/rate-limit/unlimited-resolver.d.ts +2 -0
- package/dist/rate-limit/unlimited-resolver.js +22 -0
- package/dist/schema-cli.js +46 -5
- package/package.json +5 -5
- package/src/changes.json +55 -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
|
|
@@ -13,8 +13,29 @@ End-to-end rate limiting: L1 global-IP + L2 auth + L3 handler opt-in.
|
|
|
13
13
|
## What it shows
|
|
14
14
|
|
|
15
15
|
- Handler `rateLimit: { per, limit, windowSeconds }`
|
|
16
|
+
- Several buckets on one handler: `rateLimit: [{ per: "user", ... }, { per: "tenant+handler", ... }]`
|
|
16
17
|
- How L1/L2 from `buildServer` stack with L3
|
|
17
18
|
|
|
19
|
+
## Combined buckets
|
|
20
|
+
|
|
21
|
+
Pass a list to enforce several budgets on one handler, for example a per-caller and a per-tenant cap:
|
|
22
|
+
|
|
23
|
+
```ts illustration
|
|
24
|
+
r.writeHandler({
|
|
25
|
+
// ...
|
|
26
|
+
rateLimit: [
|
|
27
|
+
{ per: "user", limit: 2, windowSeconds: 3600 },
|
|
28
|
+
{ per: "tenant+handler", limit: 3, windowSeconds: 3600 },
|
|
29
|
+
],
|
|
30
|
+
});
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Each bucket applies on its own. The check is all-or-nothing: if any bucket is exhausted the call
|
|
34
|
+
answers 429 `rate_limited` for that bucket (the one with the longest wait when several are
|
|
35
|
+
exhausted) and no bucket is charged. Each `per` may appear once; an empty list fails at boot.
|
|
36
|
+
An anonymous handler needs every bucket to be ip-keyed, and `{ disabled: true, reason }`
|
|
37
|
+
stays a single declaration.
|
|
38
|
+
|
|
18
39
|
## Per-payload limits
|
|
19
40
|
|
|
20
41
|
A public write handler that mails an address typed by the caller is also limited per
|
|
@@ -67,7 +88,8 @@ bun test
|
|
|
67
88
|
// in each layer's test.
|
|
68
89
|
|
|
69
90
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
70
|
-
import {
|
|
91
|
+
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
92
|
+
import { createTestUser, TestUsers } from "@cosmicdrift/kumiko-framework/seeding";
|
|
71
93
|
import { setupTestStack, type TestStack } from "@cosmicdrift/kumiko-testing/stack";
|
|
72
94
|
import { createRateLimitShowcaseFeature } from "../feature";
|
|
73
95
|
|
|
@@ -130,6 +152,35 @@ describe("L3 — handler opt-in (per-user budget on expensive-search)", () => {
|
|
|
130
152
|
});
|
|
131
153
|
});
|
|
132
154
|
|
|
155
|
+
describe("L3 — combined buckets (per-user and per-tenant budget on submit-report)", () => {
|
|
156
|
+
const submitReport = "rl-showcase:write:submit-report";
|
|
157
|
+
|
|
158
|
+
async function rejectedBucket(user: SessionUser): Promise<string | undefined> {
|
|
159
|
+
const res = await stack.http.write(submitReport, {}, user);
|
|
160
|
+
expect(res.status).toBe(429);
|
|
161
|
+
const body = (await res.json()) as { error: { details?: { bucket?: string } } };
|
|
162
|
+
return body.error.details?.bucket;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
test("the user bucket rejects first; the tenant bucket only counts accepted requests", async () => {
|
|
166
|
+
await stack.http.writeOk(submitReport, {}, admin);
|
|
167
|
+
await stack.http.writeOk(submitReport, {}, admin);
|
|
168
|
+
expect(await rejectedBucket(admin)).toBe("user");
|
|
169
|
+
|
|
170
|
+
// The rejected third call did not touch the tenant bucket: one of its 3 tokens is left.
|
|
171
|
+
await stack.http.writeOk(submitReport, {}, TestUsers.user);
|
|
172
|
+
expect(await rejectedBucket(TestUsers.user)).toBe("tenant+handler");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("a caller without Admin or User role is rejected with access_denied before any bucket is charged", async () => {
|
|
176
|
+
const outsider = createTestUser({ id: 9, tenantId: admin.tenantId, roles: ["Guest"] });
|
|
177
|
+
const res = await stack.http.write(submitReport, {}, outsider);
|
|
178
|
+
expect(res.status).toBe(403);
|
|
179
|
+
const body = (await res.json()) as { error: { code: string } };
|
|
180
|
+
expect(body.error.code).toBe("access_denied");
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
133
184
|
describe("L1 — global IP middleware (covers every /api/* request)", () => {
|
|
134
185
|
test("21st request from same IP returns 429 with l1:ip bucket", async () => {
|
|
135
186
|
// L1 sits ahead of auth + every /api/* route. Target an authless,
|
|
@@ -205,6 +256,19 @@ export function createRateLimitShowcaseFeature(): FeatureDefinition {
|
|
|
205
256
|
access: { roles: ["Admin", "User"] },
|
|
206
257
|
rateLimit: { per: "user", limit: 3, windowSeconds: 60 },
|
|
207
258
|
});
|
|
259
|
+
|
|
260
|
+
// Combined budget: each caller gets 2 submits per hour and the tenant 3 per hour in
|
|
261
|
+
// total. Both buckets are checked together; a request one bucket rejects charges neither.
|
|
262
|
+
r.writeHandler({
|
|
263
|
+
name: "submit-report",
|
|
264
|
+
schema: z.object({}),
|
|
265
|
+
handler: async () => ({ isSuccess: true as const, data: {} }),
|
|
266
|
+
access: { roles: ["Admin", "User"] },
|
|
267
|
+
rateLimit: [
|
|
268
|
+
{ per: "user", limit: 2, windowSeconds: 3600 },
|
|
269
|
+
{ per: "tenant+handler", limit: 3, windowSeconds: 3600 },
|
|
270
|
+
],
|
|
271
|
+
});
|
|
208
272
|
});
|
|
209
273
|
}
|
|
210
274
|
```
|
|
@@ -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)
|