@cosmicdrift/kumiko-framework 0.220.1 → 0.222.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/package.json +7 -3
- package/src/__tests__/store-table.integration.test.ts +2 -1
- package/src/__tests__/upgrade-cli.test.ts +81 -12
- package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +1 -3
- package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +1 -3
- package/src/api/__tests__/auth-routes-mfa-verify.test.ts +1 -3
- package/src/api/__tests__/pii-leak-guard.integration.test.ts +17 -5
- package/src/api/api-constants.ts +10 -0
- package/src/api/auth-routes.ts +3 -0
- package/src/api/index.ts +1 -0
- package/src/api/pii-leak-guard.ts +4 -5
- package/src/api/server.ts +17 -1
- package/src/arg-parser.ts +1 -1
- package/src/bun-db/__tests__/coerce-row-plain-date.test.ts +2 -0
- package/src/bun-db/__tests__/coerce-row-temporal.test.ts +2 -1
- package/src/bun-db/index.ts +1 -0
- package/src/bun-db/query.ts +30 -14
- package/src/crypto/index.ts +1 -0
- package/src/crypto/is-self-pii-field.ts +8 -0
- package/src/crypto/subject-resolver.ts +4 -3
- package/src/db/__tests__/event-store-executor-list.integration.test.ts +31 -2
- package/src/db/__tests__/migrate-generator.test.ts +12 -0
- package/src/db/__tests__/multi-row-insert.integration.test.ts +2 -0
- package/src/db/__tests__/schema-migration.integration.test.ts +1 -0
- package/src/db/__tests__/source-shadow-create.integration.test.ts +2 -0
- package/src/db/__tests__/table-builder-meta-lockstep.test.ts +29 -0
- package/src/db/blind-index-cleanup.ts +36 -19
- package/src/db/entity-table-meta.ts +6 -1
- package/src/db/event-store-executor-context.ts +2 -2
- package/src/db/event-store-executor-read.ts +7 -6
- package/src/db/event-store-executor-write.ts +103 -49
- package/src/db/index.ts +2 -0
- package/src/db/migrate-generator.ts +14 -0
- package/src/db/queries/__tests__/unsafe-read-retrying.test.ts +8 -1
- package/src/db/queries/backfill-pii.ts +13 -10
- package/src/db/queries/raw-sql.ts +14 -2
- package/src/db/queries/seed-context.ts +8 -4
- package/src/db/table-builder.ts +10 -3
- package/src/derivatives/__tests__/variant-key.test.ts +123 -1
- package/src/derivatives/__tests__/variant-route.integration.test.ts +3 -0
- package/src/derivatives/derivatives-context.ts +4 -0
- package/src/derivatives/index.ts +9 -1
- package/src/derivatives/variant-key.ts +68 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +32 -26
- package/src/engine/__tests__/boot-validator.test.ts +29 -3
- package/src/engine/__tests__/role-assignment.test.ts +41 -17
- package/src/engine/__tests__/schema-builder.test.ts +7 -7
- package/src/engine/boot-validator/__tests__/i18n-keys.test.ts +147 -14
- package/src/engine/boot-validator/entity-handler.ts +5 -0
- package/src/engine/boot-validator/pii-retention.ts +16 -4
- package/src/engine/boot-validator/screens.ts +9 -2
- package/src/engine/embedded-derived.ts +11 -10
- package/src/engine/extensions/storage-provider.ts +28 -0
- package/src/engine/extensions/user-data.ts +4 -0
- package/src/engine/feature-ast/__tests__/parse.test.ts +1 -1
- package/src/engine/feature-ast/__tests__/patch.test.ts +10 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +1 -3
- package/src/engine/feature-ast/extractors/ai-steps.ts +26 -40
- package/src/engine/feature-ast/extractors/index.ts +2 -0
- package/src/engine/feature-ast/extractors/shared.ts +26 -1
- package/src/engine/feature-ast/parse.ts +10 -25
- package/src/engine/feature-ast/patch.ts +23 -16
- package/src/engine/feature-ast/render.ts +3 -3
- package/src/engine/field-helpers.ts +1 -1
- package/src/engine/index.ts +5 -0
- package/src/engine/pattern-library/mixed-schemas.ts +6 -0
- package/src/engine/role-assignment.ts +36 -18
- package/src/engine/schema-builder.ts +14 -2
- package/src/errors/__tests__/classes.test.ts +21 -2
- package/src/errors/__tests__/write-failures.test.ts +13 -3
- package/src/errors/classes.ts +14 -13
- package/src/errors/kumiko-error.ts +11 -11
- package/src/errors/write-error-info.ts +1 -1
- package/src/event-store/__tests__/backfill-pii.integration.test.ts +58 -0
- package/src/event-store/__tests__/perf.integration.test.ts +5 -1
- package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +1 -0
- package/src/event-store/event-store.ts +7 -0
- package/src/event-store/index.ts +1 -0
- package/src/files/__tests__/files.integration.test.ts +181 -2
- package/src/files/__tests__/local-provider.contract.test.ts +14 -0
- package/src/files/__tests__/storage-tracking.integration.test.ts +3 -0
- package/src/files/file-routes.ts +53 -6
- package/src/files/in-memory-provider.ts +4 -0
- package/src/files/local-provider.ts +22 -1
- package/src/i18n/__tests__/mail-registry.test.ts +13 -1
- package/src/i18n/__tests__/request-locale.test.ts +19 -0
- package/src/i18n/index.ts +7 -1
- package/src/i18n/mail-registry.ts +10 -0
- package/src/i18n/request-locale.ts +11 -2
- package/src/i18n/required-surface-keys.ts +3 -1
- package/src/jobs/job-runner.ts +22 -10
- package/src/lifecycle/signal-handlers.ts +2 -0
- package/src/pipeline/__tests__/distributed-lock.integration.test.ts +12 -0
- package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.test.ts +22 -38
- package/src/pipeline/__tests__/tenant-timezone-cache.test.ts +89 -0
- package/src/pipeline/dispatch-shared.ts +39 -2
- package/src/pipeline/dispatch-write.ts +48 -0
- package/src/pipeline/dispatcher.ts +5 -0
- package/src/pipeline/distributed-lock.ts +3 -0
- package/src/pipeline/tenant-timezone-cache.ts +92 -0
- package/src/schema-cli.ts +39 -48
- package/src/scripts/codemod/crypto-shredding-testing-move.ts +64 -32
- package/src/scripts/codemod/pii-personal-migration.ts +7 -7
- package/src/search/purge-subject.ts +4 -3
- package/src/search/reindex-entity.ts +2 -2
- package/src/stack/__tests__/request-helper.integration.test.ts +24 -10
- package/src/stack/__tests__/request-helper.test.ts +2 -2
- package/src/testing/file-provider-contract.ts +19 -0
- package/src/ui-types/index.ts +1 -0
- package/src/upgrade-cli.ts +112 -15
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import type { EntityTableMeta } from "../entity-table-meta";
|
|
3
3
|
import {
|
|
4
|
+
assertValidMigrationName,
|
|
4
5
|
diffSnapshots,
|
|
5
6
|
generateMigration,
|
|
6
7
|
renderMigrationSql,
|
|
@@ -197,3 +198,14 @@ describe("renderMigrationSql — managed recreate vs unmanaged in-place", () =>
|
|
|
197
198
|
expect(sql).toContain("new NOT NULL column(s) without default: envelope");
|
|
198
199
|
});
|
|
199
200
|
});
|
|
201
|
+
|
|
202
|
+
describe("assertValidMigrationName", () => {
|
|
203
|
+
test("accepts alphanumeric hyphenated names", () => {
|
|
204
|
+
expect(() => assertValidMigrationName("add-user-table")).not.toThrow();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("rejects leading hyphen and oversized names", () => {
|
|
208
|
+
expect(() => assertValidMigrationName("-foo")).toThrow(/Invalid migration name/);
|
|
209
|
+
expect(() => assertValidMigrationName(`a${"x".repeat(64)}`)).toThrow(/Invalid migration name/);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
11
11
|
import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
|
|
12
|
+
import { Temporal } from "temporal-polyfill";
|
|
12
13
|
import { selectMany } from "../../db/query";
|
|
13
14
|
import { buildEntityTable } from "../../db/table-builder";
|
|
14
15
|
import { createEntity, createTextField } from "../../engine";
|
|
@@ -61,6 +62,7 @@ describe("instant() customType is forgiving with ISO strings", () => {
|
|
|
61
62
|
expect(rows).toHaveLength(1);
|
|
62
63
|
const insertedAt = rows[0]?.["insertedAt"];
|
|
63
64
|
expect(insertedAt).toBeDefined();
|
|
65
|
+
expect(insertedAt).toBeInstanceOf(Temporal.Instant);
|
|
64
66
|
expect((insertedAt as Temporal.Instant).toString()).toBe(isoString);
|
|
65
67
|
});
|
|
66
68
|
});
|
|
@@ -314,6 +314,7 @@ describe("date field — real DATE column, no TZ dependence (kumiko-framework#19
|
|
|
314
314
|
const rows = await selectMany(testDb.db, table);
|
|
315
315
|
expect(rows).toHaveLength(1);
|
|
316
316
|
const periodFrom = (rows[0] as { periodFrom: unknown }).periodFrom;
|
|
317
|
+
expect(periodFrom).toBeInstanceOf(Temporal.PlainDate);
|
|
317
318
|
expect((periodFrom as Temporal.PlainDate).toString()).toBe("2026-03-15");
|
|
318
319
|
});
|
|
319
320
|
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
15
15
|
import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
|
|
16
|
+
import { Temporal } from "temporal-polyfill";
|
|
16
17
|
import { selectMany } from "../../db/query";
|
|
17
18
|
import { buildEntityTable } from "../../db/table-builder";
|
|
18
19
|
import { createEntity, createTextField } from "../../engine";
|
|
@@ -52,6 +53,7 @@ describe("entity with a `source` field — create-path is shadow-proof", () => {
|
|
|
52
53
|
expect(rows[0]?.["source"]).toBe("import");
|
|
53
54
|
const insertedAt = rows[0]?.["insertedAt"];
|
|
54
55
|
expect(insertedAt).toBeDefined();
|
|
56
|
+
expect(insertedAt).toBeInstanceOf(Temporal.Instant);
|
|
55
57
|
expect((insertedAt as Temporal.Instant).toString()).toBe("2026-01-15T12:00:00Z");
|
|
56
58
|
});
|
|
57
59
|
});
|
|
@@ -160,3 +160,32 @@ describe("lock-step — lookupable / blind-index (#818)", () => {
|
|
|
160
160
|
expect(partial?.whereSql).toBe('"email_bidx" IS NOT NULL');
|
|
161
161
|
});
|
|
162
162
|
});
|
|
163
|
+
|
|
164
|
+
// Fourth probe: softDelete + lookupable (#2464) — the partial bidx unique
|
|
165
|
+
// must exclude soft-deleted rows from the uniqueness scope, otherwise a
|
|
166
|
+
// soft-deleted row blocks the same value for a new/restored row.
|
|
167
|
+
const entityWithSoftDeleteAndLookupable = createEntity({
|
|
168
|
+
table: "read_lockstep_probe_bidx_sd",
|
|
169
|
+
fields: {
|
|
170
|
+
email: { type: "text", required: true, pii: true, lookupable: true },
|
|
171
|
+
tenantSlug: { type: "text", required: true },
|
|
172
|
+
},
|
|
173
|
+
softDelete: true,
|
|
174
|
+
indexes: [{ columns: ["tenantSlug", "email"], unique: true }],
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("lock-step — softDelete + lookupable/blind-index (#2464)", () => {
|
|
178
|
+
const fromBuilder = asEntityTableMeta(
|
|
179
|
+
buildEntityTable("lockstepProbeBidxSd", entityWithSoftDeleteAndLookupable),
|
|
180
|
+
);
|
|
181
|
+
const fromMeta = deriveEntityTableMeta("lockstepProbeBidxSd", entityWithSoftDeleteAndLookupable);
|
|
182
|
+
|
|
183
|
+
test("identical indexes incl. soft-delete-aware bidx partial where", () => {
|
|
184
|
+
expect(byName<IndexMeta>(fromBuilder?.indexes ?? [])).toEqual(
|
|
185
|
+
byName<IndexMeta>(fromMeta.indexes),
|
|
186
|
+
);
|
|
187
|
+
const partial = fromMeta.indexes.find((i) => i.name.endsWith("_tenant_slug_email_unique_bidx"));
|
|
188
|
+
expect(partial).toBeDefined();
|
|
189
|
+
expect(partial?.whereSql).toBe('"email_bidx" IS NOT NULL AND "is_deleted" = false');
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -13,11 +13,12 @@
|
|
|
13
13
|
|
|
14
14
|
import { collectLookupableFields } from "../crypto/blind-index";
|
|
15
15
|
import { quoteIdent, subjectCiphertextLikePattern } from "../crypto/ciphertext-pattern";
|
|
16
|
+
import { isSelfPiiField } from "../crypto/is-self-pii-field";
|
|
16
17
|
import type { FeatureDefinition } from "../engine/types";
|
|
17
18
|
import { toSnakeCase } from "../utils/case";
|
|
18
19
|
import type { DbRunner } from "./connection";
|
|
19
20
|
import { resolveTableName } from "./entity-table-meta";
|
|
20
|
-
import { executeRawQuery } from "./queries/raw-sql";
|
|
21
|
+
import { executeRawQuery, executeRawQueryRead } from "./queries/raw-sql";
|
|
21
22
|
|
|
22
23
|
export async function nullBlindIndexesForSubject(
|
|
23
24
|
db: DbRunner,
|
|
@@ -47,38 +48,54 @@ export async function nullBlindIndexesForSubject(
|
|
|
47
48
|
|
|
48
49
|
// Tenant-scope oracle for crypto-shredding's forget-subject (mh#349): a
|
|
49
50
|
// "user"-kind subject id is often not a real user (share-token recipient,
|
|
50
|
-
// email subscriber, ...) — those entities self-own their PII (`
|
|
51
|
-
//
|
|
51
|
+
// email subscriber, ...) — those entities self-own their PII (`pii: true`,
|
|
52
|
+
// i.e. their own row id IS the subject) and carry a real tenant_id,
|
|
52
53
|
// unlike read_users (systemStream, tenant_id always SYSTEM_TENANT_ID). This
|
|
53
54
|
// checks whether the subject row lives in the given tenant, so a tenant-
|
|
54
55
|
// scoped DPO can still forget subjects it truly owns without needing a
|
|
55
56
|
// tenant-membership row (which only exists for real users).
|
|
57
|
+
//
|
|
58
|
+
// Invariant: `id` must never be client-settable on self-PII entities — the
|
|
59
|
+
// framework create path strips client ids; app write paths MUST do the same
|
|
60
|
+
// or a DPO could plant a foreign subject id in their tenant and pass this
|
|
61
|
+
// oracle (#2348). Upgrade path: require an event-store provenance check
|
|
62
|
+
// (`aggregate_id = subjectId` under the actor tenant) when apps need
|
|
63
|
+
// client-supplied ids.
|
|
56
64
|
export async function subjectRowExistsInTenant(
|
|
57
65
|
db: DbRunner,
|
|
58
66
|
features: ReadonlyMap<string, FeatureDefinition>,
|
|
59
67
|
subjectId: string,
|
|
60
68
|
tenantId: string,
|
|
61
69
|
): Promise<boolean> {
|
|
70
|
+
// Prefetch which self-PII projection tables actually exist — probing a
|
|
71
|
+
// missing relation used to throw (and get swallowed), which both hid
|
|
72
|
+
// real schema bugs and risked poisoning the Bun.SQL connection for the
|
|
73
|
+
// rest of the forget TX (fw#2348 / framework#356).
|
|
74
|
+
const candidateTables: string[] = [];
|
|
62
75
|
for (const feature of features.values()) {
|
|
63
76
|
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
64
|
-
const hasSelfPiiField = Object.values(entity.fields).some(
|
|
65
|
-
(field) => "pii" in field && field.pii === true,
|
|
66
|
-
);
|
|
77
|
+
const hasSelfPiiField = Object.values(entity.fields).some(isSelfPiiField);
|
|
67
78
|
if (!hasSelfPiiField) continue;
|
|
68
|
-
|
|
69
|
-
try {
|
|
70
|
-
const rows = await executeRawQuery(
|
|
71
|
-
db,
|
|
72
|
-
`SELECT 1 FROM ${quoteIdent(tableName)} WHERE id = $1 AND tenant_id = $2 LIMIT 1`,
|
|
73
|
-
[subjectId, tenantId],
|
|
74
|
-
);
|
|
75
|
-
if (rows.length > 0) return true;
|
|
76
|
-
} catch {
|
|
77
|
-
// Table missing, id-type mismatch, ... — not evidence the subject is
|
|
78
|
-
// owned here. Fail-closed must come from an honest "not found" across
|
|
79
|
-
// every entity, never from a query blowing up on one of them.
|
|
80
|
-
}
|
|
79
|
+
candidateTables.push(resolveTableName(entityName, entity, undefined));
|
|
81
80
|
}
|
|
82
81
|
}
|
|
82
|
+
const existing = new Set<string>();
|
|
83
|
+
for (const tableName of candidateTables) {
|
|
84
|
+
const rows = await executeRawQueryRead<{ exists: boolean }>(
|
|
85
|
+
db,
|
|
86
|
+
`SELECT to_regclass(quote_ident($1)) IS NOT NULL AS exists`,
|
|
87
|
+
[tableName],
|
|
88
|
+
);
|
|
89
|
+
if (rows[0]?.exists === true) existing.add(tableName);
|
|
90
|
+
}
|
|
91
|
+
for (const tableName of candidateTables) {
|
|
92
|
+
if (!existing.has(tableName)) continue;
|
|
93
|
+
const rows = await executeRawQueryRead(
|
|
94
|
+
db,
|
|
95
|
+
`SELECT 1 FROM ${quoteIdent(tableName)} WHERE id = $1 AND tenant_id = $2 LIMIT 1`,
|
|
96
|
+
[subjectId, tenantId],
|
|
97
|
+
);
|
|
98
|
+
if (rows.length > 0) return true;
|
|
99
|
+
}
|
|
83
100
|
return false;
|
|
84
101
|
}
|
|
@@ -356,11 +356,16 @@ export function deriveEntityTableMeta(
|
|
|
356
356
|
const notNullParts = bidxCols
|
|
357
357
|
.filter((c, i) => c !== cols[i])
|
|
358
358
|
.map((c) => `"${c}" IS NOT NULL`);
|
|
359
|
+
// Soft-deleted rows keep their bidx hash — without this, a restored
|
|
360
|
+
// row can't reuse a value a soft-deleted sibling still holds
|
|
361
|
+
// (framework#2464). Kept in lock-step with table-builder.ts.
|
|
362
|
+
const whereParts =
|
|
363
|
+
entity.softDelete === true ? [...notNullParts, `"is_deleted" = false`] : notNullParts;
|
|
359
364
|
indexes.push({
|
|
360
365
|
name: `${indexName}_bidx`,
|
|
361
366
|
columns: bidxCols,
|
|
362
367
|
unique: true,
|
|
363
|
-
whereSql:
|
|
368
|
+
whereSql: whereParts.join(" AND "),
|
|
364
369
|
});
|
|
365
370
|
}
|
|
366
371
|
}
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
type KmsContext,
|
|
9
9
|
type LocalKeyKmsAdapter,
|
|
10
10
|
} from "../crypto";
|
|
11
|
-
import {
|
|
11
|
+
import { executeRawQueryRead } from "../db/queries/raw-sql";
|
|
12
12
|
import type { WhereObject } from "../db/query";
|
|
13
13
|
import { shiftParams } from "../engine/ownership";
|
|
14
14
|
import type {
|
|
@@ -381,7 +381,7 @@ export function buildExecutorContext(
|
|
|
381
381
|
whereParts.push(shifted.sqlText);
|
|
382
382
|
for (const p of shifted.params) params.push(p);
|
|
383
383
|
const sqlText = `SELECT * FROM "${tableName}" WHERE ${whereParts.join(" AND ")} LIMIT 1`;
|
|
384
|
-
return [...(await
|
|
384
|
+
return [...(await executeRawQueryRead<Record<string, unknown>>(db.raw, sqlText, params))];
|
|
385
385
|
}
|
|
386
386
|
|
|
387
387
|
return {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { KUMIKO_NAME_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
2
2
|
import { computeBlindIndex, configuredBlindIndexKey } from "../crypto";
|
|
3
|
-
import {
|
|
3
|
+
import { executeRawQueryRead } from "../db/queries/raw-sql";
|
|
4
4
|
import { coerceRow, extractTableInfo } from "../db/query";
|
|
5
5
|
import { buildOwnershipClause, shiftParams } from "../engine/ownership";
|
|
6
6
|
import type { EntityId } from "../engine/types";
|
|
@@ -138,9 +138,10 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
|
|
|
138
138
|
},
|
|
139
139
|
});
|
|
140
140
|
}
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
|
|
141
|
+
// Same choke-point as writes/index: systemStream docs live under
|
|
142
|
+
// SYSTEM_TENANT_ID; everything else under the session tenant — db.mode
|
|
143
|
+
// must not invent a second predicate (kumiko-framework#2412).
|
|
144
|
+
const searchTenantId = streamTenantFor(user);
|
|
144
145
|
const results = await effectiveSearchAdapter.search(searchTenantId, payload.search, {
|
|
145
146
|
filterType: entityName,
|
|
146
147
|
});
|
|
@@ -275,7 +276,7 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
|
|
|
275
276
|
const whereClauseSqlText = whereSql.length > 0 ? ` WHERE ${whereSql.join(" AND ")}` : "";
|
|
276
277
|
const listSql = `SELECT * FROM "${tableName}"${whereClauseSqlText}${orderByClause} LIMIT ${limit}${offsetClause}`;
|
|
277
278
|
|
|
278
|
-
const rawRows = await
|
|
279
|
+
const rawRows = await executeRawQueryRead<Record<string, unknown>>(db.raw, listSql, params);
|
|
279
280
|
// Per-row read-side rehydrate + snake→camel coercion for driver-agnostic field names.
|
|
280
281
|
// Coerce BEFORE rehydrate/decrypt: the raw SELECT * rows carry snake_case
|
|
281
282
|
// column names, while compound-type lookups (rehydrateMoney et al.) and the
|
|
@@ -321,7 +322,7 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
|
|
|
321
322
|
total = filterIds.length;
|
|
322
323
|
} else {
|
|
323
324
|
const countSql = `SELECT COUNT(*)::int AS count FROM "${tableName}"${whereClauseSqlText}`;
|
|
324
|
-
const countRows = await
|
|
325
|
+
const countRows = await executeRawQueryRead<{ count: number }>(db.raw, countSql, params);
|
|
325
326
|
total = countRows[0]?.count ?? 0;
|
|
326
327
|
}
|
|
327
328
|
}
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
import { generateId } from "../utils";
|
|
19
19
|
import { applyEntityEvent } from "./apply-entity-event";
|
|
20
20
|
import { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types";
|
|
21
|
-
import type { DbRow
|
|
21
|
+
import type { DbRow } from "./connection";
|
|
22
22
|
import type { EventStoreExecutor } from "./event-store-executor";
|
|
23
23
|
import {
|
|
24
24
|
buildEventMetadata,
|
|
@@ -200,8 +200,8 @@ export function createWriteVerbs(
|
|
|
200
200
|
// its own (same pattern as ctx.tryAppendEvent), and falls back to a
|
|
201
201
|
// plain call when db.raw is a bare pool connection with no active
|
|
202
202
|
// transaction to poison (seeds/tests calling the executor directly).
|
|
203
|
-
event = await runInSavepointIfSupported(db.raw, (sp) =>
|
|
204
|
-
append(sp
|
|
203
|
+
event = await runInSavepointIfSupported(db.raw, async (sp) =>
|
|
204
|
+
append(sp, {
|
|
205
205
|
aggregateId,
|
|
206
206
|
aggregateType: entityName,
|
|
207
207
|
tenantId: streamTenantFor(user),
|
|
@@ -251,8 +251,8 @@ export function createWriteVerbs(
|
|
|
251
251
|
// failed INSERT back; the error still propagates and stays catchable.
|
|
252
252
|
let result: Awaited<ReturnType<typeof applyEntityEvent>>;
|
|
253
253
|
try {
|
|
254
|
-
result = await runInSavepointIfSupported(db.raw, (sp) =>
|
|
255
|
-
applyEntityEvent(event, table, entity, sp
|
|
254
|
+
result = await runInSavepointIfSupported(db.raw, async (sp) =>
|
|
255
|
+
applyEntityEvent(event, table, entity, sp),
|
|
256
256
|
);
|
|
257
257
|
} catch (e) {
|
|
258
258
|
const mapped = tryMapUniqueViolation(e, entityName);
|
|
@@ -429,7 +429,7 @@ export function createWriteVerbs(
|
|
|
429
429
|
// confines a losing writer's unique-violation to a nested scope
|
|
430
430
|
// instead of poisoning the whole outer transaction.
|
|
431
431
|
const event = await runInSavepointIfSupported(db.raw, (sp) =>
|
|
432
|
-
append(sp
|
|
432
|
+
append(sp, {
|
|
433
433
|
aggregateId: String(payload.id),
|
|
434
434
|
aggregateType: entityName,
|
|
435
435
|
tenantId: streamTenantFor(user),
|
|
@@ -456,8 +456,8 @@ export function createWriteVerbs(
|
|
|
456
456
|
// failed nested one throws 500 instead of failing cleanly.
|
|
457
457
|
let result: Awaited<ReturnType<typeof applyEntityEvent>>;
|
|
458
458
|
try {
|
|
459
|
-
result = await runInSavepointIfSupported(db.raw, (sp) =>
|
|
460
|
-
applyEntityEvent(event, table, entity, sp
|
|
459
|
+
result = await runInSavepointIfSupported(db.raw, async (sp) =>
|
|
460
|
+
applyEntityEvent(event, table, entity, sp),
|
|
461
461
|
);
|
|
462
462
|
} catch (e) {
|
|
463
463
|
const mapped = tryMapUniqueViolation(e, entityName);
|
|
@@ -552,26 +552,38 @@ export function createWriteVerbs(
|
|
|
552
552
|
// to rebuild from the event log alone. `existing` came from loadById(),
|
|
553
553
|
// which decrypts — re-encrypt before persisting so plaintext doesn't
|
|
554
554
|
// land in the immutable log.
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
555
|
+
let event: Awaited<ReturnType<typeof append>>;
|
|
556
|
+
try {
|
|
557
|
+
event = await runInSavepointIfSupported(db.raw, async (sp) =>
|
|
558
|
+
append(sp, {
|
|
559
|
+
aggregateId: String(payload.id),
|
|
560
|
+
aggregateType: entityName,
|
|
561
|
+
tenantId: streamTenantFor(user),
|
|
562
|
+
expectedVersion: currentVersion,
|
|
563
|
+
type: entityEventName(entityName, "deleted"),
|
|
564
|
+
payload: { previous: await encryptForStorage(existing, user) },
|
|
565
|
+
metadata: buildEventMetadata(user),
|
|
566
|
+
}),
|
|
567
|
+
);
|
|
568
|
+
} catch (e) {
|
|
569
|
+
if (e instanceof EventStoreVersionConflict) {
|
|
570
|
+
return writeFailure(
|
|
571
|
+
new FrameworkVersionConflict({
|
|
572
|
+
entityId: payload.id,
|
|
573
|
+
expectedVersion: currentVersion,
|
|
574
|
+
currentVersion: -1,
|
|
575
|
+
}),
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
if (e instanceof EventStoreIdempotentAppendConflict) {
|
|
579
|
+
return writeFailure(new IdempotentReplayError({ idempotencyKey: e.idempotencyKey }));
|
|
580
|
+
}
|
|
581
|
+
throw e;
|
|
582
|
+
}
|
|
564
583
|
|
|
565
|
-
// Live==Rebuild via applyEntityEvent.
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
// (nur isDeleted/deletedAt/version-Bump). Live + Replay schreiben
|
|
569
|
-
// dasselbe — kein payload-override nötig.
|
|
570
|
-
// Savepoint like the create/update paths: a raw DB error out of the
|
|
571
|
-
// projection delete poisons the enclosing tx otherwise, surfacing as a
|
|
572
|
-
// 500 on the next write that shares it rather than a clean failure.
|
|
573
|
-
const deleteResult = await runInSavepointIfSupported(db.raw, (sp) =>
|
|
574
|
-
applyEntityEvent(event, table, entity, sp as DbRunner),
|
|
584
|
+
// Live==Rebuild via applyEntityEvent. Savepoint like create/update.
|
|
585
|
+
const deleteResult = await runInSavepointIfSupported(db.raw, async (sp) =>
|
|
586
|
+
applyEntityEvent(event, table, entity, sp),
|
|
575
587
|
);
|
|
576
588
|
if (deleteResult.kind !== "applied") {
|
|
577
589
|
return writeFailure(
|
|
@@ -626,19 +638,40 @@ export function createWriteVerbs(
|
|
|
626
638
|
streamTenantFor(user),
|
|
627
639
|
);
|
|
628
640
|
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
641
|
+
let event: Awaited<ReturnType<typeof append>>;
|
|
642
|
+
try {
|
|
643
|
+
event = await runInSavepointIfSupported(db.raw, async (sp) =>
|
|
644
|
+
append(sp, {
|
|
645
|
+
aggregateId: String(payload.id),
|
|
646
|
+
aggregateType: entityName,
|
|
647
|
+
tenantId: streamTenantFor(user),
|
|
648
|
+
expectedVersion: currentVersion,
|
|
649
|
+
type: entityEventName(entityName, "forgotten"),
|
|
650
|
+
// Re-encrypt like delete(): `existing` came decrypted from loadById —
|
|
651
|
+
// plaintext must not land in the immutable log, least of all on forget.
|
|
652
|
+
payload: { previous: await encryptForStorage(existing, user) },
|
|
653
|
+
metadata: buildEventMetadata(user),
|
|
654
|
+
}),
|
|
655
|
+
);
|
|
656
|
+
} catch (e) {
|
|
657
|
+
if (e instanceof EventStoreVersionConflict) {
|
|
658
|
+
return writeFailure(
|
|
659
|
+
new FrameworkVersionConflict({
|
|
660
|
+
entityId: payload.id,
|
|
661
|
+
expectedVersion: currentVersion,
|
|
662
|
+
currentVersion: -1,
|
|
663
|
+
}),
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
if (e instanceof EventStoreIdempotentAppendConflict) {
|
|
667
|
+
return writeFailure(new IdempotentReplayError({ idempotencyKey: e.idempotencyKey }));
|
|
668
|
+
}
|
|
669
|
+
throw e;
|
|
670
|
+
}
|
|
640
671
|
|
|
641
|
-
const forgetResult = await
|
|
672
|
+
const forgetResult = await runInSavepointIfSupported(db.raw, async (sp) =>
|
|
673
|
+
applyEntityEvent(event, table, entity, sp),
|
|
674
|
+
);
|
|
642
675
|
if (forgetResult.kind !== "applied") {
|
|
643
676
|
return writeFailure(
|
|
644
677
|
new InternalError({ message: "projection forget: applyEntityEvent skipped" }),
|
|
@@ -710,20 +743,41 @@ export function createWriteVerbs(
|
|
|
710
743
|
// `previous` to re-increment on restore without re-querying the entity
|
|
711
744
|
// table. `data` is the raw stored row — pii/encrypted fields are
|
|
712
745
|
// already ciphertext, no re-encrypt needed.
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
746
|
+
let event: Awaited<ReturnType<typeof append>>;
|
|
747
|
+
try {
|
|
748
|
+
event = await runInSavepointIfSupported(db.raw, (sp) =>
|
|
749
|
+
append(sp, {
|
|
750
|
+
aggregateId: String(payload.id),
|
|
751
|
+
aggregateType: entityName,
|
|
752
|
+
tenantId: streamTenantFor(user),
|
|
753
|
+
expectedVersion: currentVersion,
|
|
754
|
+
type: entityEventName(entityName, "restored"),
|
|
755
|
+
payload: { previous: data },
|
|
756
|
+
metadata: buildEventMetadata(user),
|
|
757
|
+
}),
|
|
758
|
+
);
|
|
759
|
+
} catch (e) {
|
|
760
|
+
if (e instanceof EventStoreVersionConflict) {
|
|
761
|
+
return writeFailure(
|
|
762
|
+
new FrameworkVersionConflict({
|
|
763
|
+
entityId: payload.id,
|
|
764
|
+
expectedVersion: currentVersion,
|
|
765
|
+
currentVersion: -1,
|
|
766
|
+
}),
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
if (e instanceof EventStoreIdempotentAppendConflict) {
|
|
770
|
+
return writeFailure(new IdempotentReplayError({ idempotencyKey: e.idempotencyKey }));
|
|
771
|
+
}
|
|
772
|
+
throw e;
|
|
773
|
+
}
|
|
722
774
|
|
|
723
775
|
// Live==Rebuild via applyEntityEvent. Restore schreibt nur isDeleted=
|
|
724
776
|
// false + version-Bump in die Tabelle — keine sensitive-Drift, daher
|
|
725
777
|
// kein payload-override nötig.
|
|
726
|
-
const restoreResult = await
|
|
778
|
+
const restoreResult = await runInSavepointIfSupported(db.raw, async (sp) =>
|
|
779
|
+
applyEntityEvent(event, table, entity, sp),
|
|
780
|
+
);
|
|
727
781
|
if (restoreResult.kind !== "applied" || restoreResult.row === null) {
|
|
728
782
|
return writeFailure(new InternalError({ message: "projection restore returned no row" }));
|
|
729
783
|
}
|
package/src/db/index.ts
CHANGED
|
@@ -76,6 +76,7 @@ export {
|
|
|
76
76
|
} from "./feature-table-sources";
|
|
77
77
|
export { flattenLocatedTimestamp, rehydrateLocatedTimestamp } from "./located-timestamp";
|
|
78
78
|
export {
|
|
79
|
+
assertValidMigrationName,
|
|
79
80
|
diffSnapshots,
|
|
80
81
|
type GenerateMigrationInput,
|
|
81
82
|
type GenerateMigrationOutput,
|
|
@@ -109,6 +110,7 @@ export {
|
|
|
109
110
|
type PgErrorInfo,
|
|
110
111
|
} from "./pg-error";
|
|
111
112
|
export { acquireNamespacedAdvisoryLock } from "./queries/advisory-lock";
|
|
113
|
+
export { executeRawQuery } from "./queries/raw-sql";
|
|
112
114
|
export type { SelectOptions, WhereObject, WhereValue } from "./query-api";
|
|
113
115
|
export {
|
|
114
116
|
asRawClient,
|
|
@@ -415,7 +415,21 @@ export type GenerateMigrationOutput = {
|
|
|
415
415
|
readonly diff: SchemaDiff;
|
|
416
416
|
};
|
|
417
417
|
|
|
418
|
+
// Shared allowlist for migration names — used by the CLI and by
|
|
419
|
+
// generateMigration so a programmatic caller cannot skip the check and
|
|
420
|
+
// smuggle path segments / newlines into the filename or SQL header.
|
|
421
|
+
export const MIGRATION_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
422
|
+
|
|
423
|
+
export function assertValidMigrationName(name: string): void {
|
|
424
|
+
if (!MIGRATION_NAME_RE.test(name)) {
|
|
425
|
+
throw new Error(
|
|
426
|
+
`Invalid migration name "${name}" — must start with a letter or digit, then letters/digits/"-"/"_" only (max 64 chars).`,
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
418
431
|
export function generateMigration(input: GenerateMigrationInput): GenerateMigrationOutput {
|
|
432
|
+
assertValidMigrationName(input.name);
|
|
419
433
|
const nextSnapshot = snapshotFromMetas(input.metas);
|
|
420
434
|
const diff = diffSnapshots(input.prevSnapshot, nextSnapshot);
|
|
421
435
|
const sqlContent = renderMigrationSql(diff, {
|
|
@@ -26,18 +26,23 @@ function closedConnectionError(): Error {
|
|
|
26
26
|
return Object.assign(new Error("The connection was closed."), { name: "AbortError" });
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
type RecordedCall = { readonly sql: string; readonly params: readonly unknown[] | undefined };
|
|
30
|
+
|
|
29
31
|
type FakeClient = {
|
|
30
32
|
unsafe: (sql: string, params?: readonly unknown[]) => Promise<readonly unknown[]>;
|
|
31
33
|
begin: () => never;
|
|
32
34
|
calls: number;
|
|
35
|
+
recordedCalls: RecordedCall[];
|
|
33
36
|
};
|
|
34
37
|
|
|
35
38
|
function fakeClient(failures: Error[], row: Record<string, unknown>): FakeClient {
|
|
36
39
|
const remaining = [...failures];
|
|
37
40
|
const client: FakeClient = {
|
|
38
41
|
calls: 0,
|
|
39
|
-
|
|
42
|
+
recordedCalls: [],
|
|
43
|
+
unsafe: async (sql, params) => {
|
|
40
44
|
client.calls++;
|
|
45
|
+
client.recordedCalls.push({ sql, params });
|
|
41
46
|
const err = remaining.shift();
|
|
42
47
|
if (err) throw err;
|
|
43
48
|
return [row];
|
|
@@ -55,6 +60,8 @@ describe("framework db/queries — closed-connection retry (#2323)", () => {
|
|
|
55
60
|
const result = await selectStreamMaxVersion(db as never, "agg1", "t1");
|
|
56
61
|
expect(result).toBe(5);
|
|
57
62
|
expect(db.calls).toBe(2);
|
|
63
|
+
expect(db.recordedCalls).toHaveLength(2);
|
|
64
|
+
expect(db.recordedCalls[0]).toEqual(db.recordedCalls[1]);
|
|
58
65
|
});
|
|
59
66
|
|
|
60
67
|
test("selectAggregateMaxVersion retries once and returns the version", async () => {
|
|
@@ -40,8 +40,13 @@
|
|
|
40
40
|
import { asRawClient } from "../../bun-db";
|
|
41
41
|
import { quoteIdent } from "../../crypto/ciphertext-pattern";
|
|
42
42
|
import { configuredEventPiiCatalog } from "../../crypto/event-pii";
|
|
43
|
-
import
|
|
44
|
-
|
|
43
|
+
import {
|
|
44
|
+
isLocalKeyKmsAdapter,
|
|
45
|
+
KeyErasedError,
|
|
46
|
+
KeyNotFoundError,
|
|
47
|
+
type KmsContext,
|
|
48
|
+
type SubjectId,
|
|
49
|
+
} from "../../crypto/kms-adapter";
|
|
45
50
|
import {
|
|
46
51
|
configuredPiiSubjectKms,
|
|
47
52
|
encryptPiiValueForSubject,
|
|
@@ -117,6 +122,10 @@ export async function backfillEventPiiEncryption(
|
|
|
117
122
|
"runProdApp({ kms }) / configurePiiSubjectKms(adapter) before running the backfill.",
|
|
118
123
|
);
|
|
119
124
|
}
|
|
125
|
+
if (!isLocalKeyKmsAdapter(kms)) {
|
|
126
|
+
throw new Error("backfillEventPiiEncryption requires a local-key KMS adapter");
|
|
127
|
+
}
|
|
128
|
+
const subjectKms = kms;
|
|
120
129
|
const batchSize = options.batchSize ?? 500;
|
|
121
130
|
const raw = asRawClient(db);
|
|
122
131
|
const kmsCtx: KmsContext = { requestId: "pii-backfill" };
|
|
@@ -353,13 +362,7 @@ export async function backfillEventPiiEncryption(
|
|
|
353
362
|
}
|
|
354
363
|
if (options.dryRun) return predictEncryptOutcome(subject);
|
|
355
364
|
try {
|
|
356
|
-
section[field] = await encryptPiiValueForSubject(
|
|
357
|
-
kms as LocalKeyKmsAdapter,
|
|
358
|
-
subject,
|
|
359
|
-
value,
|
|
360
|
-
kmsCtx,
|
|
361
|
-
field,
|
|
362
|
-
);
|
|
365
|
+
section[field] = await encryptPiiValueForSubject(subjectKms, subject, value, kmsCtx, field);
|
|
363
366
|
return "encrypted";
|
|
364
367
|
} catch (e) {
|
|
365
368
|
if (e instanceof KeyErasedError) {
|
|
@@ -376,7 +379,7 @@ export async function backfillEventPiiEncryption(
|
|
|
376
379
|
// writing to the subject-keys store.
|
|
377
380
|
async function predictEncryptOutcome(subject: SubjectId): Promise<FieldOutcome> {
|
|
378
381
|
try {
|
|
379
|
-
await
|
|
382
|
+
await subjectKms.getKey(subject, kmsCtx);
|
|
380
383
|
return "encrypted";
|
|
381
384
|
} catch (e) {
|
|
382
385
|
if (e instanceof KeyErasedError) return "erased";
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { AnyDb } from "../query";
|
|
2
|
-
import { asRawClient } from "../query";
|
|
2
|
+
import { asRawClient, unsafeReadRetrying } from "../query";
|
|
3
3
|
|
|
4
|
-
/** Escape hatch for caller-built SQL
|
|
4
|
+
/** Escape hatch for caller-built SQL that may write (or lock). No closed-connection retry —
|
|
5
|
+
* retrying an ambiguous write risks double-apply (#1358). Prefer {@link executeRawQueryRead}
|
|
6
|
+
* for SELECT-only paths. */
|
|
5
7
|
export async function executeRawQuery<T = Record<string, unknown>>(
|
|
6
8
|
db: AnyDb,
|
|
7
9
|
sqlText: string,
|
|
@@ -10,6 +12,16 @@ export async function executeRawQuery<T = Record<string, unknown>>(
|
|
|
10
12
|
return (await asRawClient(db).unsafe(sqlText, params)) as readonly T[];
|
|
11
13
|
}
|
|
12
14
|
|
|
15
|
+
/** SELECT-only escape hatch with the #1163 closed-connection retry. Do not pass
|
|
16
|
+
* INSERT/UPDATE/DELETE — retry re-executes the statement. */
|
|
17
|
+
export async function executeRawQueryRead<T = Record<string, unknown>>(
|
|
18
|
+
db: AnyDb,
|
|
19
|
+
sqlText: string,
|
|
20
|
+
params: readonly unknown[] = [],
|
|
21
|
+
): Promise<readonly T[]> {
|
|
22
|
+
return unsafeReadRetrying<T>(db, sqlText, params);
|
|
23
|
+
}
|
|
24
|
+
|
|
13
25
|
export async function pingDatabase(db: AnyDb): Promise<void> {
|
|
14
26
|
await asRawClient(db).unsafe("SELECT 1");
|
|
15
27
|
}
|