@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
|
@@ -115,6 +115,11 @@ export function collectKnownRoles(features: readonly FeatureDefinition[]): Set<s
|
|
|
115
115
|
for (const r of def.access.roles) roles.add(r);
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
|
+
for (const def of Object.values(f.streamHandlers)) {
|
|
119
|
+
if (def.access && "roles" in def.access) {
|
|
120
|
+
for (const r of def.access.roles) roles.add(r);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
118
123
|
}
|
|
119
124
|
return roles;
|
|
120
125
|
}
|
package/src/engine/constants.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import type { ConcurrencyMode } from "@cosmicdrift/kumiko-types/concurrency-mode";
|
|
2
|
+
import type { ConfigScope } from "@cosmicdrift/kumiko-types/config-scope";
|
|
3
|
+
import type { OnDeleteStrategy as OnDeleteStrategyType } from "@cosmicdrift/kumiko-types/relations";
|
|
1
4
|
import type { TenantId } from "./types/identifiers";
|
|
2
5
|
|
|
3
6
|
// All framework constants as `as const` objects with inferred union types.
|
|
@@ -47,14 +50,23 @@ export const LifecycleHookTypes = {
|
|
|
47
50
|
export type LifecycleHookType = (typeof LifecycleHookTypes)[keyof typeof LifecycleHookTypes];
|
|
48
51
|
|
|
49
52
|
// --- Config Scopes ---
|
|
53
|
+
// Value object satisfies the canonical ConfigScope union from kumiko-types —
|
|
54
|
+
// a scope added here without a matching kumiko-types member is now a
|
|
55
|
+
// compile error instead of silent drift (#1423/#1439).
|
|
50
56
|
|
|
51
57
|
export const ConfigScopes = {
|
|
52
58
|
system: "system",
|
|
53
59
|
tenant: "tenant",
|
|
54
60
|
user: "user",
|
|
55
|
-
} as const
|
|
61
|
+
} as const satisfies Record<string, ConfigScope>;
|
|
62
|
+
|
|
63
|
+
export type { ConfigScope };
|
|
56
64
|
|
|
57
|
-
|
|
65
|
+
// Reverse direction: a member added only to kumiko-types' ConfigScope
|
|
66
|
+
// (not to ConfigScopes above) would otherwise pass the `satisfies` check
|
|
67
|
+
// above unnoticed — this line forces exhaustiveness the other way too.
|
|
68
|
+
const _configScopeExhaustive: Record<ConfigScope, unknown> = ConfigScopes;
|
|
69
|
+
void _configScopeExhaustive;
|
|
58
70
|
|
|
59
71
|
// --- On Delete Strategies ---
|
|
60
72
|
|
|
@@ -63,11 +75,13 @@ export const OnDeleteStrategies = {
|
|
|
63
75
|
restrict: "restrict",
|
|
64
76
|
setNull: "setNull",
|
|
65
77
|
nothing: "nothing",
|
|
66
|
-
} as const
|
|
78
|
+
} as const satisfies Record<OnDeleteStrategyType, OnDeleteStrategyType>;
|
|
67
79
|
|
|
68
|
-
export type OnDeleteStrategy =
|
|
80
|
+
export type OnDeleteStrategy = OnDeleteStrategyType;
|
|
69
81
|
|
|
70
82
|
// --- Concurrency Modes ---
|
|
83
|
+
// Value object satisfies the canonical ConcurrencyMode union from
|
|
84
|
+
// kumiko-types — same drift-guard as ConfigScopes above.
|
|
71
85
|
|
|
72
86
|
export const ConcurrencyModes = {
|
|
73
87
|
parallel: "parallel",
|
|
@@ -75,12 +89,20 @@ export const ConcurrencyModes = {
|
|
|
75
89
|
replace: "replace",
|
|
76
90
|
sequential: "sequential",
|
|
77
91
|
debounce: "debounce",
|
|
78
|
-
} as const
|
|
92
|
+
} as const satisfies Record<string, ConcurrencyMode>;
|
|
79
93
|
|
|
80
|
-
export type ConcurrencyMode
|
|
94
|
+
export type { ConcurrencyMode };
|
|
95
|
+
|
|
96
|
+
// Reverse-direction exhaustiveness guard — see the ConfigScope one above.
|
|
97
|
+
const _concurrencyModeExhaustive: Record<ConcurrencyMode, unknown> = ConcurrencyModes;
|
|
98
|
+
void _concurrencyModeExhaustive;
|
|
81
99
|
|
|
82
100
|
// --- SSE Channels ---
|
|
83
101
|
|
|
84
102
|
export function tenantChannel(tenantId: TenantId): string {
|
|
85
103
|
return `tenant:${tenantId}`;
|
|
86
104
|
}
|
|
105
|
+
|
|
106
|
+
export function userAccessChannel(userId: string): string {
|
|
107
|
+
return `user:${userId}:access`;
|
|
108
|
+
}
|
package/src/engine/create-app.ts
CHANGED
|
@@ -53,6 +53,17 @@ export function createApp(config: AppConfig): App {
|
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
|
+
for (const handler of Object.values(feature.streamHandlers)) {
|
|
57
|
+
if (handler.access && "roles" in handler.access) {
|
|
58
|
+
for (const role of handler.access.roles) {
|
|
59
|
+
if (!validRoles.has(role)) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`Unknown role "${role}" in stream handler "${handler.name}" of feature "${feature.name}". Valid roles: ${config.roles.join(", ")}`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
56
67
|
for (const [key, keyDef] of Object.entries(feature.configKeys)) {
|
|
57
68
|
for (const role of [...keyDef.access.read, ...keyDef.access.write]) {
|
|
58
69
|
if (!systemRoles.has(role) && !validRoles.has(role)) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Registry } from "./types";
|
|
1
|
+
import type { FeatureDefinition, Registry } from "./types";
|
|
2
2
|
|
|
3
3
|
// Callback that returns the current global-toggle override for a feature.
|
|
4
4
|
// `true` = explicit global row says enabled.
|
|
@@ -11,6 +11,16 @@ import type { Registry } from "./types";
|
|
|
11
11
|
// a Map lookup to keep compute() allocation-light.
|
|
12
12
|
export type ToggleReader = (featureName: string) => boolean | undefined;
|
|
13
13
|
|
|
14
|
+
// A feature is toggleable iff it declared r.toggleable() at define-time
|
|
15
|
+
// (toggleableDefault set). Non-toggleable features are always-on and ignore
|
|
16
|
+
// overrides entirely — used here, by compose-tier-resolver, and by
|
|
17
|
+
// tier-engine's always-on set to agree on the same rule.
|
|
18
|
+
export function isToggleableFeature(
|
|
19
|
+
feature: FeatureDefinition,
|
|
20
|
+
): feature is FeatureDefinition & { toggleableDefault: boolean } {
|
|
21
|
+
return feature.toggleableDefault !== undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
14
24
|
// Compute the set of effectively-enabled features for the current call.
|
|
15
25
|
//
|
|
16
26
|
// Rules (AND-combined, any false wins):
|
|
@@ -35,7 +45,7 @@ export function computeEffectiveFeatures(
|
|
|
35
45
|
// Raw enablement, before cascade.
|
|
36
46
|
const raw = new Map<string, boolean>();
|
|
37
47
|
for (const feature of registry.features.values()) {
|
|
38
|
-
if (feature
|
|
48
|
+
if (!isToggleableFeature(feature)) {
|
|
39
49
|
raw.set(feature.name, true);
|
|
40
50
|
continue;
|
|
41
51
|
}
|
|
@@ -150,14 +150,22 @@ export type UserDataExportHook = (ctx: UserDataHookCtx) => Promise<UserDataExpor
|
|
|
150
150
|
* `{status:"incomplete", reason}` to signal a partial success to the
|
|
151
151
|
* cleanup runner (e.g. an external provider call failed) without
|
|
152
152
|
* throwing/rolling back the sub-transaction. Existing `void` returners
|
|
153
|
-
* stay valid unchanged
|
|
153
|
+
* stay valid unchanged — including ones explicitly typed
|
|
154
|
+
* `Promise<void>` (an explicitly-annotated hook, unlike a contextually-
|
|
155
|
+
* typed arrow literal, needs `void` in the union itself: `void` is not
|
|
156
|
+
* assignable to `undefined`).
|
|
154
157
|
*/
|
|
158
|
+
// Intentional: void (not undefined) is required here so an explicitly-typed
|
|
159
|
+
// `Promise<void>` hook stays assignable (see UserDataDeleteHook's doc
|
|
160
|
+
// comment above).
|
|
161
|
+
export type UserDataDeleteHookResult =
|
|
162
|
+
// biome-ignore lint/suspicious/noConfusingVoidType: see comment above
|
|
163
|
+
void | { readonly status: "ok" } | { readonly status: "incomplete"; readonly reason: string };
|
|
164
|
+
|
|
155
165
|
export type UserDataDeleteHook = (
|
|
156
166
|
ctx: UserDataHookCtx,
|
|
157
167
|
strategy: UserDataDeleteStrategy,
|
|
158
|
-
) => Promise<
|
|
159
|
-
undefined | { readonly status: "ok" } | { readonly status: "incomplete"; readonly reason: string }
|
|
160
|
-
>;
|
|
168
|
+
) => Promise<UserDataDeleteHookResult>;
|
|
161
169
|
|
|
162
170
|
/**
|
|
163
171
|
* Komplette Hook-Tafel für EXT_USER_DATA. Sprint 2 user-data-rights
|
|
@@ -100,7 +100,7 @@ export function extractStoreTable(
|
|
|
100
100
|
sourceFile: SourceFile,
|
|
101
101
|
): ExtractOutput<never> {
|
|
102
102
|
// The meta argument is always a factory call (defineUnmanagedTable /
|
|
103
|
-
//
|
|
103
|
+
// deriveEntityTableMeta) or a captured identifier — never an inline literal,
|
|
104
104
|
// so there is nothing to extract statically. A clean ParseError (not
|
|
105
105
|
// UnknownPattern) marks it design-time-unreadable, like entity-by-identifier.
|
|
106
106
|
return fail(
|
|
@@ -426,14 +426,14 @@ export function buildUiExtensionsMethods<TName extends string>(
|
|
|
426
426
|
// EntityTableMeta uses snake_case for tableName (matches Postgres
|
|
427
427
|
// convention); we just guard against truly broken input.
|
|
428
428
|
throw new Error(
|
|
429
|
-
`[Feature ${name}]
|
|
429
|
+
`[Feature ${name}] Store-table name "${tableName}" must be a ` +
|
|
430
430
|
`valid identifier (lowercase letters, digits, underscores; start with a letter).`,
|
|
431
431
|
);
|
|
432
432
|
}
|
|
433
433
|
if (state.storeTables[tableName]) {
|
|
434
434
|
throw new Error(
|
|
435
435
|
`[Feature ${name}] r.storeTable("${tableName}") already registered. ` +
|
|
436
|
-
`
|
|
436
|
+
`Store-table names must be unique per feature.`,
|
|
437
437
|
);
|
|
438
438
|
}
|
|
439
439
|
// `read_` is reserved for r.entity()/r.projection() (managed,
|
|
@@ -454,7 +454,7 @@ export function buildUiExtensionsMethods<TName extends string>(
|
|
|
454
454
|
throw new Error(
|
|
455
455
|
`[Feature ${name}] r.storeTable("${tableName}") was given an EntityTableMeta with ` +
|
|
456
456
|
`source: "${meta.source}". r.storeTable() requires source: "unmanaged" (via ` +
|
|
457
|
-
`defineUnmanagedTable(), or
|
|
457
|
+
`defineUnmanagedTable(), or deriveEntityTableMeta(..., { source: "unmanaged" })) — ` +
|
|
458
458
|
`otherwise the migration generator will treat schema drift on this table as safe ` +
|
|
459
459
|
`to DROP+rebuild, wiping any direct-write data.`,
|
|
460
460
|
);
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
// are logged but don't affect the write. Use for: external systems
|
|
9
9
|
// (SSE broadcast, search index, email, webhooks).
|
|
10
10
|
|
|
11
|
+
import type { HookPhase } from "@cosmicdrift/kumiko-types/hook-phase";
|
|
12
|
+
|
|
11
13
|
export const HookPhases = {
|
|
12
14
|
inTransaction: "inTransaction",
|
|
13
15
|
afterCommit: "afterCommit",
|
|
14
|
-
} as const
|
|
16
|
+
} as const satisfies Record<HookPhase, HookPhase>;
|
package/src/engine/index.ts
CHANGED
|
@@ -52,7 +52,7 @@ export { defineStep, getStep, listStepKinds } from "./define-step";
|
|
|
52
52
|
export type { WorkflowDefinition, WorkflowInput, WorkflowTrigger } from "./define-workflow";
|
|
53
53
|
export { computeDefinitionFingerprint, defineWorkflow } from "./define-workflow";
|
|
54
54
|
export type { ToggleReader } from "./effective-features";
|
|
55
|
-
export { computeEffectiveFeatures } from "./effective-features";
|
|
55
|
+
export { computeEffectiveFeatures, isToggleableFeature } from "./effective-features";
|
|
56
56
|
export {
|
|
57
57
|
createEntityExecutor,
|
|
58
58
|
defineEntityCreateHandler,
|
package/src/engine/ownership.ts
CHANGED
|
@@ -17,6 +17,10 @@
|
|
|
17
17
|
// Construction: use the `from(ref, column?)` helper. It returns a FromRule
|
|
18
18
|
// ready to drop into an access map.
|
|
19
19
|
|
|
20
|
+
import {
|
|
21
|
+
KUMIKO_COLUMNS_SYMBOL,
|
|
22
|
+
KUMIKO_NAME_SYMBOL,
|
|
23
|
+
} from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
20
24
|
import { toSnakeCase } from "../db/table-builder";
|
|
21
25
|
import type { SessionUser } from "./types";
|
|
22
26
|
|
|
@@ -221,9 +225,6 @@ export function userCanCreateFieldRow(
|
|
|
221
225
|
const PASS_CLAUSE: OwnershipClause = { kind: "pass" };
|
|
222
226
|
const EMPTY_CLAUSE: OwnershipClause = { kind: "empty" };
|
|
223
227
|
|
|
224
|
-
const KUMIKO_NAME_SYMBOL = Symbol.for("kumiko:schema:Name");
|
|
225
|
-
const KUMIKO_COLUMNS_SYMBOL = Symbol.for("kumiko:schema:Columns");
|
|
226
|
-
|
|
227
228
|
function tableNameOf(table: unknown): string {
|
|
228
229
|
if (table !== null && typeof table === "object") {
|
|
229
230
|
const sym = (table as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL];
|
|
@@ -20,7 +20,7 @@ export function populateFeatureCore(state: RegistryState, feature: FeatureDefini
|
|
|
20
20
|
state.entityMap.set(name, entity);
|
|
21
21
|
const physical = resolveTableName(name, entity, feature.name);
|
|
22
22
|
const clash = state.physicalTableOwners.get(physical);
|
|
23
|
-
if (clash?.kind === "
|
|
23
|
+
if (clash?.kind === "store") {
|
|
24
24
|
throw new Error(
|
|
25
25
|
`Entity "${name}" (feature "${feature.name}") has physical table "${physical}" which ` +
|
|
26
26
|
`collides with r.storeTable("${physical}") (feature "${clash.featureName}"). ` +
|
|
@@ -317,37 +317,37 @@ export function populateProjectionsAndTables(
|
|
|
317
317
|
// statements that target the same physical table name. Two features
|
|
318
318
|
// registering the same physical tableName would also race two CREATE
|
|
319
319
|
// TABLE statements via migrate-runner.
|
|
320
|
-
for (const [
|
|
321
|
-
const existing = state.storeTableMap.get(
|
|
320
|
+
for (const [storeName, storeDef] of Object.entries(feature.storeTables ?? {})) {
|
|
321
|
+
const existing = state.storeTableMap.get(storeName);
|
|
322
322
|
if (existing) {
|
|
323
323
|
throw new Error(
|
|
324
|
-
`
|
|
324
|
+
`Store-table "${storeName}" registered by both feature "${existing.featureName}" and ` +
|
|
325
325
|
`"${feature.name}". Pick a feature-prefixed name to disambiguate.`,
|
|
326
326
|
);
|
|
327
327
|
}
|
|
328
|
-
const physicalClash = state.physicalTableOwners.get(
|
|
328
|
+
const physicalClash = state.physicalTableOwners.get(storeName);
|
|
329
329
|
if (physicalClash?.kind === "entity") {
|
|
330
330
|
throw new Error(
|
|
331
|
-
`
|
|
331
|
+
`Store-table "${storeName}" (feature "${feature.name}") collides with the physical ` +
|
|
332
332
|
`table of entity "${physicalClash.owner}" (feature "${physicalClash.featureName}"). ` +
|
|
333
|
-
`Pick a different tableName — both would emit CREATE TABLE "${
|
|
333
|
+
`Pick a different tableName — both would emit CREATE TABLE "${storeName}".`,
|
|
334
334
|
);
|
|
335
335
|
}
|
|
336
|
-
const piiFields =
|
|
337
|
-
if (piiFields.length > 0 && !
|
|
336
|
+
const piiFields = storeDef.meta.piiSubjectFields ?? [];
|
|
337
|
+
if (piiFields.length > 0 && !storeDef.piiEncryptedOnWrite) {
|
|
338
338
|
throw new Error(
|
|
339
|
-
`
|
|
339
|
+
`Store-table "${storeName}" (feature "${feature.name}") has PII-annotated fields ` +
|
|
340
340
|
`(${piiFields.join(", ")}) but direct writes bypass the executor's PII encryption. ` +
|
|
341
341
|
`Encrypt those fields before every insert/update (encryptPiiFieldValues) and declare ` +
|
|
342
342
|
`{ piiEncryptedOnWrite: true }, or drop the subject annotations.`,
|
|
343
343
|
);
|
|
344
344
|
}
|
|
345
|
-
state.physicalTableOwners.set(
|
|
346
|
-
kind: "
|
|
347
|
-
owner:
|
|
345
|
+
state.physicalTableOwners.set(storeName, {
|
|
346
|
+
kind: "store",
|
|
347
|
+
owner: storeName,
|
|
348
348
|
featureName: feature.name,
|
|
349
349
|
});
|
|
350
|
-
state.storeTableMap.set(
|
|
350
|
+
state.storeTableMap.set(storeName, { ...storeDef, featureName: feature.name });
|
|
351
351
|
}
|
|
352
352
|
}
|
|
353
353
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { applyEntityEvent } from "../db/apply-entity-event";
|
|
2
|
-
import { assertBackingTableSuperset,
|
|
2
|
+
import { assertBackingTableSuperset, deriveEntityTableMeta } from "../db/entity-table-meta";
|
|
3
3
|
import { asEntityTableMeta } from "../db/query";
|
|
4
4
|
import { buildEntityTable } from "../db/table-builder";
|
|
5
5
|
import { type QnType, qualifyEntityName } from "./qualified-name";
|
|
@@ -143,7 +143,7 @@ function resolveBackingTable(
|
|
|
143
143
|
"EntityTableMeta — build it via table() / buildEntityTable.",
|
|
144
144
|
);
|
|
145
145
|
}
|
|
146
|
-
assertBackingTableSuperset(entityName,
|
|
146
|
+
assertBackingTableSuperset(entityName, deriveEntityTableMeta(entityName, entity), tableMeta);
|
|
147
147
|
return backingTable as ProjectionDefinition["table"];
|
|
148
148
|
}
|
|
149
149
|
|
|
@@ -202,7 +202,10 @@ export type RegistryState = {
|
|
|
202
202
|
multiStreamProjectionMap: Map<string, MultiStreamProjectionDefinition>;
|
|
203
203
|
multiStreamProjectionFeatureMap: Map<string, string>;
|
|
204
204
|
storeTableMap: Map<string, StoreTableDef>;
|
|
205
|
-
physicalTableOwners: Map<
|
|
205
|
+
physicalTableOwners: Map<
|
|
206
|
+
string,
|
|
207
|
+
{ kind: "entity" | "store"; owner: string; featureName: string }
|
|
208
|
+
>;
|
|
206
209
|
authClaimsHooks: AuthClaimsHookDef[];
|
|
207
210
|
claimKeyMap: Map<string, ClaimKeyDefinition>;
|
|
208
211
|
screenMap: Map<string, ScreenDefinition>;
|
|
@@ -97,6 +97,7 @@ export function fieldToZod(field: FieldDefinition, currencies: readonly string[]
|
|
|
97
97
|
let schema = z.number();
|
|
98
98
|
if (field.integer) schema = schema.int();
|
|
99
99
|
if (field.min !== undefined) schema = schema.min(field.min);
|
|
100
|
+
if (field.max !== undefined) schema = schema.max(field.max);
|
|
100
101
|
return field.default !== undefined ? schema.default(field.default) : schema;
|
|
101
102
|
}
|
|
102
103
|
case "decimal": {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// kumiko-framework#1525: addDuration must not rely on globalThis.Temporal —
|
|
2
|
+
// it's a pure free function called from step handlers before boot may have
|
|
3
|
+
// run ensureTemporalPolyfill() on every code path.
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { addDuration } from "../_duration-utils";
|
|
7
|
+
|
|
8
|
+
describe("addDuration — kumiko-framework#1525: no ambient Temporal global", () => {
|
|
9
|
+
test("adds a duration without relying on globalThis.Temporal", () => {
|
|
10
|
+
const savedGlobal = (globalThis as { Temporal?: unknown }).Temporal;
|
|
11
|
+
delete (globalThis as { Temporal?: unknown }).Temporal;
|
|
12
|
+
try {
|
|
13
|
+
const result = addDuration("2026-01-01T00:00:00Z", "P1DT1H");
|
|
14
|
+
expect(result).toBe("2026-01-02T01:00:00Z");
|
|
15
|
+
} finally {
|
|
16
|
+
if (savedGlobal === undefined) delete (globalThis as { Temporal?: unknown }).Temporal;
|
|
17
|
+
else (globalThis as { Temporal?: unknown }).Temporal = savedGlobal;
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// Accepts "P1D", "PT1H", "P1Y2M3DT4H5M6S" etc.
|
|
3
3
|
// Uses approximate calendar math (365d/year, 30d/month). See #23.
|
|
4
4
|
|
|
5
|
+
import { Temporal } from "temporal-polyfill";
|
|
6
|
+
|
|
5
7
|
export function addDuration(baseIso: string, duration: string): string {
|
|
6
8
|
const pattern = /^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/;
|
|
7
9
|
const match = duration.match(pattern);
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// rejected by boot-validation — domain mutation MUST go through
|
|
12
12
|
// r.step.aggregate.*.
|
|
13
13
|
|
|
14
|
+
import { KUMIKO_COLUMNS_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
14
15
|
import { extractTableName } from "../../db";
|
|
15
16
|
import { executeRawQuery } from "../../db/queries/raw-sql";
|
|
16
17
|
import { defineStep } from "../define-step";
|
|
@@ -23,10 +24,6 @@ type UnsafeProjectionUpsertArgs = {
|
|
|
23
24
|
readonly row: StepResolver<Record<string, unknown>>;
|
|
24
25
|
};
|
|
25
26
|
|
|
26
|
-
// @cast-boundary drizzle-bridge — reads column snake_case names from
|
|
27
|
-
// drizzle Symbol-based metadata without importing drizzle-orm.
|
|
28
|
-
const KUMIKO_COLUMNS_SYMBOL = Symbol.for("kumiko:schema:Columns");
|
|
29
|
-
|
|
30
27
|
function resolveColumnName(table: unknown, field: string): string {
|
|
31
28
|
if (typeof table !== "object" || table === null) return field;
|
|
32
29
|
const cols = (table as Record<symbol, unknown>)[KUMIKO_COLUMNS_SYMBOL];
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Legacy path — re-exported for callers still importing this module directly.
|
|
2
|
-
export * from "@cosmicdrift/kumiko-types/config";
|
|
2
|
+
export type * from "@cosmicdrift/kumiko-types/config";
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Legacy path — re-exported for callers still importing this module directly.
|
|
2
|
-
export * from "@cosmicdrift/kumiko-types/define-handler";
|
|
2
|
+
export type * from "@cosmicdrift/kumiko-types/define-handler";
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Legacy path — re-exported for callers still importing this module directly.
|
|
2
|
-
export * from "@cosmicdrift/kumiko-types/entity-handlers";
|
|
2
|
+
export type * from "@cosmicdrift/kumiko-types/entity-handlers";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export * from "@cosmicdrift/kumiko-types/event-type-map";
|
|
1
|
+
export type * from "@cosmicdrift/kumiko-types/event-type-map";
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Legacy path — re-exported for callers still importing this module directly.
|
|
2
|
-
export * from "@cosmicdrift/kumiko-types/feature";
|
|
2
|
+
export type * from "@cosmicdrift/kumiko-types/feature";
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Legacy path — re-exported for callers still importing this module directly.
|
|
2
|
-
export * from "@cosmicdrift/kumiko-types/fields";
|
|
2
|
+
export type * from "@cosmicdrift/kumiko-types/fields";
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Legacy path — re-exported for callers still importing this module directly.
|
|
2
|
-
export * from "@cosmicdrift/kumiko-types/handlers";
|
|
2
|
+
export type * from "@cosmicdrift/kumiko-types/handlers";
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Legacy path — re-exported for callers still importing this module directly.
|
|
2
|
-
export * from "@cosmicdrift/kumiko-types/hooks";
|
|
2
|
+
export type * from "@cosmicdrift/kumiko-types/hooks";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export * from "@cosmicdrift/kumiko-types/http-route";
|
|
1
|
+
export type * from "@cosmicdrift/kumiko-types/http-route";
|
package/src/engine/types/nav.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Legacy path — re-exported for callers still importing this module directly.
|
|
2
|
-
export * from "@cosmicdrift/kumiko-types/nav";
|
|
2
|
+
export type * from "@cosmicdrift/kumiko-types/nav";
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Legacy path — re-exported for callers still importing this module directly.
|
|
2
|
-
export * from "@cosmicdrift/kumiko-types/ownership";
|
|
2
|
+
export type * from "@cosmicdrift/kumiko-types/ownership";
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Legacy path — re-exported for callers still importing this module directly.
|
|
2
|
-
export * from "@cosmicdrift/kumiko-types/projection";
|
|
2
|
+
export type * from "@cosmicdrift/kumiko-types/projection";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export * from "@cosmicdrift/kumiko-types/relations";
|
|
1
|
+
export type * from "@cosmicdrift/kumiko-types/relations";
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Legacy path — re-exported for callers still importing this module directly.
|
|
2
|
-
export * from "@cosmicdrift/kumiko-types/screen";
|
|
2
|
+
export type * from "@cosmicdrift/kumiko-types/screen";
|
package/src/engine/types/step.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Legacy path — re-exported for callers still importing this module directly.
|
|
2
|
-
export * from "@cosmicdrift/kumiko-types/step";
|
|
2
|
+
export type * from "@cosmicdrift/kumiko-types/step";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export * from "@cosmicdrift/kumiko-types/target-ref";
|
|
1
|
+
export type * from "@cosmicdrift/kumiko-types/target-ref";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export * from "@cosmicdrift/kumiko-types/tree-node";
|
|
1
|
+
export type * from "@cosmicdrift/kumiko-types/tree-node";
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Legacy path — re-exported for callers still importing this module directly.
|
|
2
|
-
export * from "@cosmicdrift/kumiko-types/workspace";
|
|
2
|
+
export type * from "@cosmicdrift/kumiko-types/workspace";
|
|
@@ -23,6 +23,10 @@
|
|
|
23
23
|
// caught by this validator. A future lint-rule will enforce the contract
|
|
24
24
|
// statically; today it lives in this comment + the StepBuilder doc.
|
|
25
25
|
|
|
26
|
+
import {
|
|
27
|
+
KUMIKO_META_SYMBOL,
|
|
28
|
+
KUMIKO_NAME_SYMBOL,
|
|
29
|
+
} from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
26
30
|
import { getStep } from "./define-step";
|
|
27
31
|
import { buildPipelineSteps } from "./pipeline";
|
|
28
32
|
import type { FeatureDefinition, SessionUser, TenantId, WriteEvent } from "./types";
|
|
@@ -55,13 +59,9 @@ function* walkAllSteps(steps: readonly StepInstance[]): Generator<StepInstance,
|
|
|
55
59
|
}
|
|
56
60
|
}
|
|
57
61
|
|
|
58
|
-
// @cast-boundary drizzle-bridge — reads table name from a Symbol without
|
|
59
|
-
// importing drizzle-orm (bun-db pattern, see bun-db/query.ts).
|
|
60
|
-
const KUMIKO_NAME_SYMBOL = Symbol.for("kumiko:schema:Name");
|
|
61
62
|
// table()/buildEntityTable spread column handles as enumerable props, so an
|
|
62
63
|
// entity field named `tableName`/`source` would shadow the matching meta key —
|
|
63
|
-
// the canonical meta under
|
|
64
|
-
const KUMIKO_META_SYMBOL = Symbol.for("kumiko:schema:Meta");
|
|
64
|
+
// the canonical meta under KUMIKO_META_SYMBOL is the only collision-safe source.
|
|
65
65
|
|
|
66
66
|
function resolveTableNameFromStep(table: unknown): string {
|
|
67
67
|
if (typeof table === "object" && table !== null) {
|
|
@@ -71,7 +71,7 @@ function resolveTableNameFromStep(table: unknown): string {
|
|
|
71
71
|
const metaName = (meta as Record<string, unknown>)["tableName"];
|
|
72
72
|
if (typeof metaName === "string") return metaName;
|
|
73
73
|
}
|
|
74
|
-
// Plain meta (
|
|
74
|
+
// Plain meta (deriveEntityTableMeta / defineUnmanagedTable — no handle-spread).
|
|
75
75
|
if (
|
|
76
76
|
"source" in table &&
|
|
77
77
|
"tableName" in table &&
|
package/src/errors/classes.ts
CHANGED
|
@@ -138,6 +138,27 @@ export class VersionConflictError extends ConflictError {
|
|
|
138
138
|
}
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
// The caller retried a write with an idempotencyKey it had already used —
|
|
142
|
+
// the event-store's partial unique index on metadata.idempotencyKey caught
|
|
143
|
+
// the duplicate. Distinct from unique_violation/version_conflict: this is
|
|
144
|
+
// not an error the caller should retry, it means the earlier attempt
|
|
145
|
+
// already succeeded and this one is a no-op replay.
|
|
146
|
+
export class IdempotentReplayError extends ConflictError {
|
|
147
|
+
override readonly code: string = "idempotent_replay";
|
|
148
|
+
|
|
149
|
+
constructor(
|
|
150
|
+
details: { readonly idempotencyKey: string },
|
|
151
|
+
opts?: Pick<ErrorOpts, "i18nKey" | "cause">,
|
|
152
|
+
) {
|
|
153
|
+
super({
|
|
154
|
+
message: `event with idempotencyKey "${details.idempotencyKey}" was already appended`,
|
|
155
|
+
i18nKey: opts?.i18nKey ?? "errors.idempotentReplay",
|
|
156
|
+
details,
|
|
157
|
+
...(opts?.cause && { cause: opts.cause }),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
141
162
|
// Entity-level unique-index violation. Distinct from VersionConflictError:
|
|
142
163
|
// version_conflict means "two writers raced on the events_aggregate_version
|
|
143
164
|
// _uq index" (optimistic-concurrency); unique_violation means "you tried to
|
package/src/errors/index.ts
CHANGED
|
@@ -7,8 +7,12 @@ import { KumikoError } from "./kumiko-error";
|
|
|
7
7
|
import { FrameworkReasons } from "./reasons";
|
|
8
8
|
import { buildInvalidTransitionDetails } from "./transition-details";
|
|
9
9
|
|
|
10
|
-
//
|
|
11
|
-
|
|
10
|
+
// Re-exported so a caller importing this module directly (instead of via
|
|
11
|
+
// the `./errors` barrel, which already re-exports these by name) still
|
|
12
|
+
// gets the types — explicit, not a wildcard: this module isn't a public
|
|
13
|
+
// subpath export (see package.json `exports`), so the only readers are
|
|
14
|
+
// framework-internal.
|
|
15
|
+
export type { WriteErrorInfo, WriteFailure };
|
|
12
16
|
|
|
13
17
|
// Convenience for call sites that return a failed WriteResult. Keeps the
|
|
14
18
|
// pattern `return writeFailure(new NotFoundError(...))` compact so handlers
|
|
@@ -15,7 +15,7 @@ import { asRawClient, selectMany } from "../../db/query";
|
|
|
15
15
|
import { createTestDb, type TestDb } from "../../stack";
|
|
16
16
|
import { generateId as uuid } from "../../utils";
|
|
17
17
|
import { appendRaw, appendRawBatch, type RawEventToAppend } from "../admin-api";
|
|
18
|
-
import { VersionConflictError } from "../errors";
|
|
18
|
+
import { IdempotentAppendConflictError, VersionConflictError } from "../errors";
|
|
19
19
|
import { append, loadAggregate } from "../event-store";
|
|
20
20
|
import { createEventsTable, eventsTable } from "../events-schema";
|
|
21
21
|
|
|
@@ -187,6 +187,32 @@ describe("appendRaw — single event", () => {
|
|
|
187
187
|
).rejects.toBeInstanceOf(VersionConflictError);
|
|
188
188
|
});
|
|
189
189
|
|
|
190
|
+
test("appendRaw distinguishes an idempotency-key replay from a version conflict (#1499)", async () => {
|
|
191
|
+
const idempotencyKey = uuid();
|
|
192
|
+
await appendRaw(
|
|
193
|
+
testDb.db,
|
|
194
|
+
makeEvent({
|
|
195
|
+
aggregateId: uuid(),
|
|
196
|
+
expectedVersion: 0,
|
|
197
|
+
metadata: { userId: userMigration, idempotencyKey },
|
|
198
|
+
}),
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
// A second, different aggregate reusing the same idempotencyKey hits
|
|
202
|
+
// the tenant-scoped partial unique index — distinct from a version
|
|
203
|
+
// race on the same aggregate, and must not be reported as one.
|
|
204
|
+
await expect(
|
|
205
|
+
appendRaw(
|
|
206
|
+
testDb.db,
|
|
207
|
+
makeEvent({
|
|
208
|
+
aggregateId: uuid(),
|
|
209
|
+
expectedVersion: 0,
|
|
210
|
+
metadata: { userId: userMigration, idempotencyKey },
|
|
211
|
+
}),
|
|
212
|
+
),
|
|
213
|
+
).rejects.toBeInstanceOf(IdempotentAppendConflictError);
|
|
214
|
+
});
|
|
215
|
+
|
|
190
216
|
test("version_conflict on missing predecessor (appendRaw v=5 without v=1..4)", async () => {
|
|
191
217
|
const aggregateId = uuid();
|
|
192
218
|
// Try to write version=5 (expectedVersion=4) against an empty stream.
|