@cosmicdrift/kumiko-framework 0.165.0 → 0.165.2
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/README.md +1 -1
- 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 +150 -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 +178 -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 +26 -2
- package/src/api/sse-broker.ts +38 -0
- package/src/bun-db/index.ts +1 -0
- package/src/bun-db/query.ts +14 -5
- package/src/consumer-cli.ts +60 -13
- package/src/db/__tests__/decimal-field.test.ts +3 -3
- package/src/db/__tests__/entity-table-meta-source.test.ts +43 -8
- 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 +75 -0
- package/src/db/__tests__/replay-migration-sql.test.ts +131 -2
- package/src/db/__tests__/table-builder-meta-lockstep.test.ts +6 -6
- package/src/db/__tests__/tenant-db-where-merge.test.ts +10 -6
- package/src/db/api.ts +2 -2
- package/src/db/bun-provider.ts +2 -2
- package/src/db/collect-table-metas.ts +3 -3
- 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/entity-table-meta.ts +49 -32
- 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 +13 -2
- package/src/db/located-timestamp.ts +4 -0
- package/src/db/migrate-runner.ts +102 -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/table-builder.ts +2 -2
- package/src/db/tenant-db.ts +3 -5
- 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 +10 -13
- package/src/engine/boot-validator/nav.ts +5 -0
- package/src/engine/constants.ts +28 -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-ast/extractors/round5.ts +1 -1
- package/src/engine/feature-ui-extensions.ts +3 -3
- 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 +6 -3
- 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 +6 -6
- 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/migrations/__tests__/kumiko-drift.integration.test.ts +2 -2
- package/src/migrations/projection-table-index.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 +212 -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 +50 -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/__tests__/polyfill-reinstall.test.ts +17 -0
- package/src/time/geo-tz.ts +1 -1
- package/src/time/polyfill.ts +28 -39
- package/src/time/tz-context.ts +30 -24
- package/src/utils/__tests__/safe-json-temporal.test.ts +14 -0
- package/src/utils/safe-json.ts +3 -2
- package/src/engine/__tests__/registry-facade-sweep.test.ts +0 -80
|
@@ -2,12 +2,12 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { createEntity } from "../../engine/factories";
|
|
3
3
|
import { sql } from "../dialect";
|
|
4
4
|
import type { ColumnMeta, IndexMeta } from "../entity-table-meta";
|
|
5
|
-
import {
|
|
5
|
+
import { deriveEntityTableMeta } from "../entity-table-meta";
|
|
6
6
|
import { asEntityTableMeta } from "../query";
|
|
7
7
|
import { buildEntityTable } from "../table-builder";
|
|
8
8
|
|
|
9
9
|
// Lock-step-Guard: buildEntityTable (Runtime-/Test-Stack-Pfad, Meta am
|
|
10
|
-
// KUMIKO_META_SYMBOL) und
|
|
10
|
+
// KUMIKO_META_SYMBOL) und deriveEntityTableMeta (Migrations-Pfad) müssen
|
|
11
11
|
// für dieselbe EntityDefinition identische Spalten + Indexes produzieren.
|
|
12
12
|
// Drift hier = Migration und Prod-Tabelle (bzw. collectTableMetas-Output)
|
|
13
13
|
// gehen auseinander — gefunden als #255-Follow-up: select/number/bigInt
|
|
@@ -33,9 +33,9 @@ function byName<T extends { name: string }>(items: readonly T[]): readonly T[] {
|
|
|
33
33
|
return [...items].sort((a, b) => a.name.localeCompare(b.name));
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
describe("buildEntityTable ↔
|
|
36
|
+
describe("buildEntityTable ↔ deriveEntityTableMeta lock-step", () => {
|
|
37
37
|
const fromBuilder = asEntityTableMeta(buildEntityTable("lockstepProbe", entityWithDefaults));
|
|
38
|
-
const fromMeta =
|
|
38
|
+
const fromMeta = deriveEntityTableMeta("lockstepProbe", entityWithDefaults);
|
|
39
39
|
|
|
40
40
|
test("builder table carries an EntityTableMeta", () => {
|
|
41
41
|
expect(fromBuilder).toBeDefined();
|
|
@@ -92,7 +92,7 @@ describe("lock-step — softDelete + explizite Indexes", () => {
|
|
|
92
92
|
const fromBuilder = asEntityTableMeta(
|
|
93
93
|
buildEntityTable("lockstepProbeSd", entityWithSoftDeleteAndIndexes),
|
|
94
94
|
);
|
|
95
|
-
const fromMeta =
|
|
95
|
+
const fromMeta = deriveEntityTableMeta("lockstepProbeSd", entityWithSoftDeleteAndIndexes);
|
|
96
96
|
|
|
97
97
|
test("identical columns inkl. softDelete-Spalten", () => {
|
|
98
98
|
expect(byName<ColumnMeta>(fromBuilder?.columns ?? [])).toEqual(
|
|
@@ -125,7 +125,7 @@ describe("lock-step — lookupable / blind-index (#818)", () => {
|
|
|
125
125
|
const fromBuilder = asEntityTableMeta(
|
|
126
126
|
buildEntityTable("lockstepProbeBidx", entityWithLookupable),
|
|
127
127
|
);
|
|
128
|
-
const fromMeta =
|
|
128
|
+
const fromMeta = deriveEntityTableMeta("lockstepProbeBidx", entityWithLookupable);
|
|
129
129
|
|
|
130
130
|
test("identical columns inkl. nullable bidx-Spalte", () => {
|
|
131
131
|
expect(byName<ColumnMeta>(fromBuilder?.columns ?? [])).toEqual(
|
|
@@ -1,8 +1,9 @@
|
|
|
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
|
-
import {
|
|
6
|
+
import { deriveEntityTableMeta } from "../entity-table-meta";
|
|
6
7
|
import { buildEntityTable } from "../table-builder";
|
|
7
8
|
import { createTenantDb } from "../tenant-db";
|
|
8
9
|
|
|
@@ -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", () => {
|
|
@@ -119,16 +123,16 @@ describe("tenant-db WHERE merge — narrowing within the enforced scope", () =>
|
|
|
119
123
|
});
|
|
120
124
|
|
|
121
125
|
// Root-cause regression for the cross-tenant leak fixed in hasTenantColumn:
|
|
122
|
-
// unmanaged direct-write stores (
|
|
126
|
+
// unmanaged direct-write stores (deriveEntityTableMeta, e.g. userSessionTable,
|
|
123
127
|
// apiTokenTable) store tenantId as a snake_case column-meta entry, not a
|
|
124
128
|
// direct `table.tenantId` property — a naive property check silently treated
|
|
125
129
|
// them as tenant-less and skipped the WHERE-scope entirely.
|
|
126
|
-
describe("tenant-db WHERE merge — unmanaged EntityTableMeta tables (
|
|
130
|
+
describe("tenant-db WHERE merge — unmanaged EntityTableMeta tables (deriveEntityTableMeta)", () => {
|
|
127
131
|
const unmanagedEntity = createEntity({
|
|
128
132
|
table: "merge_meta_items",
|
|
129
133
|
fields: { tenantId: createTextField({ required: true }), name: createTextField() },
|
|
130
134
|
});
|
|
131
|
-
const unmanagedTable =
|
|
135
|
+
const unmanagedTable = deriveEntityTableMeta("merge-meta-item", unmanagedEntity);
|
|
132
136
|
|
|
133
137
|
test("selectMany still applies the tenant scope (pre-fix: no WHERE at all)", async () => {
|
|
134
138
|
const captured: Captured[] = [];
|
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;
|
|
@@ -9,7 +9,7 @@ import type { FeatureDefinition } from "../engine/types";
|
|
|
9
9
|
import { compareByCodepoint } from "../utils";
|
|
10
10
|
import {
|
|
11
11
|
assertBackingTableSuperset,
|
|
12
|
-
|
|
12
|
+
deriveEntityTableMeta,
|
|
13
13
|
type EntityTableMeta,
|
|
14
14
|
} from "./entity-table-meta";
|
|
15
15
|
import { enumerateFeatureTableSources } from "./feature-table-sources";
|
|
@@ -36,10 +36,10 @@ export function collectTableMetas(
|
|
|
36
36
|
const byName = new Map<string, { meta: EntityTableMeta; origin: string }>();
|
|
37
37
|
|
|
38
38
|
// Pass 1: kanonische Schema-Quellen, identisch zum bisherigen Template-
|
|
39
|
-
// Verhalten (gleiche Reihenfolge, gleiche
|
|
39
|
+
// Verhalten (gleiche Reihenfolge, gleiche deriveEntityTableMeta-Optionen).
|
|
40
40
|
for (const feature of features) {
|
|
41
41
|
for (const [name, ent] of Object.entries(feature.entities ?? {})) {
|
|
42
|
-
const fieldMeta =
|
|
42
|
+
const fieldMeta = deriveEntityTableMeta(name, ent, { relations: feature.relations[name] });
|
|
43
43
|
// Backing table wins: it's the physical DDL truth for ride-along columns/
|
|
44
44
|
// indexes the field-DSL can't express (secrets' envelope). Validated as a
|
|
45
45
|
// superset of the field-derived meta so a field/table disagreement throws.
|
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,22 +1,20 @@
|
|
|
1
|
-
// EntityTableMeta — plain-data
|
|
2
|
-
// Single source of truth
|
|
1
|
+
// EntityTableMeta — plain-data schema meta for a read-model table.
|
|
2
|
+
// Single source of truth instead of a married drizzle pgTable builder.
|
|
3
3
|
//
|
|
4
|
-
// Phase 3a (Drizzle-Replacement Plan):
|
|
5
|
-
//
|
|
6
|
-
// `entityTableMetaToEntityTable`)
|
|
7
|
-
// umstellt.
|
|
4
|
+
// Phase 3a (Drizzle-Replacement Plan): type + generator exist in parallel
|
|
5
|
+
// with buildEntityTable. Consumers stay on EntityTable (via adapter
|
|
6
|
+
// `entityTableMetaToEntityTable`) until Phase 4 moves the query API to Bun.sql.
|
|
8
7
|
//
|
|
9
|
-
//
|
|
10
|
-
// 1. **Managed** — EntityDefinition via
|
|
11
|
-
// Standard
|
|
12
|
-
// modified_at, inserted_by_id, modified_by_id,
|
|
13
|
-
//
|
|
14
|
-
// 2. **Unmanaged** — defineUnmanagedTable(input)
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
// append-only-logs mit serial PK, aggregate-ID ohne DEFAULT, …).
|
|
8
|
+
// Two sources:
|
|
9
|
+
// 1. **Managed** — EntityDefinition via deriveEntityTableMeta(name, entity).
|
|
10
|
+
// Standard path with base columns (id, tenant_id, version, inserted_at,
|
|
11
|
+
// modified_at, inserted_by_id, modified_by_id, optional softDelete cols),
|
|
12
|
+
// automatic tenant_id index, audit-capable. Defaults to source: "managed".
|
|
13
|
+
// 2. **Unmanaged** — defineUnmanagedTable(input), or
|
|
14
|
+
// deriveEntityTableMeta(..., { source: "unmanaged" }) when you still want
|
|
15
|
+
// entity-shaped base columns. Escape hatch: no forced audit trail for the
|
|
16
|
+
// hand-built path; app author owns tenant scoping / versioning. Prefer a
|
|
17
|
+
// `store_` table name — `read_` is reserved for managed projections (#1208/#1220).
|
|
20
18
|
|
|
21
19
|
import { collectPiiSubjectFields } from "../crypto";
|
|
22
20
|
import type { EntityDefinition, EntityIndexDef, FieldDefinition } from "../engine/types";
|
|
@@ -232,12 +230,25 @@ export function resolveTableName(
|
|
|
232
230
|
return `${featureName}_${baseName}`;
|
|
233
231
|
}
|
|
234
232
|
|
|
235
|
-
|
|
233
|
+
/**
|
|
234
|
+
* Derive EntityTableMeta from an EntityDefinition (base columns + field DDL).
|
|
235
|
+
* Defaults to `source: "managed"` (rebuildable projection). For direct-write
|
|
236
|
+
* stores pass `{ source: "unmanaged" }` and use a non-`read_` table name
|
|
237
|
+
* (convention: `store_*`) — or build columns by hand with `defineUnmanagedTable`.
|
|
238
|
+
*
|
|
239
|
+
* Named `derive*` (not `build*Meta`) so it is not mistaken for the unmanaged
|
|
240
|
+
* escape hatch (#1208).
|
|
241
|
+
*/
|
|
242
|
+
export function deriveEntityTableMeta(
|
|
236
243
|
entityName: string,
|
|
237
244
|
entity: EntityDefinition,
|
|
238
245
|
options?: BuildEntityTableMetaOptions,
|
|
239
246
|
): EntityTableMeta {
|
|
240
247
|
const tableName = resolveTableName(entityName, entity, options?.featureName);
|
|
248
|
+
const source = options?.source ?? "managed";
|
|
249
|
+
if (source === "unmanaged") {
|
|
250
|
+
assertUnmanagedTableName(tableName, "deriveEntityTableMeta");
|
|
251
|
+
}
|
|
241
252
|
const idType = entity.idType ?? "uuid";
|
|
242
253
|
|
|
243
254
|
// Base-columns first, then user-fields. User-fields with the same
|
|
@@ -349,24 +360,14 @@ export function buildEntityTableMeta(
|
|
|
349
360
|
tableName,
|
|
350
361
|
columns,
|
|
351
362
|
indexes,
|
|
352
|
-
source
|
|
363
|
+
source,
|
|
353
364
|
...(piiSubjectFields.length > 0 && { piiSubjectFields }),
|
|
354
365
|
};
|
|
355
366
|
}
|
|
356
367
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
//
|
|
361
|
-
// **Vorsicht-vor-Use:** wenn du das hier benutzt, gibst du das Standard-
|
|
362
|
-
// Audit-Pattern auf. Begründe im Code WARUM (child-projection ohne tenant-
|
|
363
|
-
// scope, aggregate-id-PK ohne DEFAULT, append-only-log mit serial PK,
|
|
364
|
-
// performance-critical hot-path ohne version-check, …). Reviewer sollten
|
|
365
|
-
// jede neue defineUnmanagedTable-Stelle prüfen.
|
|
366
|
-
//
|
|
367
|
-
// Heutige use-cases im framework:
|
|
368
|
-
// - `store_delivery_attempts` — id kommt aus dem Aggregate-Stream
|
|
369
|
-
// - `store_job_run_logs` — child-table, serial PK, kein tenant-scope
|
|
368
|
+
/** @deprecated Use {@link deriveEntityTableMeta} — the old name read as an unmanaged escape hatch (#1208). */
|
|
369
|
+
export const buildEntityTableMeta = deriveEntityTableMeta;
|
|
370
|
+
|
|
370
371
|
function sqlExpressionText(where: unknown): string | undefined {
|
|
371
372
|
if (
|
|
372
373
|
typeof where === "object" &&
|
|
@@ -420,7 +421,12 @@ function columnsByNameMeta(meta: EntityTableMeta): Map<string, ColumnMeta> {
|
|
|
420
421
|
return m;
|
|
421
422
|
}
|
|
422
423
|
|
|
424
|
+
/**
|
|
425
|
+
* Hand-built EntityTableMeta for direct-write stores (no entity base columns).
|
|
426
|
+
* Prefer a `store_*` table name; `read_` is reserved for managed projections (#1220).
|
|
427
|
+
*/
|
|
423
428
|
export function defineUnmanagedTable(input: UnmanagedTableInput): EntityTableMeta {
|
|
429
|
+
assertUnmanagedTableName(input.tableName, "defineUnmanagedTable");
|
|
424
430
|
return {
|
|
425
431
|
tableName: input.tableName,
|
|
426
432
|
columns: input.columns,
|
|
@@ -431,3 +437,14 @@ export function defineUnmanagedTable(input: UnmanagedTableInput): EntityTableMet
|
|
|
431
437
|
source: "unmanaged",
|
|
432
438
|
};
|
|
433
439
|
}
|
|
440
|
+
|
|
441
|
+
function assertUnmanagedTableName(tableName: string, via: string): void {
|
|
442
|
+
if (tableName.startsWith("read_")) {
|
|
443
|
+
throw new Error(
|
|
444
|
+
`${via}("${tableName}"): the "read_" prefix is reserved for managed ` +
|
|
445
|
+
`r.entity()/r.projection() tables. Unmanaged direct-write stores need a ` +
|
|
446
|
+
`distinct name (convention: "store_${tableName.slice("read_".length)}"). ` +
|
|
447
|
+
`See #1208/#1220.`,
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
@@ -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";
|
|
@@ -52,7 +59,11 @@ export type {
|
|
|
52
59
|
PgType,
|
|
53
60
|
UnmanagedTableInput,
|
|
54
61
|
} from "./entity-table-meta";
|
|
55
|
-
export {
|
|
62
|
+
export {
|
|
63
|
+
buildEntityTableMeta,
|
|
64
|
+
defineUnmanagedTable,
|
|
65
|
+
deriveEntityTableMeta,
|
|
66
|
+
} from "./entity-table-meta";
|
|
56
67
|
export type {
|
|
57
68
|
EntityLifecycleVerb,
|
|
58
69
|
EventStoreExecutor,
|
|
@@ -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) —
|
package/src/db/migrate-runner.ts
CHANGED
|
@@ -74,14 +74,98 @@ CREATE TABLE IF NOT EXISTS "_kumiko_migrations" (
|
|
|
74
74
|
)
|
|
75
75
|
`.trim();
|
|
76
76
|
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
// das wissen — sonst pg-Parser einziehen.
|
|
77
|
+
// Plain `;`-split breaks on `;` inside comments/string literals (#1542).
|
|
78
|
+
type SqlScanState = "normal" | "lineComment" | "blockComment" | "singleQuote" | "doubleQuote";
|
|
79
|
+
|
|
81
80
|
export function splitSqlStatements(sqlText: string): readonly string[] {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
81
|
+
const statements: string[] = [];
|
|
82
|
+
let current = "";
|
|
83
|
+
let state: SqlScanState = "normal";
|
|
84
|
+
|
|
85
|
+
for (let i = 0; i < sqlText.length; i++) {
|
|
86
|
+
const ch = sqlText.charAt(i);
|
|
87
|
+
const next = sqlText.charAt(i + 1);
|
|
88
|
+
|
|
89
|
+
if (state === "lineComment") {
|
|
90
|
+
if (ch === "\n") {
|
|
91
|
+
state = "normal";
|
|
92
|
+
current += ch;
|
|
93
|
+
}
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (state === "blockComment") {
|
|
97
|
+
if (ch === "*" && next === "/") {
|
|
98
|
+
state = "normal";
|
|
99
|
+
i++;
|
|
100
|
+
// Keep a space so `a/*x*/AS` does not become `aAS`.
|
|
101
|
+
current += " ";
|
|
102
|
+
}
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (state === "singleQuote") {
|
|
106
|
+
current += ch;
|
|
107
|
+
if (ch === "'") {
|
|
108
|
+
if (next === "'") {
|
|
109
|
+
current += next;
|
|
110
|
+
i++;
|
|
111
|
+
} else {
|
|
112
|
+
state = "normal";
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (state === "doubleQuote") {
|
|
118
|
+
current += ch;
|
|
119
|
+
if (ch === '"') {
|
|
120
|
+
if (next === '"') {
|
|
121
|
+
current += next;
|
|
122
|
+
i++;
|
|
123
|
+
} else {
|
|
124
|
+
state = "normal";
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// state === "normal"
|
|
131
|
+
if (ch === "-" && next === "-") {
|
|
132
|
+
state = "lineComment";
|
|
133
|
+
i++;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (ch === "/" && next === "*") {
|
|
137
|
+
state = "blockComment";
|
|
138
|
+
i++;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (ch === "'") {
|
|
142
|
+
state = "singleQuote";
|
|
143
|
+
current += ch;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (ch === '"') {
|
|
147
|
+
state = "doubleQuote";
|
|
148
|
+
current += ch;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (ch === ";") {
|
|
152
|
+
statements.push(current);
|
|
153
|
+
current = "";
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
current += ch;
|
|
157
|
+
}
|
|
158
|
+
if (state === "blockComment" || state === "singleQuote" || state === "doubleQuote") {
|
|
159
|
+
// Does not track dollar-quoted (`$$...$$`) bodies — none of this repo's
|
|
160
|
+
// migrations use them; add that state if one ever does.
|
|
161
|
+
throw new Error(
|
|
162
|
+
`splitSqlStatements: unterminated ${state} — migration SQL is malformed, refusing to split`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
statements.push(current);
|
|
166
|
+
|
|
167
|
+
return statements
|
|
168
|
+
.map((s) => s.trim())
|
|
85
169
|
.filter((s) => s.length > 0)
|
|
86
170
|
.map((s) => `${s};`);
|
|
87
171
|
}
|
|
@@ -90,14 +174,21 @@ function sha256Hex(content: string): string {
|
|
|
90
174
|
return createHash("sha256").update(content).digest("hex");
|
|
91
175
|
}
|
|
92
176
|
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
|
|
177
|
+
// Reads <dir>/*.sql, sorted lexically (e.g. 0001_init.sql, 0002_add_locale.sql),
|
|
178
|
+
// returns Migration[] with id + checksum + statements. `preprocess` is an
|
|
179
|
+
// optional hook applied to each file's raw content before splitting/hashing
|
|
180
|
+
// — used by replayMigrationsDir to expand its DESTRUCTIVE-marker comments
|
|
181
|
+
// without a second, drifting copy of this file-discovery logic (#1522/9).
|
|
182
|
+
export function loadMigrationsFromDir(
|
|
183
|
+
dir: string,
|
|
184
|
+
preprocess?: (sql: string) => string,
|
|
185
|
+
): readonly Migration[] {
|
|
96
186
|
const files = readdirSync(dir)
|
|
97
187
|
.filter((f) => f.endsWith(".sql"))
|
|
98
188
|
.sort();
|
|
99
189
|
return files.map((file) => {
|
|
100
|
-
const
|
|
190
|
+
const raw = readFileSync(join(dir, file), "utf8");
|
|
191
|
+
const content = preprocess ? preprocess(raw) : raw;
|
|
101
192
|
return {
|
|
102
193
|
id: file.replace(/\.sql$/, ""),
|
|
103
194
|
checksum: sha256Hex(content),
|
package/src/db/pg-error.ts
CHANGED
|
@@ -41,6 +41,14 @@ export function isTableAlreadyExists(e: unknown): boolean {
|
|
|
41
41
|
return extractPgError(e)?.code === "42P07";
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
// PG SQLSTATE 55P03 — "lock not available". Raised by a NOWAIT-style lock
|
|
45
|
+
// request that couldn't be granted; a CREATE/DROP INDEX CONCURRENTLY racing
|
|
46
|
+
// another session's DDL on the same relation can hit this instead of a
|
|
47
|
+
// duplicate-relation error, depending on exact timing.
|
|
48
|
+
export function isLockNotAvailable(e: unknown): boolean {
|
|
49
|
+
return extractPgError(e)?.code === "55P03";
|
|
50
|
+
}
|
|
51
|
+
|
|
44
52
|
export function constraintOf(e: unknown): string | undefined {
|
|
45
53
|
return extractPgError(e)?.constraint_name;
|
|
46
54
|
}
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
// `asRawClient(db)` wrappt .unsafe und .begin transparent.
|
|
4
4
|
|
|
5
5
|
import postgres from "postgres";
|
|
6
|
-
import type {
|
|
6
|
+
import type { DbConnectionOptions, DbPoolHandle } from "./api";
|
|
7
7
|
|
|
8
|
-
export function createPgConnection(url: string, options: DbConnectionOptions = {}):
|
|
8
|
+
export function createPgConnection(url: string, options: DbConnectionOptions = {}): DbPoolHandle {
|
|
9
9
|
const pgOptions: Parameters<typeof postgres>[1] = {};
|
|
10
10
|
if (options.maxConnections !== undefined) pgOptions.max = options.maxConnections;
|
|
11
11
|
if (options.idleTimeoutSeconds !== undefined) pgOptions.idle_timeout = options.idleTimeoutSeconds;
|