@cosmicdrift/kumiko-framework 0.159.1 → 0.161.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 +3 -3
- package/src/api/__tests__/api.test.ts +65 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +1 -0
- package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +237 -0
- package/src/api/__tests__/auth-routes-mfa-verify.test.ts +1 -0
- package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
- package/src/api/__tests__/login-rate-limiter-sweep.test.ts +41 -0
- package/src/api/__tests__/server-boot-guards.test.ts +71 -0
- package/src/api/api-constants.ts +1 -0
- package/src/api/auth-middleware.ts +17 -44
- package/src/api/auth-routes.ts +6 -2
- package/src/api/index.ts +1 -0
- package/src/api/routes.ts +57 -0
- package/src/api/server.ts +5 -4
- package/src/bun-db/query.ts +12 -25
- package/src/crypto/kms-adapter.ts +2 -118
- package/src/db/__tests__/build-filter-where.test.ts +34 -0
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +91 -0
- package/src/db/cursor.ts +1 -18
- package/src/db/dialect.ts +8 -19
- package/src/db/entity-table-meta-types.ts +2 -92
- package/src/db/event-store-executor.ts +4 -96
- package/src/db/table-builder.ts +2 -19
- package/src/db/tenant-db.ts +6 -55
- package/src/engine/__tests__/boot-validator.test.ts +46 -0
- package/src/engine/__tests__/codemod-pipeline.test.ts +139 -10
- package/src/engine/__tests__/engine.test.ts +28 -0
- package/src/engine/__tests__/registry-facade-sweep.test.ts +80 -0
- package/src/engine/__tests__/registry.test.ts +40 -0
- package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
- package/src/engine/boot-validator/entity-handler.ts +10 -1
- package/src/engine/define-feature.ts +1 -0
- package/src/engine/define-handler.ts +1 -0
- package/src/engine/feature-ast/__tests__/canonical-form.test.ts +11 -1
- package/src/engine/feature-ast/__tests__/parse.test.ts +983 -3
- package/src/engine/feature-ast/__tests__/patch.test.ts +168 -0
- package/src/engine/feature-ast/__tests__/patcher.test.ts +7 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +9 -0
- package/src/engine/feature-ast/extractors/handlers.ts +19 -2
- package/src/engine/feature-ast/extractors/index.ts +1 -0
- package/src/engine/feature-ast/index.ts +2 -0
- package/src/engine/feature-ast/parse.ts +3 -0
- package/src/engine/feature-ast/patch.ts +2 -0
- package/src/engine/feature-ast/patcher.ts +21 -0
- package/src/engine/feature-ast/patterns.ts +16 -0
- package/src/engine/feature-ast/render.ts +15 -0
- package/src/engine/feature-builder-state.ts +3 -0
- package/src/engine/feature-entity-handlers.ts +35 -1
- package/src/engine/index.ts +3 -0
- package/src/engine/pattern-library/__tests__/library.test.ts +9 -0
- package/src/engine/pattern-library/library.ts +2 -0
- package/src/engine/pattern-library/mixed-schemas.ts +37 -0
- package/src/engine/registry-facade.ts +9 -0
- package/src/engine/registry-ingest.ts +10 -0
- package/src/engine/registry-state.ts +3 -0
- package/src/engine/types/config.ts +2 -497
- package/src/engine/types/define-handler.ts +2 -94
- package/src/engine/types/entity-handlers.ts +2 -30
- package/src/engine/types/feature.ts +2 -1021
- package/src/engine/types/fields.ts +2 -685
- package/src/engine/types/handlers.ts +2 -820
- package/src/engine/types/hooks.ts +2 -170
- package/src/engine/types/index.ts +44 -36
- package/src/engine/types/nav.ts +2 -67
- package/src/engine/types/ownership.ts +2 -83
- package/src/engine/types/projection.ts +2 -165
- package/src/engine/types/screen.ts +2 -747
- package/src/engine/types/step.ts +2 -334
- package/src/engine/types/workspace.ts +2 -42
- package/src/errors/write-error-info.ts +6 -22
- package/src/event-store/errors.ts +2 -35
- package/src/event-store/event-store.ts +2 -21
- package/src/event-store/snapshot.ts +11 -35
- package/src/event-store/types.ts +2 -22
- package/src/files/provider-resolver.ts +3 -5
- package/src/files/types.ts +5 -54
- package/src/jobs/__tests__/jobs.integration.test.ts +102 -1
- package/src/pipeline/__tests__/dispatcher.test.ts +96 -0
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +208 -0
- package/src/pipeline/dispatch-shared.ts +39 -1
- package/src/pipeline/dispatch-stream.ts +74 -0
- package/src/pipeline/dispatcher-utils.ts +1 -1
- package/src/pipeline/dispatcher.ts +7 -0
- package/src/pipeline/multi-stream-apply-context.ts +4 -42
- package/src/rate-limit/resolver.ts +10 -30
- package/src/secrets/envelope-cipher.ts +4 -6
- package/src/secrets/types.ts +2 -177
- package/src/stack/request-helper.ts +19 -2
- package/src/stack/test-stack.ts +33 -14
- package/src/time/tz-context.ts +9 -56
package/src/api/auth-routes.ts
CHANGED
|
@@ -598,13 +598,17 @@ export function createAuthRoutes(
|
|
|
598
598
|
const data = result.data as
|
|
599
599
|
| { kind: "auth-session"; session: SessionUser }
|
|
600
600
|
| { kind: "mfa-challenge"; challengeToken: string }
|
|
601
|
-
| { kind: "mfa-setup-required" };
|
|
601
|
+
| { kind: "mfa-setup-required"; preauthSetupToken: string };
|
|
602
602
|
|
|
603
603
|
if (data.kind === "mfa-setup-required") {
|
|
604
604
|
// No session, no challenge — the client must show an
|
|
605
605
|
// enrollment-required message. No rate-limit reset (same reasoning
|
|
606
606
|
// as the mfa-challenge branch below).
|
|
607
|
-
return c.json({
|
|
607
|
+
return c.json({
|
|
608
|
+
isSuccess: true,
|
|
609
|
+
mfaSetupRequired: true,
|
|
610
|
+
preauthSetupToken: data.preauthSetupToken,
|
|
611
|
+
});
|
|
608
612
|
}
|
|
609
613
|
|
|
610
614
|
if (data.kind === "mfa-challenge") {
|
package/src/api/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ export type { SetTenantCookieOptions } from "./anonymous-cookie";
|
|
|
2
2
|
export { deleteTenantCookie, setTenantCookie } from "./anonymous-cookie";
|
|
3
3
|
export type {
|
|
4
4
|
AnonymousAccessConfig,
|
|
5
|
+
AnonymousAccessResolved,
|
|
5
6
|
AuthMiddlewareOptions,
|
|
6
7
|
AuthSessionChecker,
|
|
7
8
|
AuthSessionStatus,
|
package/src/api/routes.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Context, Hono } from "hono";
|
|
2
|
+
import { streamSSE } from "hono/streaming";
|
|
2
3
|
import type { ContentfulStatusCode } from "hono/utils/http-status";
|
|
3
4
|
import type { SessionUser } from "../engine/types/handlers";
|
|
4
5
|
import {
|
|
@@ -16,6 +17,7 @@ import { Routes } from "./api-constants";
|
|
|
16
17
|
import { getUser } from "./auth-middleware";
|
|
17
18
|
import { patAllows } from "./pat-scope";
|
|
18
19
|
import { requestContext } from "./request-context";
|
|
20
|
+
import { SSE_HEARTBEAT_INTERVAL_MS } from "./sse-route";
|
|
19
21
|
|
|
20
22
|
export function createApiRoutes(dispatcher: Dispatcher) {
|
|
21
23
|
const api = new Hono();
|
|
@@ -121,6 +123,61 @@ export function createApiRoutes(dispatcher: Dispatcher) {
|
|
|
121
123
|
}
|
|
122
124
|
});
|
|
123
125
|
|
|
126
|
+
// Dispatcher-driven SSE, full auth/CSRF/rate-limit chain (unlike the
|
|
127
|
+
// broker-based /sse route). Frame contract for clients: "chunk" (one per
|
|
128
|
+
// yielded value, JSON-encoded), "ping" (heartbeat, empty data), "done"
|
|
129
|
+
// (terminal, empty data), "error" (terminal, JSON error envelope — the
|
|
130
|
+
// response status stays 200 since SSE headers are already flushed before
|
|
131
|
+
// dispatch gates run on the generator's first pull).
|
|
132
|
+
api.post(Routes.stream, async (c) => {
|
|
133
|
+
const user = getUser(c);
|
|
134
|
+
const body = await c.req.json<{ type: string; payload: unknown }>();
|
|
135
|
+
const requestId = requestContext.get()?.requestId;
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
assertPatAllowed(user, body.type);
|
|
139
|
+
} catch (e) {
|
|
140
|
+
return queryErrorResponse(c, toKumiko(e), body.type);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return streamSSE(c, async (stream) => {
|
|
144
|
+
const generator = dispatcher.stream(body.type, body.payload, user);
|
|
145
|
+
stream.onAbort(() => {
|
|
146
|
+
void generator.return(undefined);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
let pending = generator.next();
|
|
151
|
+
while (true) {
|
|
152
|
+
let heartbeatTimer: ReturnType<typeof setTimeout> | undefined;
|
|
153
|
+
const heartbeat = new Promise<"heartbeat">((resolve) => {
|
|
154
|
+
heartbeatTimer = setTimeout(() => resolve("heartbeat"), SSE_HEARTBEAT_INTERVAL_MS);
|
|
155
|
+
});
|
|
156
|
+
let outcome: Awaited<typeof pending> | "heartbeat";
|
|
157
|
+
try {
|
|
158
|
+
outcome = await Promise.race([pending, heartbeat]);
|
|
159
|
+
} finally {
|
|
160
|
+
clearTimeout(heartbeatTimer);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (outcome === "heartbeat") {
|
|
164
|
+
await stream.writeSSE({ event: "ping", data: "" });
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (outcome.done) break;
|
|
168
|
+
await stream.writeSSE({ event: "chunk", data: stringifyJson(outcome.value) });
|
|
169
|
+
pending = generator.next();
|
|
170
|
+
}
|
|
171
|
+
await stream.writeSSE({ event: "done", data: "" });
|
|
172
|
+
} catch (e) {
|
|
173
|
+
const err = toKumiko(e);
|
|
174
|
+
logServerFault(err, requestId, body.type);
|
|
175
|
+
const { error } = serializeError(err, requestId);
|
|
176
|
+
await stream.writeSSE({ event: "error", data: stringifyJson(error) });
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
124
181
|
return api;
|
|
125
182
|
}
|
|
126
183
|
|
package/src/api/server.ts
CHANGED
|
@@ -47,7 +47,7 @@ import {
|
|
|
47
47
|
import type { SearchAdapter } from "../search/types";
|
|
48
48
|
import { assertUnreachable, generateId } from "../utils";
|
|
49
49
|
import { PUBLIC_API_PATHS } from "./api-constants";
|
|
50
|
-
import { type
|
|
50
|
+
import { type AnonymousAccessResolved, authMiddleware, getUser } from "./auth-middleware";
|
|
51
51
|
import { type AuthRoutesConfig, createAuthRoutes } from "./auth-routes";
|
|
52
52
|
import { csrfMiddleware } from "./csrf-middleware";
|
|
53
53
|
import { createJwtHelper, type JwtHelper, type JwtKeyring } from "./jwt";
|
|
@@ -201,9 +201,10 @@ export type ServerOptions = {
|
|
|
201
201
|
instanceId?: string;
|
|
202
202
|
// Opt-in: serve unauthenticated requests on handlers that allow
|
|
203
203
|
// roles=["anonymous"]. When omitted, every /api/* request still requires
|
|
204
|
-
// a valid JWT (status quo).
|
|
205
|
-
//
|
|
206
|
-
|
|
204
|
+
// a valid JWT (status quo). App-facing config is AnonymousAccessConfig
|
|
205
|
+
// (defaultTenantId only); run{Prod,Dev}App merge auth-foundation tenant
|
|
206
|
+
// providers into AnonymousAccessResolved before calling buildServer.
|
|
207
|
+
anonymousAccess?: AnonymousAccessResolved;
|
|
207
208
|
};
|
|
208
209
|
|
|
209
210
|
export type KumikoServer = {
|
package/src/bun-db/query.ts
CHANGED
|
@@ -19,6 +19,11 @@
|
|
|
19
19
|
// drizzle's getTableName + getTableColumns (drizzle weiterhin als type-
|
|
20
20
|
// reference, NICHT als runtime-API-call)
|
|
21
21
|
|
|
22
|
+
import type {
|
|
23
|
+
SelectOptions,
|
|
24
|
+
WhereObject,
|
|
25
|
+
WhereOperator,
|
|
26
|
+
} from "@cosmicdrift/kumiko-types/where-clause-types";
|
|
22
27
|
import { computeBlindIndex, configuredBlindIndexKey } from "../crypto/blind-index";
|
|
23
28
|
import type { EntityTableMeta } from "../db/entity-table-meta";
|
|
24
29
|
import { type NotExecutorOnly, toSnakeCase } from "../db/table-builder";
|
|
@@ -183,19 +188,13 @@ function assertNotTenantScoped(db: unknown, fnName: string): void {
|
|
|
183
188
|
|
|
184
189
|
export type AnyDb = BunDbRunner | unknown;
|
|
185
190
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
readonly ne?: unknown;
|
|
194
|
-
readonly in?: readonly unknown[];
|
|
195
|
-
readonly like?: string;
|
|
196
|
-
};
|
|
197
|
-
export type WhereValue = unknown | WhereOperator;
|
|
198
|
-
export type WhereObject = Record<string, WhereValue>;
|
|
191
|
+
export type {
|
|
192
|
+
OrderByClause,
|
|
193
|
+
SelectOptions,
|
|
194
|
+
WhereObject,
|
|
195
|
+
WhereOperator,
|
|
196
|
+
WhereValue,
|
|
197
|
+
} from "@cosmicdrift/kumiko-types/where-clause-types";
|
|
199
198
|
|
|
200
199
|
function isWhereOperator(v: unknown): v is WhereOperator {
|
|
201
200
|
if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
|
|
@@ -206,18 +205,6 @@ function isWhereOperator(v: unknown): v is WhereOperator {
|
|
|
206
205
|
const opKeys = ["gt", "gte", "lt", "lte", "ne", "in", "like"];
|
|
207
206
|
return keys.every((k) => opKeys.includes(k));
|
|
208
207
|
}
|
|
209
|
-
export type OrderByClause = {
|
|
210
|
-
readonly col: string;
|
|
211
|
-
readonly direction?: "asc" | "desc";
|
|
212
|
-
};
|
|
213
|
-
|
|
214
|
-
export type SelectOptions = {
|
|
215
|
-
readonly limit?: number;
|
|
216
|
-
// Single column or array for multi-column tie-breaks (e.g.
|
|
217
|
-
// [{col: "createdAt"}, {col: "id"}] for chronological-with-stable-id).
|
|
218
|
-
readonly orderBy?: OrderByClause | readonly OrderByClause[];
|
|
219
|
-
};
|
|
220
|
-
|
|
221
208
|
// Akzeptiert EITHER. Beide haben einen tableName und field→column-mapping.
|
|
222
209
|
// biome-ignore lint/suspicious/noExplicitAny: legacy drizzle pgTable surface
|
|
223
210
|
type TableLike = EntityTableMeta | any;
|
|
@@ -1,118 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
// The subject a DEK belongs to. User data is shredded on user-forget,
|
|
4
|
-
// tenant data on tenant-destroy — two erase triggers, two subject kinds.
|
|
5
|
-
export type SubjectId =
|
|
6
|
-
| { readonly kind: "user"; readonly userId: string }
|
|
7
|
-
| { readonly kind: "tenant"; readonly tenantId: TenantId };
|
|
8
|
-
|
|
9
|
-
// Compact storage key ("user:<uuid>" / "tenant:<uuid>") — primary key in
|
|
10
|
-
// adapter backends and cache key in the request-level DEK cache.
|
|
11
|
-
export type SubjectKey = string;
|
|
12
|
-
|
|
13
|
-
export function subjectKeyForUser(userId: string): SubjectKey {
|
|
14
|
-
return `user:${userId}`;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function subjectKeyForTenant(tenantId: TenantId): SubjectKey {
|
|
18
|
-
return `tenant:${tenantId}`;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function subjectIdToKey(subject: SubjectId): SubjectKey {
|
|
22
|
-
return subject.kind === "user"
|
|
23
|
-
? subjectKeyForUser(subject.userId)
|
|
24
|
-
: subjectKeyForTenant(subject.tenantId);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function subjectIdFromKey(key: SubjectKey): SubjectId {
|
|
28
|
-
if (key.startsWith("user:")) return { kind: "user", userId: key.slice("user:".length) };
|
|
29
|
-
if (key.startsWith("tenant:")) {
|
|
30
|
-
return { kind: "tenant", tenantId: key.slice("tenant:".length) as TenantId }; // @cast-boundary parse of a key this module minted
|
|
31
|
-
}
|
|
32
|
-
throw new Error(`Invalid subject key: ${key}`);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export interface KmsContext {
|
|
36
|
-
readonly tenantId?: TenantId;
|
|
37
|
-
readonly requestId: string;
|
|
38
|
-
readonly userId?: string;
|
|
39
|
-
readonly eraseReason?: string;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export interface KmsHealth {
|
|
43
|
-
readonly ok: boolean;
|
|
44
|
-
readonly latencyMs: number;
|
|
45
|
-
readonly details?: Record<string, unknown>;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// 32-byte AES-256 data-encryption key, unwrapped and ready for local use.
|
|
49
|
-
export type SubjectDek = Buffer;
|
|
50
|
-
|
|
51
|
-
interface KmsAdapterBase {
|
|
52
|
-
/**
|
|
53
|
-
* Creates a fresh subject key. Throws KeyAlreadyExistsError when the
|
|
54
|
-
* subject already has one — including an erased tombstone: a shredded
|
|
55
|
-
* subject must never get a new key, or forget could be undone by
|
|
56
|
-
* re-encrypting under it.
|
|
57
|
-
*/
|
|
58
|
-
createKey(subject: SubjectId, ctx: KmsContext): Promise<void>;
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Erases the key material immediately; the tombstone row stays for the
|
|
62
|
-
* audit trail. Idempotent — repeat calls and unknown subjects are no-ops.
|
|
63
|
-
*/
|
|
64
|
-
eraseKey(subject: SubjectId, ctx: KmsContext): Promise<void>;
|
|
65
|
-
|
|
66
|
-
/** Probe for boot + readiness. Throws when the backend is unreachable. */
|
|
67
|
-
health(): Promise<KmsHealth>;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// Backends that hand out the plaintext DEK (Pg, InMemory). Encrypt/decrypt
|
|
71
|
-
// happens locally; DEKs are cacheable per request.
|
|
72
|
-
export interface LocalKeyKmsAdapter extends KmsAdapterBase {
|
|
73
|
-
readonly capabilities: { readonly mode: "local-key" };
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Throws KeyErasedError after eraseKey (callers render "[[erased]]"),
|
|
77
|
-
* KeyNotFoundError when the subject never had a key (typically a bug).
|
|
78
|
-
*/
|
|
79
|
-
getKey(subject: SubjectId, ctx: KmsContext): Promise<SubjectDek>;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// Backends that never release key material (Vault transit, cloud KMS).
|
|
83
|
-
// Every encrypt/decrypt is a round-trip; nothing is cacheable.
|
|
84
|
-
export interface RemoteCryptoKmsAdapter extends KmsAdapterBase {
|
|
85
|
-
readonly capabilities: { readonly mode: "remote-crypto" };
|
|
86
|
-
|
|
87
|
-
encrypt(subject: SubjectId, plaintext: Uint8Array, ctx: KmsContext): Promise<Uint8Array>;
|
|
88
|
-
|
|
89
|
-
/** Same error contract as LocalKeyKmsAdapter.getKey. */
|
|
90
|
-
decrypt(subject: SubjectId, ciphertext: Uint8Array, ctx: KmsContext): Promise<Uint8Array>;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export type KmsAdapter = LocalKeyKmsAdapter | RemoteCryptoKmsAdapter;
|
|
94
|
-
|
|
95
|
-
export function isLocalKeyKmsAdapter(adapter: KmsAdapter): adapter is LocalKeyKmsAdapter {
|
|
96
|
-
return adapter.capabilities.mode === "local-key";
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export class KeyErasedError extends Error {
|
|
100
|
-
constructor(public readonly subject: SubjectId) {
|
|
101
|
-
super(`Subject key erased: ${subjectIdToKey(subject)}`);
|
|
102
|
-
this.name = "KeyErasedError";
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export class KeyNotFoundError extends Error {
|
|
107
|
-
constructor(public readonly subject: SubjectId) {
|
|
108
|
-
super(`Subject key not found: ${subjectIdToKey(subject)}`);
|
|
109
|
-
this.name = "KeyNotFoundError";
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export class KeyAlreadyExistsError extends Error {
|
|
114
|
-
constructor(public readonly subject: SubjectId) {
|
|
115
|
-
super(`Subject key already exists: ${subjectIdToKey(subject)}`);
|
|
116
|
-
this.name = "KeyAlreadyExistsError";
|
|
117
|
-
}
|
|
118
|
-
}
|
|
1
|
+
// Legacy path — re-exported for callers still importing this module directly.
|
|
2
|
+
export * from "@cosmicdrift/kumiko-types/kms-adapter-types";
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { buildFilterWhere } from "../event-store-executor-context";
|
|
3
|
+
|
|
4
|
+
describe("buildFilterWhere", () => {
|
|
5
|
+
test("eq: returns a direct field-equality WhereObject", () => {
|
|
6
|
+
expect(buildFilterWhere("status", "eq", "active")).toEqual({ status: "active" });
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("ne: wraps the value in a { ne } clause", () => {
|
|
10
|
+
expect(buildFilterWhere("status", "ne", "active")).toEqual({ status: { ne: "active" } });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("lt: wraps the value in a { lt } clause", () => {
|
|
14
|
+
expect(buildFilterWhere("createdAt", "lt", 100)).toEqual({ createdAt: { lt: 100 } });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("gt: wraps the value in a { gt } clause", () => {
|
|
18
|
+
expect(buildFilterWhere("createdAt", "gt", 100)).toEqual({ createdAt: { gt: 100 } });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("in: non-empty array → direct array WhereObject", () => {
|
|
22
|
+
expect(buildFilterWhere("status", "in", ["active", "pending"])).toEqual({
|
|
23
|
+
status: ["active", "pending"],
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("in: empty array → null (no-match short-circuit)", () => {
|
|
28
|
+
expect(buildFilterWhere("status", "in", [])).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("in: non-array value → null (no-match short-circuit)", () => {
|
|
32
|
+
expect(buildFilterWhere("status", "in", "not-an-array")).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -9,6 +9,7 @@ import { asRawClient } from "../../db/query";
|
|
|
9
9
|
import { createEntity, createTextField } from "../../engine";
|
|
10
10
|
import { from } from "../../engine/ownership";
|
|
11
11
|
import { createEventsTable } from "../../event-store";
|
|
12
|
+
import type { EntityCache } from "../../pipeline/entity-cache";
|
|
12
13
|
import { createTestDb, type TestDb, TestUsers, unsafeCreateEntityTable } from "../../stack";
|
|
13
14
|
import { createEventStoreExecutor } from "../event-store-executor";
|
|
14
15
|
import { buildEntityTable } from "../table-builder";
|
|
@@ -303,3 +304,93 @@ async function unsafeCreateEntityTableFor(
|
|
|
303
304
|
): Promise<void> {
|
|
304
305
|
await unsafeCreateEntityTable(testDb.db, entity, name);
|
|
305
306
|
}
|
|
307
|
+
|
|
308
|
+
// =============================================================================
|
|
309
|
+
// Concurrent update race → EventStoreVersionConflict catch + entityCache.del
|
|
310
|
+
// on forget/restore (create/update/delete already exercise cache in the
|
|
311
|
+
// main suite; forget/restore del() stayed uncovered).
|
|
312
|
+
// =============================================================================
|
|
313
|
+
|
|
314
|
+
const raceEntity = createEntity({
|
|
315
|
+
table: "read_es_write_race",
|
|
316
|
+
fields: {
|
|
317
|
+
email: createTextField({ required: true }),
|
|
318
|
+
},
|
|
319
|
+
softDelete: true,
|
|
320
|
+
});
|
|
321
|
+
const raceTable = buildEntityTable("esWriteRace", raceEntity);
|
|
322
|
+
|
|
323
|
+
describe("event-store-executor write-verbs — concurrent version race + cache", () => {
|
|
324
|
+
const store = new Map<string, Record<string, unknown>>();
|
|
325
|
+
const entityCache: EntityCache = {
|
|
326
|
+
get: async (tenantId, name, id) => store.get(`${tenantId}:${name}:${id}`) ?? null,
|
|
327
|
+
mget: async () => new Map(),
|
|
328
|
+
set: async (tenantId, name, id, data) => {
|
|
329
|
+
store.set(`${tenantId}:${name}:${id}`, data);
|
|
330
|
+
},
|
|
331
|
+
mset: async (tenantId, name, entries) => {
|
|
332
|
+
for (const { id, data } of entries) store.set(`${tenantId}:${name}:${id}`, data);
|
|
333
|
+
},
|
|
334
|
+
del: async (tenantId, name, id) => {
|
|
335
|
+
store.delete(`${tenantId}:${name}:${id}`);
|
|
336
|
+
},
|
|
337
|
+
};
|
|
338
|
+
const crud = createEventStoreExecutor(raceTable, raceEntity, {
|
|
339
|
+
entityName: "esWriteRace",
|
|
340
|
+
entityCache,
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
beforeAll(async () => {
|
|
344
|
+
await unsafeCreateEntityTableFor(raceEntity, "esWriteRace");
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
beforeEach(async () => {
|
|
348
|
+
store.clear();
|
|
349
|
+
await asRawClient(testDb.db).unsafe(
|
|
350
|
+
`TRUNCATE kumiko_events, read_es_write_race RESTART IDENTITY CASCADE`,
|
|
351
|
+
);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test("two concurrent updates with the same version → one wins, one version_conflict", async () => {
|
|
355
|
+
const created = await crud.create({ email: "race@test.de" }, admin, tdb);
|
|
356
|
+
if (!created.isSuccess) throw new Error("setup failed");
|
|
357
|
+
const id = created.data.id;
|
|
358
|
+
|
|
359
|
+
const [a, b] = await Promise.all([
|
|
360
|
+
crud.update({ id, version: 1, changes: { email: "a@test.de" } }, admin, tdb),
|
|
361
|
+
crud.update({ id, version: 1, changes: { email: "b@test.de" } }, admin, tdb),
|
|
362
|
+
]);
|
|
363
|
+
|
|
364
|
+
const results = [a, b];
|
|
365
|
+
expect(results.filter((r) => r.isSuccess)).toHaveLength(1);
|
|
366
|
+
expect(results.filter((r) => !r.isSuccess && r.error.code === "version_conflict")).toHaveLength(
|
|
367
|
+
1,
|
|
368
|
+
);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test("forget with entityCache clears the cache entry", async () => {
|
|
372
|
+
const created = await crud.create({ email: "cache-forget@test.de" }, admin, tdb);
|
|
373
|
+
if (!created.isSuccess) throw new Error("setup failed");
|
|
374
|
+
const id = created.data.id;
|
|
375
|
+
const key = `${admin.tenantId}:esWriteRace:${id}`;
|
|
376
|
+
store.set(key, { email: "poison" });
|
|
377
|
+
|
|
378
|
+
const result = await crud.forget({ id }, admin, tdb);
|
|
379
|
+
expect(result.isSuccess).toBe(true);
|
|
380
|
+
expect(store.has(key)).toBe(false);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test("restore with entityCache clears the cache entry", async () => {
|
|
384
|
+
const created = await crud.create({ email: "cache-restore@test.de" }, admin, tdb);
|
|
385
|
+
if (!created.isSuccess) throw new Error("setup failed");
|
|
386
|
+
const id = created.data.id;
|
|
387
|
+
await crud.delete({ id }, admin, tdb);
|
|
388
|
+
|
|
389
|
+
const key = `${admin.tenantId}:esWriteRace:${id}`;
|
|
390
|
+
store.set(key, { email: "poison" });
|
|
391
|
+
|
|
392
|
+
const result = await crud.restore({ id }, admin, tdb);
|
|
393
|
+
expect(result.isSuccess).toBe(true);
|
|
394
|
+
expect(store.has(key)).toBe(false);
|
|
395
|
+
});
|
|
396
|
+
});
|
package/src/db/cursor.ts
CHANGED
|
@@ -1,21 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
export type CursorQueryOptions = {
|
|
4
|
-
tenantId: TenantId;
|
|
5
|
-
cursor?: string;
|
|
6
|
-
limit?: number;
|
|
7
|
-
filterIds?: readonly EntityId[];
|
|
8
|
-
sort?: string;
|
|
9
|
-
sortDirection?: "asc" | "desc";
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
export type CursorResult<T> = {
|
|
13
|
-
rows: T[];
|
|
14
|
-
nextCursor: string | null;
|
|
15
|
-
/** Optional total row count — nur present wenn der Caller `totalCount: true`
|
|
16
|
-
* in der Query setzt. */
|
|
17
|
-
total?: number;
|
|
18
|
-
};
|
|
1
|
+
export type { CursorQueryOptions, CursorResult } from "@cosmicdrift/kumiko-types/cursor-types";
|
|
19
2
|
|
|
20
3
|
// String-basiert damit UUIDs (Default seit Sprint F) + Integer-Auto-Increment
|
|
21
4
|
// durch denselben Cursor-Pfad laufen. UUIDv7 erfüllt die lex-Monotonie
|
package/src/db/dialect.ts
CHANGED
|
@@ -13,6 +13,12 @@
|
|
|
13
13
|
// The framework no longer imports drizzle-orm at runtime — schema-files
|
|
14
14
|
// use only this module.
|
|
15
15
|
|
|
16
|
+
import {
|
|
17
|
+
type ColumnHandle,
|
|
18
|
+
KUMIKO_COLUMNS_SYMBOL,
|
|
19
|
+
KUMIKO_NAME_SYMBOL,
|
|
20
|
+
type SchemaTable,
|
|
21
|
+
} from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
16
22
|
import type {
|
|
17
23
|
ColumnMeta,
|
|
18
24
|
CompositePrimaryKeyMeta,
|
|
@@ -21,6 +27,8 @@ import type {
|
|
|
21
27
|
PgType,
|
|
22
28
|
} from "./entity-table-meta";
|
|
23
29
|
|
|
30
|
+
export type { ColumnHandle, SchemaTable } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
31
|
+
|
|
24
32
|
// Public type aliases — historical compat for callers that used to import
|
|
25
33
|
// these from drizzle-orm/pg-core. SelectQuery is no longer a meaningful
|
|
26
34
|
// shape (no chain builder); TableColumns is the new SchemaTable union.
|
|
@@ -29,31 +37,12 @@ export type TableColumns<_T = any> = SchemaTable;
|
|
|
29
37
|
// biome-ignore lint/suspicious/noExplicitAny: legacy type — chain API is gone
|
|
30
38
|
export type SelectQuery = any;
|
|
31
39
|
|
|
32
|
-
// Column handle exposed on the SchemaTable. The `name` is the SQL column
|
|
33
|
-
// name (snake_case); legacy code accesses `table.fieldName.name` to
|
|
34
|
-
// produce raw SQL.
|
|
35
|
-
export type ColumnHandle = {
|
|
36
|
-
readonly name: string;
|
|
37
|
-
readonly pgType: PgType;
|
|
38
|
-
readonly getSQLType: () => string;
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
const KUMIKO_NAME_SYMBOL = Symbol.for("kumiko:schema:Name");
|
|
42
|
-
const KUMIKO_COLUMNS_SYMBOL = Symbol.for("kumiko:schema:Columns");
|
|
43
40
|
// Shadow-proof handle on the EntityTableMeta. The column handles below are
|
|
44
41
|
// spread as enumerable props, so an entity field named `source`/`columns`/
|
|
45
42
|
// `tableName`/… would overwrite the matching meta key. extractTableInfo reads
|
|
46
43
|
// the canonical meta from this symbol instead of the (shadowable) props.
|
|
47
44
|
const KUMIKO_META_SYMBOL = Symbol.for("kumiko:schema:Meta");
|
|
48
45
|
|
|
49
|
-
// SchemaTable — opaque shape with both EntityTableMeta + Symbol-based
|
|
50
|
-
// introspection. Returned by `table(...)`.
|
|
51
|
-
export type SchemaTable = EntityTableMeta & {
|
|
52
|
-
readonly [KUMIKO_NAME_SYMBOL]: string;
|
|
53
|
-
readonly [KUMIKO_COLUMNS_SYMBOL]: Record<string, ColumnHandle>;
|
|
54
|
-
readonly [field: string]: unknown;
|
|
55
|
-
};
|
|
56
|
-
|
|
57
46
|
function isNumericPgType(t: PgType): t is `numeric(${number},${number})` {
|
|
58
47
|
return t.startsWith("numeric(");
|
|
59
48
|
}
|
|
@@ -1,92 +1,2 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
3
|
-
// entity-table-meta.ts. Prep step for the types-only package extraction
|
|
4
|
-
// (#1283) — this file must have ONLY `import type`, no value imports
|
|
5
|
-
// (crypto/DB deps).
|
|
6
|
-
|
|
7
|
-
import type { EntityRelations } from "../engine/types";
|
|
8
|
-
|
|
9
|
-
// PG type repertoire the read-model tables need. Deliberately narrow — no
|
|
10
|
-
// vendor-specific types (TSVECTOR, HSTORE, etc.). An app-author who needs
|
|
11
|
-
// those reaches into the reviewed SQL migration by hand, not the generator.
|
|
12
|
-
export type PgType =
|
|
13
|
-
| "uuid"
|
|
14
|
-
| "text"
|
|
15
|
-
| "boolean"
|
|
16
|
-
| "integer"
|
|
17
|
-
| "double precision"
|
|
18
|
-
| "bigint"
|
|
19
|
-
| "serial"
|
|
20
|
-
| "bigserial"
|
|
21
|
-
| "jsonb"
|
|
22
|
-
| "timestamptz"
|
|
23
|
-
| "timestamptz(3)"
|
|
24
|
-
// Exact decimal — precision/scale are encoded in the type string so the
|
|
25
|
-
// DDL renderer and read-coercion need no side-channel metadata.
|
|
26
|
-
| `numeric(${number},${number})`;
|
|
27
|
-
|
|
28
|
-
export type ColumnMeta = {
|
|
29
|
-
readonly name: string; // snake_case PG column name
|
|
30
|
-
readonly pgType: PgType;
|
|
31
|
-
readonly notNull: boolean;
|
|
32
|
-
// Raw SQL-default-expression (e.g. `now()`, `gen_random_uuid()`,
|
|
33
|
-
// `'[]'::jsonb`). undefined = no DEFAULT clause.
|
|
34
|
-
readonly defaultSql?: string;
|
|
35
|
-
readonly primaryKey?: boolean;
|
|
36
|
-
readonly identity?: boolean;
|
|
37
|
-
// bigint/bigserial only: JS round-trip mode. `number` = createBigIntField /
|
|
38
|
-
// drizzle mode:"number" (safe ≤2^53). `bigint` = money cents, raw unmanaged.
|
|
39
|
-
readonly bigintJsMode?: "number" | "bigint";
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
export type IndexMeta = {
|
|
43
|
-
readonly name: string;
|
|
44
|
-
readonly columns: readonly string[]; // snake_case PG column names
|
|
45
|
-
readonly unique?: boolean;
|
|
46
|
-
// Raw SQL-where-expression for partial indexes. Caller is responsible
|
|
47
|
-
// for safety — emitted verbatim.
|
|
48
|
-
readonly whereSql?: string;
|
|
49
|
-
// Set when the EntityDefinition has a partial index (def.where as a
|
|
50
|
-
// drizzle SQL AST) the generator can't reliably render. The renderer
|
|
51
|
-
// emits the statement COMMENTED OUT with a warning hint — the app-author
|
|
52
|
-
// has to add the WHERE manually in the generated SQL.
|
|
53
|
-
readonly needsManualWhere?: boolean;
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
export type CompositePrimaryKeyMeta = {
|
|
57
|
-
readonly name: string;
|
|
58
|
-
readonly columns: readonly string[];
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
export type EntityTableMeta = {
|
|
62
|
-
readonly tableName: string;
|
|
63
|
-
readonly columns: readonly ColumnMeta[];
|
|
64
|
-
readonly indexes: readonly IndexMeta[];
|
|
65
|
-
// For tables with composite PK (no single id column, e.g. snapshots
|
|
66
|
-
// keyed by aggregate_id+version). When set, no column should have
|
|
67
|
-
// primaryKey:true; the constraint is emitted at table-level.
|
|
68
|
-
readonly compositePrimaryKey?: CompositePrimaryKeyMeta;
|
|
69
|
-
// Source hint for diagnostics/tests — not used functionally.
|
|
70
|
-
// "managed" = from EntityDefinition (with base-columns + audit trail).
|
|
71
|
-
// "unmanaged" = via defineUnmanagedTable — no standard audit, the app
|
|
72
|
-
// carries the responsibility. Migration-generator + tooling can use the
|
|
73
|
-
// discriminator to render warnings ("X tables are unmanaged").
|
|
74
|
-
readonly source: "managed" | "unmanaged";
|
|
75
|
-
// PII-subject-annotated field names (pii/userOwned/tenantOwned). Set by
|
|
76
|
-
// buildEntityTableMeta so the registry can reject r.storeTable stores
|
|
77
|
-
// whose direct writes would skip the executor's encryption (#820).
|
|
78
|
-
readonly piiSubjectFields?: readonly string[];
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
export type BuildEntityTableMetaOptions = {
|
|
82
|
-
readonly featureName?: string;
|
|
83
|
-
readonly relations?: EntityRelations;
|
|
84
|
-
readonly source?: "managed" | "unmanaged";
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
export type UnmanagedTableInput = {
|
|
88
|
-
readonly tableName: string;
|
|
89
|
-
readonly columns: readonly ColumnMeta[];
|
|
90
|
-
readonly indexes?: readonly IndexMeta[];
|
|
91
|
-
readonly compositePrimaryKey?: CompositePrimaryKeyMeta;
|
|
92
|
-
};
|
|
1
|
+
// Legacy path — re-exported for callers still importing this module directly.
|
|
2
|
+
export * from "@cosmicdrift/kumiko-types/entity-table-meta-types";
|