@cosmicdrift/kumiko-framework 0.165.0 → 2.0.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 +5 -3
- package/src/__tests__/consumer-cli.integration.test.ts +32 -0
- package/src/__tests__/schema-cli.integration.test.ts +1 -1
- package/src/api/__tests__/api.test.ts +267 -19
- package/src/api/__tests__/auth-middleware-anonymous-access-boot.test.ts +40 -0
- package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +16 -0
- package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +2 -1
- package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +64 -1
- package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +135 -0
- package/src/api/__tests__/batch.integration.test.ts +21 -2
- package/src/api/__tests__/jwt.test.ts +52 -2
- package/src/api/__tests__/redis-login-rate-limiter.integration.test.ts +72 -0
- package/src/api/__tests__/sse-broker.test.ts +57 -0
- package/src/api/__tests__/sse-route.test.ts +4 -0
- package/src/api/auth-routes.ts +165 -33
- package/src/api/index.ts +1 -0
- package/src/api/jwt.ts +22 -1
- package/src/api/routes.ts +103 -35
- package/src/api/server.ts +17 -1
- package/src/api/sse-broker.ts +39 -0
- package/src/bun-db/index.ts +1 -0
- package/src/bun-db/query.ts +12 -3
- package/src/consumer-cli.ts +60 -13
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +14 -1
- package/src/db/__tests__/located-timestamp.test.ts +19 -0
- package/src/db/__tests__/migrate-runner.test.ts +61 -0
- package/src/db/__tests__/replay-migration-sql.test.ts +131 -2
- package/src/db/__tests__/tenant-db-where-merge.test.ts +6 -2
- package/src/db/api.ts +2 -2
- package/src/db/bun-provider.ts +2 -2
- package/src/db/connection.ts +6 -3
- package/src/db/dialect.ts +1 -6
- package/src/db/entity-table-meta-types.ts +1 -1
- package/src/db/event-store-executor-context.ts +2 -3
- package/src/db/event-store-executor-read.ts +2 -3
- package/src/db/event-store-executor-write.ts +8 -0
- package/src/db/index.ts +8 -1
- package/src/db/located-timestamp.ts +4 -0
- package/src/db/migrate-runner.ts +107 -11
- package/src/db/pg-error.ts +8 -0
- package/src/db/postgres-provider.ts +2 -2
- package/src/db/queries/__tests__/event-store-idempotency-index.integration.test.ts +80 -0
- package/src/db/queries/ddl.ts +45 -0
- package/src/db/queries/event-store.ts +97 -5
- package/src/db/queries/test-stack.ts +4 -30
- package/src/db/reference-data.ts +2 -3
- package/src/db/replay-migration-sql.ts +114 -12
- package/src/db/tenant-db.ts +2 -4
- package/src/engine/__tests__/engine.test.ts +30 -0
- package/src/engine/__tests__/schema-builder.test.ts +18 -0
- package/src/engine/__tests__/store-table.test.ts +2 -2
- package/src/engine/boot-validator/nav.ts +5 -0
- package/src/engine/constants.ts +32 -6
- package/src/engine/create-app.ts +11 -0
- package/src/engine/effective-features.ts +12 -2
- package/src/engine/extensions/user-data.ts +12 -4
- package/src/engine/feature-ui-extensions.ts +2 -2
- package/src/engine/hook-helpers.ts +3 -1
- package/src/engine/index.ts +1 -1
- package/src/engine/ownership.ts +4 -3
- package/src/engine/registry-ingest.ts +14 -14
- package/src/engine/registry-state.ts +4 -1
- package/src/engine/schema-builder.ts +1 -0
- package/src/engine/steps/__tests__/duration-utils.test.ts +20 -0
- package/src/engine/steps/_duration-utils.ts +2 -0
- package/src/engine/steps/unsafe-projection-upsert.ts +1 -4
- package/src/engine/types/config.ts +1 -1
- package/src/engine/types/define-handler.ts +1 -1
- package/src/engine/types/entity-handlers.ts +1 -1
- package/src/engine/types/event-type-map.ts +1 -1
- package/src/engine/types/feature.ts +1 -1
- package/src/engine/types/fields.ts +1 -1
- package/src/engine/types/handlers.ts +1 -1
- package/src/engine/types/hooks.ts +1 -1
- package/src/engine/types/http-route.ts +1 -1
- package/src/engine/types/nav.ts +1 -1
- package/src/engine/types/ownership.ts +1 -1
- package/src/engine/types/projection.ts +1 -1
- package/src/engine/types/relations.ts +1 -1
- package/src/engine/types/screen.ts +1 -1
- package/src/engine/types/step.ts +1 -1
- package/src/engine/types/target-ref.ts +1 -1
- package/src/engine/types/tree-node.ts +1 -1
- package/src/engine/types/workspace.ts +1 -1
- package/src/engine/validate-projection-allowlist.ts +5 -5
- package/src/errors/classes.ts +21 -0
- package/src/errors/index.ts +1 -0
- package/src/errors/write-error-info.ts +6 -2
- package/src/event-store/__tests__/admin-api.integration.test.ts +27 -1
- package/src/event-store/__tests__/event-store.integration.test.ts +32 -0
- package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +22 -4
- package/src/event-store/admin-api.ts +11 -4
- package/src/event-store/event-store.ts +19 -4
- package/src/event-store/types.ts +1 -1
- package/src/files/__tests__/build-storage-key.test.ts +28 -0
- package/src/files/__tests__/local-provider.test.ts +31 -0
- package/src/files/__tests__/write-stream.test.ts +3 -3
- package/src/files/index.ts +1 -1
- package/src/files/local-provider.ts +6 -1
- package/src/files/types.ts +8 -1
- package/src/jobs/__tests__/jobs.integration.test.ts +167 -7
- package/src/jobs/job-runner.ts +41 -11
- package/src/logging/types.ts +1 -1
- package/src/observability/index.ts +1 -0
- package/src/observability/standard-metrics.ts +35 -2
- package/src/observability/types/index.ts +1 -1
- package/src/observability/types/metric.ts +1 -1
- package/src/observability/types/provider.ts +1 -1
- package/src/observability/types/span.ts +1 -1
- package/src/pipeline/__tests__/dispatcher.test.ts +151 -0
- package/src/pipeline/__tests__/event-consumer-state.integration.test.ts +31 -0
- package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +83 -0
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +107 -97
- package/src/pipeline/dispatch-shared.ts +59 -6
- package/src/pipeline/dispatch-stream.ts +32 -11
- package/src/pipeline/dispatcher.ts +7 -1
- package/src/pipeline/event-consumer-state.ts +16 -13
- package/src/pipeline/event-dispatcher-delivery.ts +20 -6
- package/src/pipeline/event-dispatcher.ts +22 -0
- package/src/pipeline/index.ts +2 -0
- package/src/pipeline/system-hooks.ts +87 -0
- package/src/rate-limit/__tests__/resolver.integration.test.ts +18 -0
- package/src/rate-limit/resolver.ts +6 -2
- package/src/schema-cli.ts +24 -12
- package/src/search/__tests__/reindex-entity.integration.test.ts +24 -1
- package/src/search/reindex-entity.ts +31 -2
- package/src/search/types.ts +1 -1
- package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +6 -2
- package/src/stack/db.ts +2 -1
- package/src/stack/push-entity-projection-tables.ts +2 -1
- package/src/stack/request-helper.ts +20 -1
- package/src/stack/table-helpers.ts +6 -4
- package/src/stack/test-stack.ts +18 -15
- package/src/testing/__tests__/late-bound.test.ts +7 -0
- package/src/testing/__tests__/wait-for.test.ts +6 -0
- package/src/testing/file-provider-contract.ts +26 -6
- package/src/testing/index.ts +1 -0
- package/src/testing/late-bound.ts +5 -3
- package/src/testing/wait-for.ts +3 -0
- package/src/testing/without-ambient-temporal.ts +14 -0
- package/src/time/geo-tz.ts +1 -1
- package/src/time/polyfill.ts +21 -38
- package/src/time/tz-context.ts +37 -31
- package/src/utils/__tests__/safe-json-temporal.test.ts +18 -0
- package/src/utils/safe-json.ts +13 -1
- package/src/engine/__tests__/registry-facade-sweep.test.ts +0 -80
package/src/bun-db/query.ts
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
// drizzle's getTableName + getTableColumns (drizzle weiterhin als type-
|
|
20
20
|
// reference, NICHT als runtime-API-call)
|
|
21
21
|
|
|
22
|
+
import { KUMIKO_META_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
22
23
|
import type {
|
|
23
24
|
SelectOptions,
|
|
24
25
|
WhereObject,
|
|
@@ -51,7 +52,6 @@ import type { BunDbRunner } from "./connection";
|
|
|
51
52
|
// enumerable props, so an entity field named `source`/`columns`/`tableName`/…
|
|
52
53
|
// would overwrite the matching meta key — reading the meta from the symbol is
|
|
53
54
|
// the only collision-safe path.
|
|
54
|
-
const KUMIKO_META_SYMBOL = Symbol.for("kumiko:schema:Meta");
|
|
55
55
|
|
|
56
56
|
function isEntityTableMeta(v: unknown): v is EntityTableMeta {
|
|
57
57
|
return (
|
|
@@ -199,14 +199,23 @@ export type {
|
|
|
199
199
|
WhereValue,
|
|
200
200
|
} from "@cosmicdrift/kumiko-types/where-clause-types";
|
|
201
201
|
|
|
202
|
+
const WHERE_OPERATOR_KEYS = ["gt", "gte", "lt", "lte", "ne", "in", "like"] as const;
|
|
203
|
+
// Forces a tsc error here (not just a silent runtime miss) the moment
|
|
204
|
+
// WhereOperator in @cosmicdrift/kumiko-types gains a key this array doesn't
|
|
205
|
+
// know about — the two live in different packages and can't share a value
|
|
206
|
+
// import without adding a runtime dependency edge.
|
|
207
|
+
type _WhereOperatorKeysExhaustive =
|
|
208
|
+
Exclude<keyof WhereOperator, (typeof WHERE_OPERATOR_KEYS)[number]> extends never ? true : never;
|
|
209
|
+
const _whereOperatorKeysExhaustive: _WhereOperatorKeysExhaustive = true;
|
|
210
|
+
void _whereOperatorKeysExhaustive;
|
|
211
|
+
|
|
202
212
|
function isWhereOperator(v: unknown): v is WhereOperator {
|
|
203
213
|
if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
|
|
204
214
|
// Don't false-positive on Date / Temporal / other domain-objects.
|
|
205
215
|
// WhereOperator is plain object literal with at most these keys.
|
|
206
216
|
const keys = Object.keys(v);
|
|
207
217
|
if (keys.length === 0) return false;
|
|
208
|
-
|
|
209
|
-
return keys.every((k) => opKeys.includes(k));
|
|
218
|
+
return keys.every((k) => (WHERE_OPERATOR_KEYS as readonly string[]).includes(k));
|
|
210
219
|
}
|
|
211
220
|
// Akzeptiert EITHER. Beide haben einen tableName und field→column-mapping.
|
|
212
221
|
// biome-ignore lint/suspicious/noExplicitAny: legacy drizzle pgTable surface
|
package/src/consumer-cli.ts
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
// out) entry point, own DB connection) so `kumiko-consumer` ships the same
|
|
7
7
|
// way `kumiko-schema` does.
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import { createConnection } from "./db/api";
|
|
10
|
+
import { dbConnectionOptionsFromEnv } from "./db/connection";
|
|
10
11
|
import { getConsumerState, restartConsumer } from "./pipeline";
|
|
11
12
|
import { ensureTemporalPolyfill } from "./time";
|
|
12
13
|
|
|
@@ -15,9 +16,45 @@ export type ConsumerCliOut = {
|
|
|
15
16
|
readonly err: (line: string) => void;
|
|
16
17
|
};
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
type ParsedConsumerCliArgs = {
|
|
20
|
+
readonly sub: string | undefined;
|
|
21
|
+
readonly name: string | undefined;
|
|
22
|
+
readonly instanceId: string | undefined;
|
|
23
|
+
readonly error: string | undefined;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Single pass: pulls --instance-id (both "--instance-id <id>" and
|
|
27
|
+
// "--instance-id=<id>") out of argv regardless of position, leaving the
|
|
28
|
+
// remaining positionals for sub/name — instead of two independent
|
|
29
|
+
// positional/flag reads that silently misparse "--instance-id" with no
|
|
30
|
+
// value, the "=" form, or a flag placed before the positionals (#1412).
|
|
31
|
+
function parseConsumerCliArgs(argv: readonly string[]): ParsedConsumerCliArgs {
|
|
32
|
+
const positionals: string[] = [];
|
|
33
|
+
let instanceId: string | undefined;
|
|
34
|
+
for (let i = 0; i < argv.length; i++) {
|
|
35
|
+
const arg = argv[i];
|
|
36
|
+
if (arg === undefined) continue;
|
|
37
|
+
if (arg === "--instance-id") {
|
|
38
|
+
const value = argv[i + 1];
|
|
39
|
+
if (value === undefined || value.startsWith("--")) {
|
|
40
|
+
return {
|
|
41
|
+
sub: undefined,
|
|
42
|
+
name: undefined,
|
|
43
|
+
instanceId: undefined,
|
|
44
|
+
error: "--instance-id braucht einen Wert.",
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
instanceId = value;
|
|
48
|
+
i++;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (arg.startsWith("--instance-id=")) {
|
|
52
|
+
instanceId = arg.slice("--instance-id=".length);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
positionals.push(arg);
|
|
56
|
+
}
|
|
57
|
+
return { sub: positionals[0], name: positionals[1], instanceId, error: undefined };
|
|
21
58
|
}
|
|
22
59
|
|
|
23
60
|
export async function runConsumerCli(
|
|
@@ -29,30 +66,40 @@ export async function runConsumerCli(
|
|
|
29
66
|
// is a Temporal.Instant, so without this every subcommand throws "Temporal
|
|
30
67
|
// is not defined" (same failure mode as schema-cli, see its polyfill test).
|
|
31
68
|
await ensureTemporalPolyfill();
|
|
32
|
-
const
|
|
69
|
+
const parsed = parseConsumerCliArgs(argv);
|
|
70
|
+
if (parsed.error) {
|
|
71
|
+
out.err(` ${parsed.error}`);
|
|
72
|
+
return 1;
|
|
73
|
+
}
|
|
74
|
+
const { sub, name, instanceId } = parsed;
|
|
33
75
|
|
|
34
76
|
if (sub !== "status" && sub !== "restart") {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
77
|
+
// Unknown subcommand must be visible on stderr, not just a bare exit 1 —
|
|
78
|
+
// ops invocations piping `2>&1 >/dev/null` or log pipelines that only
|
|
79
|
+
// watch stderr would otherwise see a failure with zero explanation
|
|
80
|
+
// (#1412). A bare help call (no subcommand at all) is not an error, so
|
|
81
|
+
// it keeps exit 0 + stdout.
|
|
82
|
+
const write = sub === undefined ? out.log : out.err;
|
|
83
|
+
if (sub !== undefined) out.err(` Unknown subcommand: ${sub}`);
|
|
84
|
+
write("");
|
|
85
|
+
write(" Subcommands:");
|
|
86
|
+
write(" status <name> [--instance-id <id>] Zeigt Status + Cursor eines Consumers");
|
|
87
|
+
write(" restart <name> [--instance-id <id>] Reaktiviert einen dead-Consumer (idle)");
|
|
88
|
+
write("");
|
|
40
89
|
return sub === undefined ? 0 : 1;
|
|
41
90
|
}
|
|
42
91
|
|
|
43
|
-
const name = argv[1];
|
|
44
92
|
if (!name) {
|
|
45
93
|
out.err(` Usage: consumer ${sub} <name> [--instance-id <id>]`);
|
|
46
94
|
return 1;
|
|
47
95
|
}
|
|
48
|
-
const instanceId = parseInstanceIdFlag(argv);
|
|
49
96
|
|
|
50
97
|
const dbUrl = process.env["DATABASE_URL"];
|
|
51
98
|
if (!dbUrl) {
|
|
52
99
|
out.err(" DATABASE_URL not set.");
|
|
53
100
|
return 1;
|
|
54
101
|
}
|
|
55
|
-
const { db, close } =
|
|
102
|
+
const { db, close } = await createConnection(dbUrl, dbConnectionOptionsFromEnv());
|
|
56
103
|
try {
|
|
57
104
|
if (sub === "status") {
|
|
58
105
|
const state = await getConsumerState(db, name, instanceId);
|
|
@@ -121,7 +121,8 @@ describe("event-store-executor write-verbs — entity-level ownership_denied", (
|
|
|
121
121
|
test("restore: role without a write-rule → ownership_denied", async () => {
|
|
122
122
|
const created = await crud.create({ email: "torestore@test.de" }, admin, tdb);
|
|
123
123
|
if (!created.isSuccess) throw new Error("setup failed");
|
|
124
|
-
await crud.delete({ id: created.data.id }, admin, tdb);
|
|
124
|
+
const deleted = await crud.delete({ id: created.data.id }, admin, tdb);
|
|
125
|
+
if (!deleted.isSuccess) throw new Error("setup failed: delete");
|
|
125
126
|
|
|
126
127
|
const result = await crud.restore({ id: created.data.id }, nonAdmin, tdb);
|
|
127
128
|
expect(result.isSuccess).toBe(false);
|
|
@@ -359,6 +360,18 @@ describe("event-store-executor write-verbs — concurrent version race + cache",
|
|
|
359
360
|
expect(results.filter((r) => !r.isSuccess && r.error.code === "version_conflict")).toHaveLength(
|
|
360
361
|
1,
|
|
361
362
|
);
|
|
363
|
+
|
|
364
|
+
// Guard against the known Bun.SQL pooled-connection-poisoning class: the
|
|
365
|
+
// losing update above provoked a real 23505 unique-violation on the
|
|
366
|
+
// shared pool. The two tests below this one reuse `tdb`/`testDb.db`, so
|
|
367
|
+
// a poisoned connection would surface as their unrelated queries
|
|
368
|
+
// failing — a trivial round-trip here catches that immediately instead
|
|
369
|
+
// of leaving it to whichever later test happens to hit the bad
|
|
370
|
+
// connection.
|
|
371
|
+
const healthCheck = (await asRawClient(testDb.db).unsafe(`SELECT 1 AS ok`)) as Array<{
|
|
372
|
+
ok: number;
|
|
373
|
+
}>;
|
|
374
|
+
expect(healthCheck[0]?.ok).toBe(1);
|
|
362
375
|
});
|
|
363
376
|
|
|
364
377
|
test("forget with entityCache clears the cache entry", async () => {
|
|
@@ -103,6 +103,25 @@ describe("flattenLocatedTimestamp — Insert/Update Convert", () => {
|
|
|
103
103
|
});
|
|
104
104
|
});
|
|
105
105
|
|
|
106
|
+
describe("rehydrateLocatedTimestamp — kumiko-framework#1490: no ambient Temporal global", () => {
|
|
107
|
+
test("rehydrates without relying on globalThis.Temporal", () => {
|
|
108
|
+
const savedGlobal = (globalThis as { Temporal?: unknown }).Temporal;
|
|
109
|
+
delete (globalThis as { Temporal?: unknown }).Temporal;
|
|
110
|
+
try {
|
|
111
|
+
const out = rehydrateLocatedTimestamp(
|
|
112
|
+
{ pickupUtc: "2026-04-15T09:00:00Z", pickupTz: "Europe/Lisbon" },
|
|
113
|
+
orderEntity,
|
|
114
|
+
);
|
|
115
|
+
expect(out).toEqual({
|
|
116
|
+
pickup: { at: "2026-04-15T10:00:00", tz: "Europe/Lisbon", utc: "2026-04-15T09:00:00Z" },
|
|
117
|
+
});
|
|
118
|
+
} finally {
|
|
119
|
+
if (savedGlobal === undefined) delete (globalThis as { Temporal?: unknown }).Temporal;
|
|
120
|
+
else (globalThis as { Temporal?: unknown }).Temporal = savedGlobal;
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
106
125
|
describe("rehydrateLocatedTimestamp — Read Convert", () => {
|
|
107
126
|
test("{ <name>Utc, <name>Tz } DB-Form → { at, tz, utc } API-Form (Pickup-Ort-lokal)", () => {
|
|
108
127
|
const out = rehydrateLocatedTimestamp(
|
|
@@ -16,4 +16,65 @@ describe("splitSqlStatements", () => {
|
|
|
16
16
|
test("filters empty segments", () => {
|
|
17
17
|
expect(splitSqlStatements("-- only comments\n; ;")).toEqual([]);
|
|
18
18
|
});
|
|
19
|
+
|
|
20
|
+
test("does not split mid-statement on a semicolon inside a line comment", () => {
|
|
21
|
+
const sql = `
|
|
22
|
+
-- sets default on the entity; the create/update handlers fill it
|
|
23
|
+
CREATE TABLE "a" ("id" uuid);
|
|
24
|
+
`;
|
|
25
|
+
expect(splitSqlStatements(sql)).toEqual(['CREATE TABLE "a" ("id" uuid);']);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("does not split on a semicolon inside a block comment", () => {
|
|
29
|
+
const sql = `
|
|
30
|
+
/* multi
|
|
31
|
+
line; with semi */
|
|
32
|
+
CREATE TABLE "a" ("id" uuid);
|
|
33
|
+
`;
|
|
34
|
+
expect(splitSqlStatements(sql)).toEqual(['CREATE TABLE "a" ("id" uuid);']);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("a block-comment opener inside a line comment does not swallow the next statement", () => {
|
|
38
|
+
const sql = `
|
|
39
|
+
-- note: see /* details below
|
|
40
|
+
CREATE TABLE "a" ("id" uuid);
|
|
41
|
+
/* real block comment */
|
|
42
|
+
CREATE TABLE "b" ("id" uuid);
|
|
43
|
+
`;
|
|
44
|
+
expect(splitSqlStatements(sql)).toEqual([
|
|
45
|
+
'CREATE TABLE "a" ("id" uuid);',
|
|
46
|
+
'CREATE TABLE "b" ("id" uuid);',
|
|
47
|
+
]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("does not split on a semicolon inside a single-quoted string literal", () => {
|
|
51
|
+
const sql = `INSERT INTO "a" ("v") VALUES ('a;b');`;
|
|
52
|
+
expect(splitSqlStatements(sql)).toEqual([`INSERT INTO "a" ("v") VALUES ('a;b');`]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("does not treat a double-dash inside a string literal as a comment", () => {
|
|
56
|
+
const sql = `INSERT INTO "a" ("v") VALUES ('a--b');`;
|
|
57
|
+
expect(splitSqlStatements(sql)).toEqual([`INSERT INTO "a" ("v") VALUES ('a--b');`]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("handles an escaped quote inside a single-quoted string literal ('')", () => {
|
|
61
|
+
const sql = `INSERT INTO "a" ("v") VALUES ('a'';b');`;
|
|
62
|
+
expect(splitSqlStatements(sql)).toEqual([`INSERT INTO "a" ("v") VALUES ('a'';b');`]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("does not split on a semicolon inside a double-quoted identifier", () => {
|
|
66
|
+
const sql = `CREATE TABLE "weird;name" ("id" uuid);`;
|
|
67
|
+
expect(splitSqlStatements(sql)).toEqual([`CREATE TABLE "weird;name" ("id" uuid);`]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("throws fail-loud on an unterminated block comment instead of silently dropping statements", () => {
|
|
71
|
+
const sql = `/* oops\nCREATE TABLE "a" ("id" uuid);`;
|
|
72
|
+
expect(() => splitSqlStatements(sql)).toThrow();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("a trailing line comment without a newline terminates cleanly", () => {
|
|
76
|
+
expect(splitSqlStatements('CREATE TABLE "a" ("id" uuid);\n-- done')).toEqual([
|
|
77
|
+
'CREATE TABLE "a" ("id" uuid);',
|
|
78
|
+
]);
|
|
79
|
+
});
|
|
19
80
|
});
|
|
@@ -136,7 +136,35 @@ CREATE INDEX IF NOT EXISTS "read_accounts_tenant_id_idx" ON "read_accounts" ("te
|
|
|
136
136
|
}
|
|
137
137
|
});
|
|
138
138
|
|
|
139
|
-
|
|
139
|
+
// The real migrate-runner never executes a DESTRUCTIVE marker (it's a
|
|
140
|
+
// commented-out suggestion an operator uncomments deliberately) — but the
|
|
141
|
+
// snapshot already reflects the post-drop state as the intended end state,
|
|
142
|
+
// so replay must count the marker as applied or "unexpected-table"/
|
|
143
|
+
// "unexpected columns" fires forever against a table the snapshot
|
|
144
|
+
// correctly omits.
|
|
145
|
+
// #1473/2: CREATE TABLE IF NOT EXISTS is a no-op in real Postgres when
|
|
146
|
+
// the table already exists — treating it as an overwrite here silently
|
|
147
|
+
// discards whatever an in-between ALTER TABLE added, exactly the
|
|
148
|
+
// copy-paste-migration class this replay exists to catch.
|
|
149
|
+
test("CREATE TABLE IF NOT EXISTS for an already-created table is a no-op, not an overwrite", () => {
|
|
150
|
+
const dir = tmpMigrationsDir();
|
|
151
|
+
try {
|
|
152
|
+
write(dir, "0001_init.sql", `CREATE TABLE IF NOT EXISTS "read_a" ("id" uuid PRIMARY KEY);`);
|
|
153
|
+
write(dir, "0002_add-col.sql", `ALTER TABLE "read_a" ADD COLUMN "title" text;`);
|
|
154
|
+
// Accidental copy-paste of 0001 — same CREATE TABLE IF NOT EXISTS text.
|
|
155
|
+
write(
|
|
156
|
+
dir,
|
|
157
|
+
"0003_copy-paste.sql",
|
|
158
|
+
`CREATE TABLE IF NOT EXISTS "read_a" ("id" uuid PRIMARY KEY);`,
|
|
159
|
+
);
|
|
160
|
+
const replayed = replayMigrationsDir(dir);
|
|
161
|
+
expect([...(replayed.get("read_a")?.columns ?? [])].sort()).toEqual(["id", "title"]);
|
|
162
|
+
} finally {
|
|
163
|
+
rmSync(dir, { recursive: true, force: true });
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("commented-out destructive DROP TABLE is replayed as applied", () => {
|
|
140
168
|
const dir = tmpMigrationsDir();
|
|
141
169
|
try {
|
|
142
170
|
write(dir, "0001_init.sql", `CREATE TABLE IF NOT EXISTS "read_a" ("id" uuid PRIMARY KEY);`);
|
|
@@ -146,7 +174,27 @@ CREATE INDEX IF NOT EXISTS "read_accounts_tenant_id_idx" ON "read_accounts" ("te
|
|
|
146
174
|
`-- DESTRUCTIVE: DROP TABLE IF EXISTS "read_a"; -- uncomment + ensure backup`,
|
|
147
175
|
);
|
|
148
176
|
const replayed = replayMigrationsDir(dir);
|
|
149
|
-
expect([...replayed.keys()]).toEqual([
|
|
177
|
+
expect([...replayed.keys()]).toEqual([]);
|
|
178
|
+
} finally {
|
|
179
|
+
rmSync(dir, { recursive: true, force: true });
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("commented-out destructive DROP COLUMN is replayed as applied", () => {
|
|
184
|
+
const dir = tmpMigrationsDir();
|
|
185
|
+
try {
|
|
186
|
+
write(
|
|
187
|
+
dir,
|
|
188
|
+
"0001_init.sql",
|
|
189
|
+
`CREATE TABLE IF NOT EXISTS "read_a" ("id" uuid PRIMARY KEY, "legacy" text);`,
|
|
190
|
+
);
|
|
191
|
+
write(
|
|
192
|
+
dir,
|
|
193
|
+
"0002_drop-col.sql",
|
|
194
|
+
`-- DESTRUCTIVE: ALTER TABLE "read_a" DROP COLUMN "legacy"; -- uncomment + ensure backup`,
|
|
195
|
+
);
|
|
196
|
+
const replayed = replayMigrationsDir(dir);
|
|
197
|
+
expect([...(replayed.get("read_a")?.columns ?? [])]).toEqual(["id"]);
|
|
150
198
|
} finally {
|
|
151
199
|
rmSync(dir, { recursive: true, force: true });
|
|
152
200
|
}
|
|
@@ -236,6 +284,87 @@ describe("diffReplayAgainstSnapshot", () => {
|
|
|
236
284
|
}
|
|
237
285
|
});
|
|
238
286
|
|
|
287
|
+
test("kumiko-framework#1473: an unparsed ALTER TABLE RENAME statement fails loud instead of vanishing", () => {
|
|
288
|
+
const dir = tmpMigrationsDir();
|
|
289
|
+
try {
|
|
290
|
+
write(
|
|
291
|
+
dir,
|
|
292
|
+
"0001_init.sql",
|
|
293
|
+
`CREATE TABLE IF NOT EXISTS "widgets" ("id" uuid PRIMARY KEY);
|
|
294
|
+
ALTER TABLE "widgets" RENAME TO "gadgets";`,
|
|
295
|
+
);
|
|
296
|
+
expect(() => replayMigrationsDir(dir)).toThrow(/unparsed table-DDL statement/);
|
|
297
|
+
} finally {
|
|
298
|
+
rmSync(dir, { recursive: true, force: true });
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("kumiko-framework#1535: an unquoted CREATE TABLE identifier parses instead of failing loud", () => {
|
|
303
|
+
// Was "fails loud" pre-#1535 — but real committed migrations do this
|
|
304
|
+
// (publicstatus's 0014_marketing-waitlist.sql), and the old behavior
|
|
305
|
+
// turned an already-graceful missing-table report into a hard abort.
|
|
306
|
+
// Unquoted identifiers must parse like quoted ones.
|
|
307
|
+
const dir = tmpMigrationsDir();
|
|
308
|
+
try {
|
|
309
|
+
write(dir, "0001_init.sql", `CREATE TABLE widgets ("id" uuid PRIMARY KEY);`);
|
|
310
|
+
const replayed = replayMigrationsDir(dir);
|
|
311
|
+
expect([...replayed.keys()]).toEqual(["widgets"]);
|
|
312
|
+
expect([...(replayed.get("widgets")?.columns ?? [])]).toEqual(["id"]);
|
|
313
|
+
} finally {
|
|
314
|
+
rmSync(dir, { recursive: true, force: true });
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("schema-qualified CREATE TABLE (public.widgets) fails loud — IDENT rejects '.'", () => {
|
|
319
|
+
const dir = tmpMigrationsDir();
|
|
320
|
+
try {
|
|
321
|
+
write(dir, "0001_init.sql", "CREATE TABLE public.widgets (id uuid PRIMARY KEY);\n");
|
|
322
|
+
expect(() => replayMigrationsDir(dir)).toThrow(/unparsed table-DDL statement/);
|
|
323
|
+
} finally {
|
|
324
|
+
rmSync(dir, { recursive: true, force: true });
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("kumiko-framework#1535: a bare table-level PRIMARY KEY/UNIQUE clause isn't parsed as a column", () => {
|
|
329
|
+
const dir = tmpMigrationsDir();
|
|
330
|
+
try {
|
|
331
|
+
write(
|
|
332
|
+
dir,
|
|
333
|
+
"0001_init.sql",
|
|
334
|
+
`CREATE TABLE widgets (id uuid, sku text, name text, UNIQUE (sku), PRIMARY KEY (id));`,
|
|
335
|
+
);
|
|
336
|
+
const replayed = replayMigrationsDir(dir);
|
|
337
|
+
expect([...(replayed.get("widgets")?.columns ?? [])].sort()).toEqual(["id", "name", "sku"]);
|
|
338
|
+
} finally {
|
|
339
|
+
rmSync(dir, { recursive: true, force: true });
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("kumiko-framework#1535: shape-neutral ALTER COLUMN/CONSTRAINT clauses don't fail loud", () => {
|
|
344
|
+
// Regression for the false-positive class: these are all real,
|
|
345
|
+
// committed, shape-neutral DDL shapes (kumiko-studio's
|
|
346
|
+
// 0002_file_refs_soft_delete.sql, publicstatus's
|
|
347
|
+
// 0020_add-monitor-checks.sql) that the #1535 fix must recognize
|
|
348
|
+
// instead of throwing "unparsed table-DDL statement".
|
|
349
|
+
const dir = tmpMigrationsDir();
|
|
350
|
+
try {
|
|
351
|
+
write(
|
|
352
|
+
dir,
|
|
353
|
+
"0001_init.sql",
|
|
354
|
+
`CREATE TABLE IF NOT EXISTS "widgets" ("id" uuid PRIMARY KEY, "title" text);
|
|
355
|
+
ALTER TABLE "widgets" ALTER COLUMN "title" SET NOT NULL;
|
|
356
|
+
ALTER TABLE "widgets" ALTER COLUMN "title" SET DEFAULT 'untitled';
|
|
357
|
+
ALTER TABLE "widgets" ADD CONSTRAINT "widgets_title_check" CHECK (length("title") > 0);
|
|
358
|
+
ALTER TABLE "widgets" ENABLE ROW LEVEL SECURITY;
|
|
359
|
+
ALTER TABLE "widgets" OWNER TO app_user;`,
|
|
360
|
+
);
|
|
361
|
+
const replayed = replayMigrationsDir(dir);
|
|
362
|
+
expect([...(replayed.get("widgets")?.columns ?? [])].sort()).toEqual(["id", "title"]);
|
|
363
|
+
} finally {
|
|
364
|
+
rmSync(dir, { recursive: true, force: true });
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
|
|
239
368
|
test("unexpected table: migrations create a table the snapshot doesn't know about", () => {
|
|
240
369
|
const dir = tmpMigrationsDir();
|
|
241
370
|
try {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { createEntity, createTextField } from "../../engine";
|
|
3
3
|
import { testTenantId } from "../../stack";
|
|
4
|
+
import type { DbRunner } from "../connection";
|
|
4
5
|
import type { TableColumns } from "../dialect";
|
|
5
6
|
import { buildEntityTableMeta } from "../entity-table-meta";
|
|
6
7
|
import { buildEntityTable } from "../table-builder";
|
|
@@ -24,13 +25,16 @@ const foreign = testTenantId(2);
|
|
|
24
25
|
|
|
25
26
|
type Captured = { sql: string; values: readonly unknown[] };
|
|
26
27
|
|
|
27
|
-
function recordingDb(captured: Captured[]) {
|
|
28
|
+
function recordingDb(captured: Captured[]): DbRunner {
|
|
28
29
|
return {
|
|
29
30
|
unsafe: async (sql: string, values: readonly unknown[]) => {
|
|
30
31
|
captured.push({ sql, values });
|
|
31
32
|
return [] as unknown[];
|
|
32
33
|
},
|
|
33
|
-
|
|
34
|
+
begin: async () => {
|
|
35
|
+
throw new Error("begin not used in these tests");
|
|
36
|
+
},
|
|
37
|
+
} as DbRunner;
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
describe("tenant-db WHERE merge — caller cannot override tenant scope", () => {
|
package/src/db/api.ts
CHANGED
|
@@ -15,7 +15,7 @@ export type DbConnectionOptions = {
|
|
|
15
15
|
|
|
16
16
|
// Connection-Handle: db für Queries, client für Legacy-Zugriff (LISTEN/NOTIFY-Peer
|
|
17
17
|
// bei Bun.SQL), close für Pool-Shutdown.
|
|
18
|
-
export type
|
|
18
|
+
export type DbPoolHandle = {
|
|
19
19
|
/** Provider Connection — Calls gehen über asRawClient() oder direkt. */
|
|
20
20
|
// biome-ignore lint/suspicious/noExplicitAny: cross-provider connection — postgres-js | Bun.SQL
|
|
21
21
|
readonly db: any;
|
|
@@ -34,7 +34,7 @@ let _provider: undefined | (() => Promise<typeof import("./postgres-provider")>)
|
|
|
34
34
|
export async function createConnection(
|
|
35
35
|
url: string,
|
|
36
36
|
options: DbConnectionOptions = {},
|
|
37
|
-
): Promise<
|
|
37
|
+
): Promise<DbPoolHandle> {
|
|
38
38
|
const p = process.env["DB_PROVIDER"];
|
|
39
39
|
if (p === "bun" || p === "bun-sql") {
|
|
40
40
|
const { createBunConnection } = await import("./bun-provider");
|
package/src/db/bun-provider.ts
CHANGED
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
// Bun.SQL hat kein LISTEN — postgres-js-Peer für event-dispatcher.
|
|
9
9
|
|
|
10
10
|
import postgres from "postgres";
|
|
11
|
-
import type {
|
|
11
|
+
import type { DbConnectionOptions, DbPoolHandle } from "./api";
|
|
12
12
|
|
|
13
|
-
export function createBunConnection(url: string, options: DbConnectionOptions = {}):
|
|
13
|
+
export function createBunConnection(url: string, options: DbConnectionOptions = {}): DbPoolHandle {
|
|
14
14
|
const bunOpts: { max?: number; idleTimeout?: number; connectionTimeout?: number } = {};
|
|
15
15
|
if (options.maxConnections !== undefined) bunOpts.max = options.maxConnections;
|
|
16
16
|
if (options.idleTimeoutSeconds !== undefined) bunOpts.idleTimeout = options.idleTimeoutSeconds;
|
package/src/db/connection.ts
CHANGED
|
@@ -4,9 +4,12 @@ import type { DbConnection, PgClient } from "@cosmicdrift/kumiko-types/db-connec
|
|
|
4
4
|
import postgres from "postgres";
|
|
5
5
|
import { readPositiveIntEnv } from "../utils/env-parse";
|
|
6
6
|
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
// Raw client types (postgres-js | Bun.SQL) — the name used across query/
|
|
8
|
+
// event-store/pipeline call sites. The structural pool handle from ./api is
|
|
9
|
+
// `DbPoolHandle` (createConnection's return type) to avoid colliding with this.
|
|
10
|
+
export type * from "@cosmicdrift/kumiko-types/db-connection";
|
|
11
|
+
export type { DbConnectionOptions, DbPoolHandle } from "./api";
|
|
12
|
+
export { createConnection } from "./api";
|
|
10
13
|
|
|
11
14
|
// Legacy: postgres-js only. Neue Aufrufer: createConnection() aus api.ts.
|
|
12
15
|
// guard:dup-ok — andere Layer als createPgConnection (gibt DbConnection zurück, nicht postgres-Instanz)
|
package/src/db/dialect.ts
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import {
|
|
20
20
|
type ColumnHandle,
|
|
21
21
|
KUMIKO_COLUMNS_SYMBOL,
|
|
22
|
+
KUMIKO_META_SYMBOL,
|
|
22
23
|
KUMIKO_NAME_SYMBOL,
|
|
23
24
|
type SchemaTable,
|
|
24
25
|
} from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
@@ -41,12 +42,6 @@ export type TableColumns<_T = any> = SchemaTable;
|
|
|
41
42
|
// biome-ignore lint/suspicious/noExplicitAny: legacy type — chain API is gone
|
|
42
43
|
export type SelectQuery = any;
|
|
43
44
|
|
|
44
|
-
// Shadow-proof handle on the EntityTableMeta. The column handles below are
|
|
45
|
-
// spread as enumerable props, so an entity field named `source`/`columns`/
|
|
46
|
-
// `tableName`/… would overwrite the matching meta key. extractTableInfo reads
|
|
47
|
-
// the canonical meta from this symbol instead of the (shadowable) props.
|
|
48
|
-
const KUMIKO_META_SYMBOL = Symbol.for("kumiko:schema:Meta");
|
|
49
|
-
|
|
50
45
|
function isNumericPgType(t: PgType): t is `numeric(${number},${number})` {
|
|
51
46
|
return t.startsWith("numeric(");
|
|
52
47
|
}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Legacy path — re-exported for callers still importing this module directly.
|
|
2
|
-
export * from "@cosmicdrift/kumiko-types/entity-table-meta-types";
|
|
2
|
+
export type * from "@cosmicdrift/kumiko-types/entity-table-meta-types";
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { KUMIKO_NAME_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
1
2
|
import { requestContext } from "../api/request-context";
|
|
2
3
|
import {
|
|
3
4
|
collectPiiSubjectFields,
|
|
@@ -359,9 +360,7 @@ export function buildExecutorContext(
|
|
|
359
360
|
}
|
|
360
361
|
// ownership has raw SQL — splice it into a raw query alongside the
|
|
361
362
|
// idFilter + tenant-filter that TenantDb would have added.
|
|
362
|
-
const tableName = String(
|
|
363
|
-
(table as unknown as Record<symbol, unknown>)[Symbol.for("kumiko:schema:Name")],
|
|
364
|
-
);
|
|
363
|
+
const tableName = String((table as unknown as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL]);
|
|
365
364
|
const colSql = (field: string): string =>
|
|
366
365
|
`"${(table[field] as { name?: string } | undefined)?.name ?? toSnakeCase(field)}"`;
|
|
367
366
|
const whereParts: string[] = [];
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { KUMIKO_NAME_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
1
2
|
import { computeBlindIndex, configuredBlindIndexKey } from "../crypto";
|
|
2
3
|
import { executeRawQuery } from "../db/queries/raw-sql";
|
|
3
4
|
import { coerceRow, extractTableInfo } from "../db/query";
|
|
@@ -66,9 +67,7 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
|
|
|
66
67
|
// Build the WHERE clause as raw SQL — ownership produces a
|
|
67
68
|
// parameterised fragment that we splice in alongside simple WhereObject
|
|
68
69
|
// conditions (cursor, search-filter-IDs, screen-filter, tenant-scope).
|
|
69
|
-
const tableName = String(
|
|
70
|
-
(table as unknown as Record<symbol, unknown>)[Symbol.for("kumiko:schema:Name")],
|
|
71
|
-
);
|
|
70
|
+
const tableName = String((table as unknown as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL]);
|
|
72
71
|
const whereSql: string[] = [];
|
|
73
72
|
const params: unknown[] = [];
|
|
74
73
|
const colSql = (field: string): string =>
|
|
@@ -3,6 +3,7 @@ import { userCanCreateFieldRow, userCanWriteFieldRow } from "../engine/ownership
|
|
|
3
3
|
import type { EntityId } from "../engine/types";
|
|
4
4
|
import {
|
|
5
5
|
VersionConflictError as FrameworkVersionConflict,
|
|
6
|
+
IdempotentReplayError,
|
|
6
7
|
InternalError,
|
|
7
8
|
NotFoundError,
|
|
8
9
|
UnprocessableError,
|
|
@@ -10,6 +11,7 @@ import {
|
|
|
10
11
|
} from "../errors";
|
|
11
12
|
import {
|
|
12
13
|
append,
|
|
14
|
+
IdempotentAppendConflictError as EventStoreIdempotentAppendConflict,
|
|
13
15
|
VersionConflictError as EventStoreVersionConflict,
|
|
14
16
|
getStreamVersion,
|
|
15
17
|
} from "../event-store";
|
|
@@ -160,6 +162,9 @@ export function createWriteVerbs(
|
|
|
160
162
|
}),
|
|
161
163
|
);
|
|
162
164
|
}
|
|
165
|
+
if (e instanceof EventStoreIdempotentAppendConflict) {
|
|
166
|
+
return writeFailure(new IdempotentReplayError({ idempotencyKey: e.idempotencyKey }));
|
|
167
|
+
}
|
|
163
168
|
throw e;
|
|
164
169
|
}
|
|
165
170
|
|
|
@@ -390,6 +395,9 @@ export function createWriteVerbs(
|
|
|
390
395
|
}),
|
|
391
396
|
);
|
|
392
397
|
}
|
|
398
|
+
if (e instanceof EventStoreIdempotentAppendConflict) {
|
|
399
|
+
return writeFailure(new IdempotentReplayError({ idempotencyKey: e.idempotencyKey }));
|
|
400
|
+
}
|
|
393
401
|
throw e;
|
|
394
402
|
}
|
|
395
403
|
},
|
package/src/db/index.ts
CHANGED
|
@@ -3,7 +3,14 @@ export { nullBlindIndexesForSubject } from "./blind-index-cleanup";
|
|
|
3
3
|
export { collectTableMetas } from "./collect-table-metas";
|
|
4
4
|
export { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types";
|
|
5
5
|
export { seedConfigValues } from "./config-seed";
|
|
6
|
-
export type {
|
|
6
|
+
export type {
|
|
7
|
+
DbConnection,
|
|
8
|
+
DbConnectionOptions,
|
|
9
|
+
DbPoolHandle,
|
|
10
|
+
DbRow,
|
|
11
|
+
DbRunner,
|
|
12
|
+
DbTx,
|
|
13
|
+
} from "./connection";
|
|
7
14
|
export { createDbConnection, dbConnectionOptionsFromEnv } from "./connection";
|
|
8
15
|
export type { CursorQueryOptions, CursorResult } from "./cursor";
|
|
9
16
|
export { decodeCursor, encodeCursor } from "./cursor";
|
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
// gespeicherter tz). Server kennt User-TZ nicht — User-spezifische
|
|
10
10
|
// Anzeige passiert client-seitig aus utc.
|
|
11
11
|
|
|
12
|
+
// Static import, not the ambient global: Bun doesn't expose Temporal on
|
|
13
|
+
// globalThis, so this crashed with "Temporal is not defined" outside boot
|
|
14
|
+
// paths that install it (#1480).
|
|
15
|
+
import { Temporal } from "temporal-polyfill";
|
|
12
16
|
import type { EntityDefinition } from "../engine/types";
|
|
13
17
|
|
|
14
18
|
// Sprint F: <name>Utc-Spalte ist jetzt instant() (siehe dialect.ts) —
|