@cosmicdrift/kumiko-framework 0.1.0 → 0.2.1
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/LICENSE +57 -0
- package/package.json +3 -3
- package/src/__tests__/anonymous-access.integration.ts +7 -7
- package/src/__tests__/error-contract.integration.ts +2 -2
- package/src/__tests__/field-access.integration.ts +2 -2
- package/src/__tests__/full-stack.integration.ts +2 -2
- package/src/__tests__/ownership.integration.ts +2 -2
- package/src/__tests__/raw-table.integration.ts +128 -0
- package/src/__tests__/reference-data.integration.ts +2 -2
- package/src/__tests__/transition-guard.integration.ts +4 -4
- package/src/api/__tests__/batch.integration.ts +3 -3
- package/src/api/__tests__/dispatcher-live.integration.ts +2 -2
- package/src/api/__tests__/nested-write.integration.ts +3 -3
- package/src/db/__tests__/drizzle-helpers.integration.ts +2 -2
- package/src/db/__tests__/event-store-executor-list.integration.ts +2 -2
- package/src/db/__tests__/event-store-executor.integration.ts +9 -3
- package/src/db/__tests__/implicit-projection-equivalence.integration.ts +3 -3
- package/src/db/__tests__/multi-row-insert.integration.ts +3 -3
- package/src/db/__tests__/schema-migration.integration.ts +9 -9
- package/src/db/__tests__/tenant-db.integration.ts +4 -4
- package/src/db/__tests__/unique-violation-mapping.integration.ts +2 -2
- package/src/db/schema-inspection.ts +1 -1
- package/src/engine/__tests__/raw-table.test.ts +149 -0
- package/src/engine/define-feature.ts +38 -0
- package/src/engine/index.ts +6 -0
- package/src/engine/registry.ts +46 -0
- package/src/engine/tier-resolver-extension.ts +78 -0
- package/src/engine/types/feature.ts +55 -0
- package/src/engine/types/handlers.ts +13 -5
- package/src/engine/types/index.ts +3 -0
- package/src/event-store/__tests__/upcaster.integration.ts +11 -5
- package/src/event-store/archive.ts +2 -2
- package/src/event-store/events-schema.ts +2 -2
- package/src/event-store/snapshot.ts +2 -2
- package/src/event-store/upcaster-dead-letter.ts +2 -2
- package/src/files/__tests__/file-field-column.integration.ts +4 -4
- package/src/files/__tests__/file-field-pipeline.integration.ts +2 -2
- package/src/files/__tests__/files.integration.ts +8 -8
- package/src/observability/__tests__/observability.integration.ts +2 -2
- package/src/pipeline/__tests__/archive-stream.integration.ts +2 -2
- package/src/pipeline/__tests__/cascade-handler.integration.ts +9 -9
- package/src/pipeline/__tests__/causation-chain.integration.ts +2 -2
- package/src/pipeline/__tests__/ctx-bridge.integration.ts +3 -3
- package/src/pipeline/__tests__/dispatcher.test.ts +73 -0
- package/src/pipeline/__tests__/domain-events-projections.integration.ts +2 -2
- package/src/pipeline/__tests__/event-dedup.integration.ts +2 -2
- package/src/pipeline/__tests__/event-define-event-strict.integration.ts +2 -2
- package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.ts +3 -3
- package/src/pipeline/__tests__/event-dispatcher-multi-instance.integration.ts +2 -2
- package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.ts +2 -2
- package/src/pipeline/__tests__/event-dispatcher-recovery.integration.ts +2 -2
- package/src/pipeline/__tests__/event-dispatcher-second-audit.integration.ts +4 -4
- package/src/pipeline/__tests__/event-dispatcher.integration.ts +4 -4
- package/src/pipeline/__tests__/event-retention.integration.ts +2 -2
- package/src/pipeline/__tests__/fetch-for-writing.integration.ts +2 -2
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +100 -0
- package/src/pipeline/__tests__/load-aggregate-query.integration.ts +2 -2
- package/src/pipeline/__tests__/msp-error-mode.integration.ts +2 -2
- package/src/pipeline/__tests__/msp-multi-hop.integration.ts +2 -2
- package/src/pipeline/__tests__/msp-rebuild.integration.ts +3 -3
- package/src/pipeline/__tests__/multi-stream-projection.integration.ts +3 -3
- package/src/pipeline/__tests__/perf-rebuild.integration.ts +2 -2
- package/src/pipeline/__tests__/projection-rebuild.integration.ts +9 -3
- package/src/pipeline/__tests__/query-projection.integration.ts +2 -2
- package/src/pipeline/dispatcher.ts +35 -15
- package/src/pipeline/event-consumer-state.ts +2 -2
- package/src/pipeline/event-dispatcher.ts +10 -1
- package/src/pipeline/lifecycle-pipeline.ts +22 -4
- package/src/pipeline/projection-state.ts +3 -3
- package/src/stack/index.ts +4 -3
- package/src/stack/push-entity-projection-tables.ts +51 -0
- package/src/stack/table-helpers.ts +20 -13
- package/src/stack/test-stack.ts +14 -5
- package/src/testing/__tests__/ensure-entity-table.integration.ts +16 -11
package/LICENSE
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Business Source License 1.1
|
|
2
|
+
|
|
3
|
+
Parameters
|
|
4
|
+
|
|
5
|
+
Licensor: Marc Frost
|
|
6
|
+
|
|
7
|
+
Licensed Work: @cosmicdrift/kumiko-framework
|
|
8
|
+
The Licensed Work is © 2026 Marc Frost.
|
|
9
|
+
|
|
10
|
+
Additional Use Grant:
|
|
11
|
+
You may use the Licensed Work in production for any purpose, including
|
|
12
|
+
commercially, EXCEPT for the Restricted Use.
|
|
13
|
+
|
|
14
|
+
"Restricted Use" is defined as using the Licensed Work to provide a platform
|
|
15
|
+
or service to third parties that allows them to host, deploy, or run their
|
|
16
|
+
own applications built with the Licensed Work. This includes, but is not
|
|
17
|
+
limited to: managed hosting services, software-as-a-service (SaaS) platforms,
|
|
18
|
+
platform-as-a-service (PaaS), developer platforms, or any multi-tenant
|
|
19
|
+
managed offering of the Licensed Work.
|
|
20
|
+
|
|
21
|
+
This restriction does not apply to the Licensor, any entity controlled by,
|
|
22
|
+
controlling, or under common control with the Licensor ("Affiliates"), or
|
|
23
|
+
contractors acting on their behalf. The Licensor remains free to use the
|
|
24
|
+
Licensed Work for any purpose, including for the operation of kumiko.so.
|
|
25
|
+
|
|
26
|
+
Change Date: 2030-05-05
|
|
27
|
+
Change License: Apache License, Version 2.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
Terms
|
|
31
|
+
|
|
32
|
+
The Licensor hereby grants you the right to copy, modify, create derivative works,
|
|
33
|
+
redistribute, and make non-production use of the Licensed Work. The Licensor may
|
|
34
|
+
make an Additional Use Grant, above, permitting limited production use.
|
|
35
|
+
|
|
36
|
+
Effective on the Change Date, or the fourth anniversary of the first publicly
|
|
37
|
+
available distribution of the Licensed Work under this License, whichever comes
|
|
38
|
+
first, this License will convert to the Change License.
|
|
39
|
+
|
|
40
|
+
This Business Source License governs use of the Licensed Work in all cases, except
|
|
41
|
+
as to any use that is explicitly granted in the Additional Use Grant above or
|
|
42
|
+
under the Change License after the Change Date.
|
|
43
|
+
|
|
44
|
+
If your use of the Licensed Work does not comply with the requirements of this
|
|
45
|
+
License, you must cease use of the Licensed Work immediately.
|
|
46
|
+
|
|
47
|
+
All copies of the Licensed Work, and all derivative works thereof, must include
|
|
48
|
+
this License.
|
|
49
|
+
|
|
50
|
+
This License does not grant you any right, title, or interest in any trademark,
|
|
51
|
+
logo, or branding of the Licensor, except as required to comply with this License.
|
|
52
|
+
|
|
53
|
+
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON AN
|
|
54
|
+
“AS IS” BASIS. LICENSOR DISCLAIMS ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING
|
|
55
|
+
WITHOUT LIMITATION WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE,
|
|
56
|
+
TITLE, AND NON-INFRINGEMENT. IN NO EVENT WILL LICENSOR BE LIABLE FOR ANY DAMAGES
|
|
57
|
+
ARISING OUT OF OR RELATED TO THIS LICENSE OR THE USE OF THE LICENSED WORK.
|
package/package.json
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
|
-
"url": "git+https://github.com/
|
|
9
|
+
"url": "git+https://github.com/CosmicDriftGameStudio/kumiko-framework.git",
|
|
10
10
|
"directory": "packages/framework"
|
|
11
11
|
},
|
|
12
12
|
"bugs": {
|
|
13
|
-
"url": "https://github.com/
|
|
13
|
+
"url": "https://github.com/CosmicDriftGameStudio/kumiko-framework/issues"
|
|
14
14
|
},
|
|
15
15
|
"homepage": "https://kumiko.so",
|
|
16
16
|
"keywords": [
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
defineFeature,
|
|
16
16
|
type TenantId,
|
|
17
17
|
} from "../engine";
|
|
18
|
-
import {
|
|
18
|
+
import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../stack";
|
|
19
19
|
|
|
20
20
|
const TENANT_ID = "00000000-0000-4000-8000-000000000001" as TenantId;
|
|
21
21
|
const OTHER_TENANT_ID = "00000000-0000-4000-8000-000000000002" as TenantId;
|
|
@@ -104,8 +104,8 @@ describe("anonymous access — single-tenant default", () => {
|
|
|
104
104
|
features: [shopFeature],
|
|
105
105
|
anonymousAccess: { defaultTenantId: TENANT_ID },
|
|
106
106
|
});
|
|
107
|
-
await
|
|
108
|
-
await
|
|
107
|
+
await unsafeCreateEntityTable(stack.db, productEntity);
|
|
108
|
+
await unsafeCreateEntityTable(stack.db, orderEntity);
|
|
109
109
|
});
|
|
110
110
|
|
|
111
111
|
afterAll(() => stack.cleanup());
|
|
@@ -216,8 +216,8 @@ describe("anonymous access — header-supplied tenant", () => {
|
|
|
216
216
|
tenantExists: async (id: TenantId) => id === TENANT_ID || id === OTHER_TENANT_ID,
|
|
217
217
|
},
|
|
218
218
|
});
|
|
219
|
-
await
|
|
220
|
-
await
|
|
219
|
+
await unsafeCreateEntityTable(stack.db, productEntity);
|
|
220
|
+
await unsafeCreateEntityTable(stack.db, orderEntity);
|
|
221
221
|
});
|
|
222
222
|
|
|
223
223
|
afterAll(() => stack.cleanup());
|
|
@@ -309,8 +309,8 @@ describe("anonymous access — disabled by default", () => {
|
|
|
309
309
|
|
|
310
310
|
beforeAll(async () => {
|
|
311
311
|
stack = await setupTestStack({ features: [shopFeature] });
|
|
312
|
-
await
|
|
313
|
-
await
|
|
312
|
+
await unsafeCreateEntityTable(stack.db, productEntity);
|
|
313
|
+
await unsafeCreateEntityTable(stack.db, orderEntity);
|
|
314
314
|
});
|
|
315
315
|
|
|
316
316
|
afterAll(() => stack.cleanup());
|
|
@@ -22,11 +22,11 @@ import {
|
|
|
22
22
|
writeFailure,
|
|
23
23
|
} from "../errors";
|
|
24
24
|
import {
|
|
25
|
-
createEntityTable,
|
|
26
25
|
createTestUser,
|
|
27
26
|
setupTestStack,
|
|
28
27
|
type TestStack,
|
|
29
28
|
TestUsers,
|
|
29
|
+
unsafeCreateEntityTable,
|
|
30
30
|
} from "../stack";
|
|
31
31
|
|
|
32
32
|
// --- Entity + handlers that deliberately raise each Kumiko error class ---
|
|
@@ -187,7 +187,7 @@ const guest = createTestUser({ id: 2, roles: ["Guest"] });
|
|
|
187
187
|
|
|
188
188
|
beforeAll(async () => {
|
|
189
189
|
stack = await setupTestStack({ features: [errorFeature] });
|
|
190
|
-
await
|
|
190
|
+
await unsafeCreateEntityTable(stack.db, itemEntity);
|
|
191
191
|
});
|
|
192
192
|
afterAll(async () => stack.cleanup());
|
|
193
193
|
beforeEach(async () => {
|
|
@@ -12,13 +12,13 @@ import {
|
|
|
12
12
|
type SessionUser,
|
|
13
13
|
} from "../engine";
|
|
14
14
|
import {
|
|
15
|
-
createEntityTable,
|
|
16
15
|
createTestDb,
|
|
17
16
|
createTestRedis,
|
|
18
17
|
createTestUser,
|
|
19
18
|
type TestDb,
|
|
20
19
|
type TestRedis,
|
|
21
20
|
TestUsers,
|
|
21
|
+
unsafeCreateEntityTable,
|
|
22
22
|
} from "../stack";
|
|
23
23
|
|
|
24
24
|
// --- Entity with field-level access ---
|
|
@@ -52,7 +52,7 @@ beforeAll(async () => {
|
|
|
52
52
|
testDb = await createTestDb();
|
|
53
53
|
testRedis = await createTestRedis();
|
|
54
54
|
|
|
55
|
-
await
|
|
55
|
+
await unsafeCreateEntityTable(testDb.db, employeeEntity);
|
|
56
56
|
|
|
57
57
|
const feature = defineFeature("employees", (r) => {
|
|
58
58
|
r.entity("employee", employeeEntity);
|
|
@@ -5,11 +5,11 @@ import { defineFeature, type EntityId, type HandlerContext, type SaveContext } f
|
|
|
5
5
|
import { UnprocessableError, writeFailure } from "../errors";
|
|
6
6
|
import { eventsTable } from "../event-store";
|
|
7
7
|
import {
|
|
8
|
-
createEntityTable,
|
|
9
8
|
createTestUser,
|
|
10
9
|
setupTestStack,
|
|
11
10
|
type TestStack,
|
|
12
11
|
TestUsers,
|
|
12
|
+
unsafeCreateEntityTable,
|
|
13
13
|
} from "../stack";
|
|
14
14
|
import { expectErrorIncludes, sharedUserEntity, sharedUserTable } from "../testing";
|
|
15
15
|
|
|
@@ -203,7 +203,7 @@ const otherTenantAdmin = createTestUser({
|
|
|
203
203
|
|
|
204
204
|
beforeAll(async () => {
|
|
205
205
|
stack = await setupTestStack({ features: [userFeature] });
|
|
206
|
-
await
|
|
206
|
+
await unsafeCreateEntityTable(stack.db, userEntity, "user");
|
|
207
207
|
});
|
|
208
208
|
|
|
209
209
|
afterAll(async () => {
|
|
@@ -15,12 +15,12 @@ import {
|
|
|
15
15
|
} from "../engine";
|
|
16
16
|
import type { SessionUser, TenantId } from "../engine/types";
|
|
17
17
|
import {
|
|
18
|
-
createEntityTable,
|
|
19
18
|
createTestUser,
|
|
20
19
|
setupTestStack,
|
|
21
20
|
type TestStack,
|
|
22
21
|
TestUsers,
|
|
23
22
|
testTenantId,
|
|
23
|
+
unsafeCreateEntityTable,
|
|
24
24
|
} from "../stack";
|
|
25
25
|
import { expectErrorIncludes } from "../testing";
|
|
26
26
|
|
|
@@ -123,7 +123,7 @@ let stack: TestStack;
|
|
|
123
123
|
|
|
124
124
|
beforeAll(async () => {
|
|
125
125
|
stack = await setupTestStack({ features: [teamsFeature, contractsFeature] });
|
|
126
|
-
await
|
|
126
|
+
await unsafeCreateEntityTable(stack.db, contractEntity, "contract");
|
|
127
127
|
});
|
|
128
128
|
|
|
129
129
|
afterAll(async () => {
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Integration test for r.rawTable() — proves the full boot path:
|
|
2
|
+
// defineFeature declares a raw table → setupTestStack auto-pushes it →
|
|
3
|
+
// INSERT/SELECT against the real DB roundtrip. Plan reference:
|
|
4
|
+
// kumiko-platform/docs/plans/architecture/table-ddl-guard.md (Stufe 3).
|
|
5
|
+
|
|
6
|
+
import { eq, sql } from "drizzle-orm";
|
|
7
|
+
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
|
8
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
9
|
+
import { defineFeature } from "../engine";
|
|
10
|
+
import { setupTestStack, type TestStack, unsafePushTables } from "../stack";
|
|
11
|
+
|
|
12
|
+
// External-system payload cache — the textbook r.rawTable() use case:
|
|
13
|
+
// write-only by an integration handler, read-only by a query, never
|
|
14
|
+
// event-sourced (the data isn't a domain fact, it's a side-effect
|
|
15
|
+
// snapshot).
|
|
16
|
+
const stripeWebhookCache = pgTable("rt_int_stripe_webhook_cache", {
|
|
17
|
+
eventId: text("event_id").primaryKey(),
|
|
18
|
+
payload: text("payload").notNull(),
|
|
19
|
+
receivedAt: timestamp("received_at", { withTimezone: true }).notNull().defaultNow(),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// A second physical table reachable through two distinct r.rawTable
|
|
23
|
+
// registrations — pins the seenTables-by-reference dedupe at push time.
|
|
24
|
+
// Two logical names, one CREATE.
|
|
25
|
+
const sharedSyncCache = pgTable("rt_int_shared_sync_cache", {
|
|
26
|
+
syncId: text("sync_id").primaryKey(),
|
|
27
|
+
payload: text("payload").notNull(),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const webhookCacheFeature = defineFeature("webhook-cache", (r) => {
|
|
31
|
+
r.rawTable("stripe", stripeWebhookCache, {
|
|
32
|
+
reason: "external Stripe webhook payload cache — write-only by webhook handler",
|
|
33
|
+
});
|
|
34
|
+
r.rawTable("primary-sync", sharedSyncCache, {
|
|
35
|
+
reason: "shared sync-state cache, primary writer",
|
|
36
|
+
});
|
|
37
|
+
r.rawTable("secondary-sync", sharedSyncCache, {
|
|
38
|
+
reason: "same physical table, different logical role for read consumers",
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
let stack: TestStack;
|
|
43
|
+
|
|
44
|
+
beforeAll(async () => {
|
|
45
|
+
stack = await setupTestStack({ features: [webhookCacheFeature] });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterAll(async () => {
|
|
49
|
+
await stack.cleanup();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("r.rawTable — DB roundtrip via setupTestStack", () => {
|
|
53
|
+
test("table is auto-pushed and accepts INSERT + SELECT", async () => {
|
|
54
|
+
const eventId = "evt_test_123";
|
|
55
|
+
const payload = JSON.stringify({ type: "invoice.paid", amount: 4200 });
|
|
56
|
+
|
|
57
|
+
await stack.db.insert(stripeWebhookCache).values({ eventId, payload });
|
|
58
|
+
|
|
59
|
+
const rows = await stack.db
|
|
60
|
+
.select()
|
|
61
|
+
.from(stripeWebhookCache)
|
|
62
|
+
.where(eq(stripeWebhookCache.eventId, eventId));
|
|
63
|
+
|
|
64
|
+
expect(rows).toHaveLength(1);
|
|
65
|
+
expect(rows[0]?.payload).toBe(payload);
|
|
66
|
+
expect(rows[0]?.receivedAt).toBeInstanceOf(Date);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("registry exposes the raw table with its reason and featureName", () => {
|
|
70
|
+
const all = stack.registry.getAllRawTables();
|
|
71
|
+
const entry = all.get("stripe");
|
|
72
|
+
expect(entry).toBeDefined();
|
|
73
|
+
expect(entry?.featureName).toBe("webhook-cache");
|
|
74
|
+
expect(entry?.reason).toContain("Stripe webhook payload cache");
|
|
75
|
+
expect(entry?.table).toBe(stripeWebhookCache);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("INSERT bypasses the event store — no kumiko_events row produced", async () => {
|
|
79
|
+
// Proves that the rawTable lives outside the event-sourcing graph:
|
|
80
|
+
// a write to it shouldn't append anything to kumiko_events. If a
|
|
81
|
+
// future regression accidentally routed raw-table writes through
|
|
82
|
+
// the executor, a row would show up here.
|
|
83
|
+
const before = await stack.db.execute<{ count: string }>(
|
|
84
|
+
sql`SELECT COUNT(*)::text AS count FROM kumiko_events`,
|
|
85
|
+
);
|
|
86
|
+
const beforeCount = Number(before[0]?.count ?? 0);
|
|
87
|
+
|
|
88
|
+
await stack.db.insert(stripeWebhookCache).values({
|
|
89
|
+
eventId: "evt_no_event_emitted",
|
|
90
|
+
payload: "{}",
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const after = await stack.db.execute<{ count: string }>(
|
|
94
|
+
sql`SELECT COUNT(*)::text AS count FROM kumiko_events`,
|
|
95
|
+
);
|
|
96
|
+
const afterCount = Number(after[0]?.count ?? 0);
|
|
97
|
+
|
|
98
|
+
expect(afterCount).toBe(beforeCount);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("two registrations sharing one PgTable result in one CREATE (dedupe by reference)", async () => {
|
|
102
|
+
// primary-sync + secondary-sync both target sharedSyncCache. If the
|
|
103
|
+
// setupTestStack dedupe (seenTables-by-table-reference) had silently
|
|
104
|
+
// broken, beforeAll's setupTestStack would have raised a 42P07 on
|
|
105
|
+
// the second push and never reached this test.
|
|
106
|
+
await stack.db.insert(sharedSyncCache).values({ syncId: "sync_1", payload: "{}" });
|
|
107
|
+
const rows = await stack.db
|
|
108
|
+
.select()
|
|
109
|
+
.from(sharedSyncCache)
|
|
110
|
+
.where(eq(sharedSyncCache.syncId, "sync_1"));
|
|
111
|
+
expect(rows).toHaveLength(1);
|
|
112
|
+
// Both registrations are visible in the registry — same physical
|
|
113
|
+
// target, different logical handles.
|
|
114
|
+
expect(stack.registry.getAllRawTables().get("primary-sync")?.table).toBe(sharedSyncCache);
|
|
115
|
+
expect(stack.registry.getAllRawTables().get("secondary-sync")?.table).toBe(sharedSyncCache);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("a second push on the same rawTable would crash — proves tableExists-filter is load-bearing", async () => {
|
|
119
|
+
// The setupTestStack push is idempotent because it filters by
|
|
120
|
+
// tableExists before calling unsafePushTables (which is itself
|
|
121
|
+
// strict — that's the contract of the unsafe-prefix). Pin the
|
|
122
|
+
// failure mode of the unfiltered call: a direct second push on
|
|
123
|
+
// the same Drizzle schema raises the underlying PG error. This
|
|
124
|
+
// is the negative form of the idempotency contract — without the
|
|
125
|
+
// filter, a re-boot against a persistent dev DB would crash here.
|
|
126
|
+
await expect(unsafePushTables(stack.db, { idem: stripeWebhookCache })).rejects.toThrow();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -2,7 +2,7 @@ import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
|
2
2
|
import { integer, table as pgTable, serial, text } from "../db/dialect";
|
|
3
3
|
import { seedReferenceData } from "../db/reference-data";
|
|
4
4
|
import type { ReferenceDataDef } from "../engine/types";
|
|
5
|
-
import { createTestDb,
|
|
5
|
+
import { createTestDb, type TestDb, unsafePushTables } from "../stack";
|
|
6
6
|
|
|
7
7
|
// --- Tables ---
|
|
8
8
|
|
|
@@ -25,7 +25,7 @@ let testDb: TestDb;
|
|
|
25
25
|
|
|
26
26
|
beforeAll(async () => {
|
|
27
27
|
testDb = await createTestDb();
|
|
28
|
-
await
|
|
28
|
+
await unsafePushTables(testDb.db, { countryTable, statusTable });
|
|
29
29
|
});
|
|
30
30
|
|
|
31
31
|
afterAll(async () => {
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
createTextField,
|
|
10
10
|
defineFeature,
|
|
11
11
|
} from "../engine";
|
|
12
|
-
import {
|
|
12
|
+
import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../stack";
|
|
13
13
|
import { expectErrorIncludes } from "../testing";
|
|
14
14
|
|
|
15
15
|
// Two entities, both with a field named `status`, but different transitions.
|
|
@@ -178,9 +178,9 @@ const admin = TestUsers.admin;
|
|
|
178
178
|
|
|
179
179
|
beforeAll(async () => {
|
|
180
180
|
stack = await setupTestStack({ features: [feature] });
|
|
181
|
-
await
|
|
182
|
-
await
|
|
183
|
-
await
|
|
181
|
+
await unsafeCreateEntityTable(stack.db, invoiceEntity);
|
|
182
|
+
await unsafeCreateEntityTable(stack.db, orderEntity);
|
|
183
|
+
await unsafeCreateEntityTable(stack.db, ticketEntity);
|
|
184
184
|
});
|
|
185
185
|
|
|
186
186
|
afterAll(async () => {
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
type SaveContext,
|
|
13
13
|
} from "../../engine";
|
|
14
14
|
import { UnprocessableError, writeFailure } from "../../errors";
|
|
15
|
-
import {
|
|
15
|
+
import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
|
|
16
16
|
|
|
17
17
|
// Entity: a simple "item" with name + counter
|
|
18
18
|
const itemEntity = createEntity({
|
|
@@ -151,8 +151,8 @@ const admin = TestUsers.admin;
|
|
|
151
151
|
|
|
152
152
|
beforeAll(async () => {
|
|
153
153
|
stack = await setupTestStack({ features: [itemFeature] });
|
|
154
|
-
await
|
|
155
|
-
await
|
|
154
|
+
await unsafeCreateEntityTable(stack.db, itemEntity);
|
|
155
|
+
await unsafeCreateEntityTable(stack.db, auditEntity);
|
|
156
156
|
});
|
|
157
157
|
|
|
158
158
|
afterAll(async () => {
|
|
@@ -5,7 +5,7 @@ import { generateToken } from "../../api/tokens";
|
|
|
5
5
|
import { createEventStoreExecutor } from "../../db/event-store-executor";
|
|
6
6
|
import { buildDrizzleTable } from "../../db/table-builder";
|
|
7
7
|
import { createEntity, createTextField, defineFeature } from "../../engine";
|
|
8
|
-
import {
|
|
8
|
+
import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
|
|
9
9
|
import { generateId } from "../../utils";
|
|
10
10
|
|
|
11
11
|
// End-to-end: UI code would call `dispatcher.write("feat:write:item:create", ...)`.
|
|
@@ -97,7 +97,7 @@ async function buildFetch(): Promise<{
|
|
|
97
97
|
|
|
98
98
|
beforeAll(async () => {
|
|
99
99
|
stack = await setupTestStack({ features: [itemFeature] });
|
|
100
|
-
await
|
|
100
|
+
await unsafeCreateEntityTable(stack.db, itemEntity);
|
|
101
101
|
});
|
|
102
102
|
|
|
103
103
|
afterAll(async () => {
|
|
@@ -4,7 +4,7 @@ import { z } from "zod";
|
|
|
4
4
|
import { createEventStoreExecutor } from "../../db/event-store-executor";
|
|
5
5
|
import { buildDrizzleTable } from "../../db/table-builder";
|
|
6
6
|
import { createEntity, createTextField, defineFeature } from "../../engine";
|
|
7
|
-
import {
|
|
7
|
+
import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
|
|
8
8
|
|
|
9
9
|
// Two entities in a 1:N relation. The relation is declared with
|
|
10
10
|
// `nestedWrite: true`, which opts the framework into expanding
|
|
@@ -69,8 +69,8 @@ const admin = TestUsers.admin;
|
|
|
69
69
|
|
|
70
70
|
beforeAll(async () => {
|
|
71
71
|
stack = await setupTestStack({ features: [nestedFeature] });
|
|
72
|
-
await
|
|
73
|
-
await
|
|
72
|
+
await unsafeCreateEntityTable(stack.db, projectEntity);
|
|
73
|
+
await unsafeCreateEntityTable(stack.db, taskEntity);
|
|
74
74
|
});
|
|
75
75
|
|
|
76
76
|
afterAll(async () => {
|
|
@@ -2,7 +2,7 @@ import { drizzle } from "drizzle-orm/postgres-js";
|
|
|
2
2
|
import postgres from "postgres";
|
|
3
3
|
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
4
4
|
import { createBooleanField, createEntity, createTextField } from "../../engine";
|
|
5
|
-
import {
|
|
5
|
+
import { testTenantId, unsafeCreateEntityTable } from "../../stack";
|
|
6
6
|
import { applyCursorQuery, encodeCursor } from "../cursor";
|
|
7
7
|
import { buildDrizzleTable } from "../table-builder";
|
|
8
8
|
|
|
@@ -49,7 +49,7 @@ beforeAll(async () => {
|
|
|
49
49
|
client = postgres(testUrl);
|
|
50
50
|
db = drizzle(client);
|
|
51
51
|
|
|
52
|
-
await
|
|
52
|
+
await unsafeCreateEntityTable(db, entity);
|
|
53
53
|
|
|
54
54
|
const rows = [
|
|
55
55
|
{
|
|
@@ -8,7 +8,7 @@ import { sql } from "drizzle-orm";
|
|
|
8
8
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
|
9
9
|
import { createEntity, createNumberField, createTextField } from "../../engine";
|
|
10
10
|
import { createEventsTable } from "../../event-store";
|
|
11
|
-
import {
|
|
11
|
+
import { createTestDb, type TestDb, TestUsers, unsafeCreateEntityTable } from "../../stack";
|
|
12
12
|
import { createEventStoreExecutor } from "../event-store-executor";
|
|
13
13
|
import { buildDrizzleTable } from "../table-builder";
|
|
14
14
|
import { createTenantDb, type TenantDb } from "../tenant-db";
|
|
@@ -28,7 +28,7 @@ const admin = TestUsers.admin;
|
|
|
28
28
|
|
|
29
29
|
beforeAll(async () => {
|
|
30
30
|
testDb = await createTestDb();
|
|
31
|
-
await
|
|
31
|
+
await unsafeCreateEntityTable(testDb.db, entity, "pagerItem");
|
|
32
32
|
await createEventsTable(testDb.db);
|
|
33
33
|
tdb = createTenantDb(testDb.db, admin.tenantId);
|
|
34
34
|
});
|
|
@@ -2,7 +2,13 @@ import { sql } from "drizzle-orm";
|
|
|
2
2
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
|
3
3
|
import { createBooleanField, createEntity, createTextField } from "../../engine";
|
|
4
4
|
import { createEventsTable } from "../../event-store";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
createTestDb,
|
|
7
|
+
type TestDb,
|
|
8
|
+
TestUsers,
|
|
9
|
+
testTenantId,
|
|
10
|
+
unsafeCreateEntityTable,
|
|
11
|
+
} from "../../stack";
|
|
6
12
|
import { createEventStoreExecutor } from "../event-store-executor";
|
|
7
13
|
import { buildDrizzleTable } from "../table-builder";
|
|
8
14
|
import { createTenantDb, type TenantDb } from "../tenant-db";
|
|
@@ -24,7 +30,7 @@ const adminUser = TestUsers.admin;
|
|
|
24
30
|
|
|
25
31
|
beforeAll(async () => {
|
|
26
32
|
testDb = await createTestDb();
|
|
27
|
-
await
|
|
33
|
+
await unsafeCreateEntityTable(testDb.db, entity, "esExecUser");
|
|
28
34
|
await createEventsTable(testDb.db);
|
|
29
35
|
tdb = createTenantDb(testDb.db, adminUser.tenantId);
|
|
30
36
|
});
|
|
@@ -119,7 +125,7 @@ describe("event-store-executor — sensitive fields", () => {
|
|
|
119
125
|
});
|
|
120
126
|
|
|
121
127
|
beforeAll(async () => {
|
|
122
|
-
await
|
|
128
|
+
await unsafeCreateEntityTable(testDb.db, sensitiveEntity, "esExecSensitive");
|
|
123
129
|
});
|
|
124
130
|
|
|
125
131
|
beforeEach(async () => {
|
|
@@ -22,7 +22,7 @@ import { createRegistry } from "../../engine/registry";
|
|
|
22
22
|
import { createEventsTable } from "../../event-store";
|
|
23
23
|
import { rebuildProjection } from "../../pipeline";
|
|
24
24
|
import { createProjectionStateTable } from "../../pipeline/projection-state";
|
|
25
|
-
import {
|
|
25
|
+
import { createTestDb, type TestDb, TestUsers, unsafeCreateEntityTable } from "../../stack";
|
|
26
26
|
import { createEventStoreExecutor } from "../event-store-executor";
|
|
27
27
|
import { buildDrizzleTable } from "../table-builder";
|
|
28
28
|
import { createTenantDb, type TenantDb } from "../tenant-db";
|
|
@@ -49,7 +49,7 @@ const adminUser = TestUsers.admin;
|
|
|
49
49
|
|
|
50
50
|
beforeAll(async () => {
|
|
51
51
|
testDb = await createTestDb();
|
|
52
|
-
await
|
|
52
|
+
await unsafeCreateEntityTable(testDb.db, userEntity, "user");
|
|
53
53
|
await createEventsTable(testDb.db);
|
|
54
54
|
await createProjectionStateTable(testDb.db);
|
|
55
55
|
tdb = createTenantDb(testDb.db, adminUser.tenantId);
|
|
@@ -244,7 +244,7 @@ const sensitiveDrizzleTable = buildDrizzleTable("sensitive-user", sensitiveEntit
|
|
|
244
244
|
|
|
245
245
|
describe("implicit-projection / dokumentierte Sensitive-Drift", () => {
|
|
246
246
|
beforeAll(async () => {
|
|
247
|
-
await
|
|
247
|
+
await unsafeCreateEntityTable(testDb.db, sensitiveEntity, "sensitive-user");
|
|
248
248
|
});
|
|
249
249
|
|
|
250
250
|
beforeEach(async () => {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
11
11
|
import { buildDrizzleTable } from "../../db/table-builder";
|
|
12
12
|
import { createEntity, createTextField } from "../../engine";
|
|
13
|
-
import {
|
|
13
|
+
import { setupTestStack, type TestStack, unsafeCreateEntityTable } from "../../stack";
|
|
14
14
|
|
|
15
15
|
const linkEntity = createEntity({
|
|
16
16
|
table: "mri_links",
|
|
@@ -25,7 +25,7 @@ let stack: TestStack;
|
|
|
25
25
|
|
|
26
26
|
beforeAll(async () => {
|
|
27
27
|
stack = await setupTestStack({ features: [] });
|
|
28
|
-
await
|
|
28
|
+
await unsafeCreateEntityTable(stack.db, linkEntity);
|
|
29
29
|
});
|
|
30
30
|
|
|
31
31
|
afterAll(async () => stack?.cleanup());
|
|
@@ -44,7 +44,7 @@ describe("instant() customType is forgiving with ISO strings", () => {
|
|
|
44
44
|
const tsTable = buildDrizzleTable("ts-row", tsEntity);
|
|
45
45
|
|
|
46
46
|
test("INSERT accepts an ISO string for an instant column (forgiving path)", async () => {
|
|
47
|
-
await
|
|
47
|
+
await unsafeCreateEntityTable(stack.db, tsEntity, "ts-row");
|
|
48
48
|
// insertedAt is base-column, type instant. Pass an ISO string —
|
|
49
49
|
// coercion in toDriver handles it. Without the fix, Drizzle-driver
|
|
50
50
|
// would call .toString() on a string and produce a malformed driver
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
defineFeature,
|
|
10
10
|
} from "../../engine";
|
|
11
11
|
import type { FeatureDefinition } from "../../engine/types";
|
|
12
|
-
import { createTestDb,
|
|
12
|
+
import { createTestDb, type TestDb, unsafePushTables } from "../../stack";
|
|
13
13
|
import { buildDrizzleTable } from "../table-builder";
|
|
14
14
|
|
|
15
15
|
/**
|
|
@@ -19,7 +19,7 @@ import { buildDrizzleTable } from "../table-builder";
|
|
|
19
19
|
* Each test simulates:
|
|
20
20
|
* 1. Developer defines/changes entities
|
|
21
21
|
* 2. buildDrizzleTable creates Drizzle table objects
|
|
22
|
-
* 3. Schema is applied to a real database via
|
|
22
|
+
* 3. Schema is applied to a real database via unsafePushTables (drizzle-kit push)
|
|
23
23
|
* 4. We verify the DB state matches expectations
|
|
24
24
|
*/
|
|
25
25
|
|
|
@@ -41,7 +41,7 @@ async function applySchema(features: readonly FeatureDefinition[]): Promise<void
|
|
|
41
41
|
tables[entityName] = buildDrizzleTable(entityName, entity);
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
|
-
await
|
|
44
|
+
await unsafePushTables(testDb.db, tables);
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
// Helper: read column info from information_schema
|
|
@@ -136,7 +136,7 @@ describe("schema migration workflows", () => {
|
|
|
136
136
|
table: "wf2_users",
|
|
137
137
|
fields: { email: createTextField() },
|
|
138
138
|
});
|
|
139
|
-
await
|
|
139
|
+
await unsafePushTables(testDb.db, { user: buildDrizzleTable("user", initialEntity) });
|
|
140
140
|
|
|
141
141
|
// Developer adds a new field
|
|
142
142
|
const updatedEntity = createEntity({
|
|
@@ -148,7 +148,7 @@ describe("schema migration workflows", () => {
|
|
|
148
148
|
});
|
|
149
149
|
|
|
150
150
|
// Push updated schema — drizzle-kit generates ALTER TABLE ADD COLUMN
|
|
151
|
-
await
|
|
151
|
+
await unsafePushTables(
|
|
152
152
|
testDb.db,
|
|
153
153
|
{ user: buildDrizzleTable("user", updatedEntity) },
|
|
154
154
|
{ user: buildDrizzleTable("user", initialEntity) },
|
|
@@ -167,7 +167,7 @@ describe("schema migration workflows", () => {
|
|
|
167
167
|
fields: { name: createTextField() },
|
|
168
168
|
});
|
|
169
169
|
const initialTable = buildDrizzleTable("project", initialEntity);
|
|
170
|
-
await
|
|
170
|
+
await unsafePushTables(testDb.db, { project: initialTable });
|
|
171
171
|
|
|
172
172
|
// Insert a row first (to prove ADD COLUMN with default doesn't break existing rows)
|
|
173
173
|
await testDb.db
|
|
@@ -180,7 +180,7 @@ describe("schema migration workflows", () => {
|
|
|
180
180
|
fields: { name: createTextField(), isArchived: createBooleanField({ default: false }) },
|
|
181
181
|
});
|
|
182
182
|
const updatedTable = buildDrizzleTable("project", updatedEntity);
|
|
183
|
-
await
|
|
183
|
+
await unsafePushTables(testDb.db, { project: updatedTable }, { project: initialTable });
|
|
184
184
|
|
|
185
185
|
// Existing row should have the default value
|
|
186
186
|
const rows = await testDb.db.select().from(updatedTable);
|
|
@@ -200,7 +200,7 @@ describe("schema migration workflows", () => {
|
|
|
200
200
|
fields: { email: createTextField({ required: true }) },
|
|
201
201
|
});
|
|
202
202
|
const initialTable = buildDrizzleTable("user", initialEntity);
|
|
203
|
-
await
|
|
203
|
+
await unsafePushTables(testDb.db, { user: initialTable });
|
|
204
204
|
|
|
205
205
|
await testDb.db
|
|
206
206
|
.insert(initialTable)
|
|
@@ -214,7 +214,7 @@ describe("schema migration workflows", () => {
|
|
|
214
214
|
},
|
|
215
215
|
});
|
|
216
216
|
const updatedTable = buildDrizzleTable("user", updatedEntity);
|
|
217
|
-
await
|
|
217
|
+
await unsafePushTables(testDb.db, { user: updatedTable }, { user: initialTable });
|
|
218
218
|
|
|
219
219
|
const rows = await testDb.db.select().from(updatedTable);
|
|
220
220
|
expect(rows[0]).toMatchObject({ email: "x@y.z", roles: "[]" });
|