@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
|
@@ -1,19 +1,14 @@
|
|
|
1
|
+
import type { EventStoreExecutor } from "@cosmicdrift/kumiko-types/event-store-executor-types";
|
|
1
2
|
import type { LocalKeyKmsAdapter } from "../crypto";
|
|
2
|
-
import type {
|
|
3
|
-
DeleteContext,
|
|
4
|
-
EntityDefinition,
|
|
5
|
-
EntityId,
|
|
6
|
-
SaveContext,
|
|
7
|
-
SessionUser,
|
|
8
|
-
WriteResult,
|
|
9
|
-
} from "../engine/types";
|
|
3
|
+
import type { EntityDefinition } from "../engine/types";
|
|
10
4
|
import type { EntityCache } from "../pipeline/entity-cache";
|
|
11
5
|
import type { SearchAdapter } from "../search/types";
|
|
12
6
|
import type { EnvelopeCipher } from "../secrets/envelope-cipher";
|
|
13
7
|
import { buildExecutorContext, type Table } from "./event-store-executor-context";
|
|
14
8
|
import { createReadVerbs } from "./event-store-executor-read";
|
|
15
9
|
import { createWriteVerbs } from "./event-store-executor-write";
|
|
16
|
-
|
|
10
|
+
|
|
11
|
+
export type { EventStoreExecutor } from "@cosmicdrift/kumiko-types/event-store-executor-types";
|
|
17
12
|
|
|
18
13
|
// The executor writes events + auto-projection (entity table) in one TX.
|
|
19
14
|
// It no longer knows about user projections — those are driven by the
|
|
@@ -42,93 +37,6 @@ export type EventStoreExecutorOptions = {
|
|
|
42
37
|
kms?: LocalKeyKmsAdapter;
|
|
43
38
|
};
|
|
44
39
|
|
|
45
|
-
export type EventStoreExecutor = {
|
|
46
|
-
create: (
|
|
47
|
-
payload: Record<string, unknown>,
|
|
48
|
-
user: SessionUser,
|
|
49
|
-
db: import("./tenant-db").TenantDb,
|
|
50
|
-
) => Promise<WriteResult<SaveContext>>;
|
|
51
|
-
|
|
52
|
-
update: (
|
|
53
|
-
payload: { id: EntityId; version?: number | undefined; changes: Record<string, unknown> },
|
|
54
|
-
user: SessionUser,
|
|
55
|
-
db: import("./tenant-db").TenantDb,
|
|
56
|
-
options?: { skipOptimisticLock?: boolean; skipUnchanged?: boolean },
|
|
57
|
-
) => Promise<WriteResult<SaveContext>>;
|
|
58
|
-
|
|
59
|
-
delete: (
|
|
60
|
-
payload: { id: EntityId },
|
|
61
|
-
user: SessionUser,
|
|
62
|
-
db: import("./tenant-db").TenantDb,
|
|
63
|
-
) => Promise<WriteResult<DeleteContext>>;
|
|
64
|
-
|
|
65
|
-
// Hard-purge (Art. 17 erasure). Like delete, but emits `<entity>.forgotten`
|
|
66
|
-
// which hard-deletes the row even for softDelete entities — and, being an
|
|
67
|
-
// auto-verb replayed by the implicit projection, the erasure survives a
|
|
68
|
-
// rebuild (created → forgotten → row gone). Reaches soft-deleted rows too.
|
|
69
|
-
forget: (
|
|
70
|
-
payload: { id: EntityId },
|
|
71
|
-
user: SessionUser,
|
|
72
|
-
db: import("./tenant-db").TenantDb,
|
|
73
|
-
) => Promise<WriteResult<DeleteContext>>;
|
|
74
|
-
|
|
75
|
-
restore: (
|
|
76
|
-
payload: { id: EntityId },
|
|
77
|
-
user: SessionUser,
|
|
78
|
-
db: import("./tenant-db").TenantDb,
|
|
79
|
-
) => Promise<WriteResult<SaveContext>>;
|
|
80
|
-
|
|
81
|
-
list: (
|
|
82
|
-
payload: {
|
|
83
|
-
cursor?: string | undefined;
|
|
84
|
-
limit?: number | undefined;
|
|
85
|
-
search?: string | undefined;
|
|
86
|
-
sort?: string | undefined;
|
|
87
|
-
sortDirection?: "asc" | "desc" | undefined;
|
|
88
|
-
offset?: number | undefined;
|
|
89
|
-
totalCount?: boolean | undefined;
|
|
90
|
-
filter?:
|
|
91
|
-
| {
|
|
92
|
-
readonly field: string;
|
|
93
|
-
readonly op: "eq" | "ne" | "lt" | "gt" | "in";
|
|
94
|
-
readonly value: unknown;
|
|
95
|
-
}
|
|
96
|
-
| undefined;
|
|
97
|
-
// User-gewählte Faceted-Filter (dynamisch, additiv zum statischen
|
|
98
|
-
// `filter`). Alle werden mit AND verknüpft.
|
|
99
|
-
filters?:
|
|
100
|
-
| ReadonlyArray<{
|
|
101
|
-
readonly field: string;
|
|
102
|
-
readonly op: "eq" | "ne" | "lt" | "gt" | "in";
|
|
103
|
-
readonly value: unknown;
|
|
104
|
-
}>
|
|
105
|
-
| undefined;
|
|
106
|
-
},
|
|
107
|
-
user: SessionUser,
|
|
108
|
-
db: import("./tenant-db").TenantDb,
|
|
109
|
-
/** Tier 2.7e Audit-Fix: per-Call SearchAdapter Override. Wenn der
|
|
110
|
-
* Executor beim Build keinen SearchAdapter via Options bekommen
|
|
111
|
-
* hat (defaultEntityQueryHandler-Pfad), kann der Caller (Handler)
|
|
112
|
-
* hier zur Runtime einen aus ctx.searchAdapter durchreichen.
|
|
113
|
-
* options.searchAdapter (build-time) gewinnt — runtime-Override
|
|
114
|
-
* ist Fallback für die default-Wrapper. */
|
|
115
|
-
runtimeOptions?: {
|
|
116
|
-
readonly searchAdapter?: SearchAdapter;
|
|
117
|
-
// Trash query: skip the implicit `isDeleted = FALSE` filter so soft-
|
|
118
|
-
// deleted rows are returned too. Tenant + ownership clauses still apply
|
|
119
|
-
// — includeDeleted only relaxes the soft-delete predicate, never the
|
|
120
|
-
// visibility ones, so it can ride untrusted query input safely.
|
|
121
|
-
readonly includeDeleted?: boolean;
|
|
122
|
-
},
|
|
123
|
-
) => Promise<CursorResult<Record<string, unknown>>>;
|
|
124
|
-
|
|
125
|
-
detail: (
|
|
126
|
-
payload: { id: EntityId },
|
|
127
|
-
user: SessionUser,
|
|
128
|
-
db: import("./tenant-db").TenantDb,
|
|
129
|
-
) => Promise<Record<string, unknown> | null>;
|
|
130
|
-
};
|
|
131
|
-
|
|
132
40
|
export function createEventStoreExecutor(
|
|
133
41
|
table: Table,
|
|
134
42
|
entity: EntityDefinition,
|
package/src/db/table-builder.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ExecutorOnly } from "@cosmicdrift/kumiko-types/executor-brand";
|
|
1
2
|
import type {
|
|
2
3
|
EntityDefinition,
|
|
3
4
|
EntityRelations,
|
|
@@ -382,25 +383,7 @@ type SoftDeleteColumnsType = {
|
|
|
382
383
|
};
|
|
383
384
|
|
|
384
385
|
// ── ES-write brand ──────────────────────────────────────────────────────
|
|
385
|
-
|
|
386
|
-
// rebuild-safe). To make a direct write a *compile* error rather than a
|
|
387
|
-
// convention, EntityTable carries a phantom `unique symbol` prop; the public
|
|
388
|
-
// write helpers reject anything that has it (see NotExecutorOnly + query.ts).
|
|
389
|
-
// The symbol key dodges SchemaTable's `[field: string]: unknown` index
|
|
390
|
-
// signature (string index sigs don't cover symbol keys), so the brand
|
|
391
|
-
// survives the type-erasure that would swallow a plain marker prop — and the
|
|
392
|
-
// executor seam (applyEntityEvent) erases `table` to TableColumns<any>, which
|
|
393
|
-
// carries no such prop, so the one legitimate writer stays green.
|
|
394
|
-
declare const EXECUTOR_ONLY: unique symbol;
|
|
395
|
-
export interface ExecutorOnly {
|
|
396
|
-
readonly [EXECUTOR_ONLY]: true;
|
|
397
|
-
}
|
|
398
|
-
// Negative brand for write-helper params: a branded EntityTable is NOT
|
|
399
|
-
// assignable (its `true` clashes with `never`), while unmanaged EntityTableMeta
|
|
400
|
-
// and erased SchemaTable pass (they lack the prop, so `?: never` is satisfied).
|
|
401
|
-
export type NotExecutorOnly = {
|
|
402
|
-
readonly [EXECUTOR_ONLY]?: never;
|
|
403
|
-
};
|
|
386
|
+
export type { ExecutorOnly, NotExecutorOnly } from "@cosmicdrift/kumiko-types/executor-brand";
|
|
404
387
|
|
|
405
388
|
export type EntityTable<E extends EntityDefinition = EntityDefinition> =
|
|
406
389
|
TableColumns<// biome-ignore lint/suspicious/noExplicitAny: drizzle's internal table-config stays generic; we layer typed columns on top via the intersection below.
|
package/src/db/tenant-db.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { SchemaTable } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
2
|
+
import type { TenantDb, TenantDbMode } from "@cosmicdrift/kumiko-types/tenant-db-types";
|
|
1
3
|
import {
|
|
2
4
|
asEntityTableMeta,
|
|
3
5
|
asRawClient,
|
|
@@ -12,61 +14,10 @@ import {
|
|
|
12
14
|
import { SYSTEM_TENANT_ID, type TenantId } from "../engine/types/identifiers";
|
|
13
15
|
import { emitDbQuery, type Meter, registerStandardMetrics, type Tracer } from "../observability";
|
|
14
16
|
import type { DbRunner } from "./connection";
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
// biome-ignore lint/suspicious/noExplicitAny: Dynamic tables — keys depend on user-defined schema.
|
|
20
|
-
type Table = TableColumns<any>;
|
|
21
|
-
|
|
22
|
-
// Method-form writes reject the executor-only brand exactly like the free-function
|
|
23
|
-
// helpers (#742): a managed EntityTable is a rebuildable projection, so writing it
|
|
24
|
-
// directly — free-function OR method-form — drifts the row past its event stream and
|
|
25
|
-
// a rebuild wipes it. The permissive base stays (raw pgTables AND unmanaged entity
|
|
26
|
-
// metas are not projections → writable); `& NotExecutorOnly` strips only branded
|
|
27
|
-
// EntityTables (its `[EXECUTOR_ONLY]: true` violates the optional-never). Reads keep
|
|
28
|
-
// the plain `Table` param.
|
|
29
|
-
type WritableTable = (Table | EntityTableMeta) & NotExecutorOnly;
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* TenantDb scope modes:
|
|
33
|
-
*
|
|
34
|
-
* - "tenant" (default): SELECT/UPDATE/DELETE filtered by tenantId + reference data (tenantId=SYSTEM_TENANT_ID).
|
|
35
|
-
* INSERT forces tenantId — handler cannot override.
|
|
36
|
-
*
|
|
37
|
-
* - "system" (r.systemScope()): No tenant filter on reads/updates/deletes.
|
|
38
|
-
* INSERT uses tenantId as default but handler can override.
|
|
39
|
-
*
|
|
40
|
-
* Tables without a tenantId column are always unfiltered regardless of mode.
|
|
41
|
-
*/
|
|
42
|
-
export type TenantDbMode = "tenant" | "system";
|
|
43
|
-
|
|
44
|
-
export type TenantDb = {
|
|
45
|
-
readonly tenantId: TenantId;
|
|
46
|
-
readonly mode: TenantDbMode;
|
|
47
|
-
/**
|
|
48
|
-
* Underlying DbRunner. Framework-internal use (event-store, migrations) —
|
|
49
|
-
* bypasses tenant-filter. Feature code uses the typed helpers above so the
|
|
50
|
-
* automatic scoping stays intact.
|
|
51
|
-
*/
|
|
52
|
-
readonly raw: DbRunner;
|
|
53
|
-
selectMany<T = Record<string, unknown>>(
|
|
54
|
-
table: Table,
|
|
55
|
-
where?: WhereObject,
|
|
56
|
-
options?: SelectOptions,
|
|
57
|
-
): Promise<readonly T[]>;
|
|
58
|
-
fetchOne<T = Record<string, unknown>>(table: Table, where: WhereObject): Promise<T | undefined>;
|
|
59
|
-
insertOne<T = Record<string, unknown>>(
|
|
60
|
-
table: WritableTable,
|
|
61
|
-
values: Record<string, unknown>,
|
|
62
|
-
): Promise<T | undefined>;
|
|
63
|
-
updateMany<T = Record<string, unknown>>(
|
|
64
|
-
table: WritableTable,
|
|
65
|
-
set: Record<string, unknown>,
|
|
66
|
-
where: WhereObject,
|
|
67
|
-
): Promise<readonly T[]>;
|
|
68
|
-
deleteMany(table: WritableTable, where: WhereObject): Promise<void>;
|
|
69
|
-
};
|
|
17
|
+
|
|
18
|
+
type Table = SchemaTable;
|
|
19
|
+
|
|
20
|
+
export type { TenantDb, TenantDbMode } from "@cosmicdrift/kumiko-types/tenant-db-types";
|
|
70
21
|
|
|
71
22
|
// @cast-boundary tenant-db-row
|
|
72
23
|
export function castTenantRows<T>(rows: readonly Record<string, unknown>[]): readonly T[] {
|
|
@@ -590,6 +590,52 @@ describe("boot-validator", () => {
|
|
|
590
590
|
expect(() => validateBoot(features)).not.toThrow();
|
|
591
591
|
});
|
|
592
592
|
|
|
593
|
+
test("throws when a stream handler has no access rule", () => {
|
|
594
|
+
const features = [
|
|
595
|
+
defineFeature("a", (r) => {
|
|
596
|
+
r.streamHandler("chat:complete", z.object({}), async function* () {});
|
|
597
|
+
}),
|
|
598
|
+
];
|
|
599
|
+
expect(() => validateBoot(features)).toThrow(/a:stream:chat:complete.*missing an access rule/i);
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
test("accepts openToAll access rule on a stream handler", () => {
|
|
603
|
+
const features = [
|
|
604
|
+
defineFeature("a", (r) => {
|
|
605
|
+
r.streamHandler("chat:complete", z.object({}), async function* () {}, {
|
|
606
|
+
access: { openToAll: true },
|
|
607
|
+
});
|
|
608
|
+
}),
|
|
609
|
+
];
|
|
610
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
test("throws when an anonymous stream handler uses a user-bucketed rate limit", () => {
|
|
614
|
+
const features = [
|
|
615
|
+
defineFeature("a", (r) => {
|
|
616
|
+
r.streamHandler("chat:complete", z.object({}), async function* () {}, {
|
|
617
|
+
access: { roles: ["anonymous"] },
|
|
618
|
+
rateLimit: { per: "user", limit: 10, windowSeconds: 60 },
|
|
619
|
+
});
|
|
620
|
+
}),
|
|
621
|
+
];
|
|
622
|
+
expect(() => validateBoot(features)).toThrow(
|
|
623
|
+
/stream handler "a:stream:chat:complete" allows anonymous callers/i,
|
|
624
|
+
);
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
test("accepts an anonymous stream handler with an ip-bucketed rate limit", () => {
|
|
628
|
+
const features = [
|
|
629
|
+
defineFeature("a", (r) => {
|
|
630
|
+
r.streamHandler("chat:complete", z.object({}), async function* () {}, {
|
|
631
|
+
access: { roles: ["anonymous"] },
|
|
632
|
+
rateLimit: { per: "ip", limit: 10, windowSeconds: 60 },
|
|
633
|
+
});
|
|
634
|
+
}),
|
|
635
|
+
];
|
|
636
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
637
|
+
});
|
|
638
|
+
|
|
593
639
|
describe("config key bounds consistency", () => {
|
|
594
640
|
test("accepts number key with consistent bounds + default", () => {
|
|
595
641
|
const features = [
|
|
@@ -1,19 +1,32 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, it, mock } from "bun:test";
|
|
2
|
-
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
|
|
5
|
-
// Bun.Glob is only used by scanForCandidates / runCodemod
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
// Bun.Glob is only used by scanForCandidates / runCodemod. Emulate
|
|
6
|
+
// **/*.write.ts via node:fs so scan/convert paths stay testable.
|
|
7
|
+
function collectWriteTsRelPaths(rootDir: string, dir = rootDir, prefix = ""): string[] {
|
|
8
|
+
const out: string[] = [];
|
|
9
|
+
for (const name of readdirSync(dir)) {
|
|
10
|
+
const rel = prefix ? `${prefix}/${name}` : name;
|
|
11
|
+
const full = join(dir, name);
|
|
12
|
+
if (statSync(full).isDirectory()) {
|
|
13
|
+
out.push(...collectWriteTsRelPaths(rootDir, full, rel));
|
|
14
|
+
} else if (name.endsWith(".write.ts")) {
|
|
15
|
+
out.push(rel);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
|
|
10
21
|
mock.module("bun", () => {
|
|
11
|
-
class
|
|
12
|
-
|
|
13
|
-
|
|
22
|
+
class FsGlob {
|
|
23
|
+
constructor(private readonly pattern: string) {}
|
|
24
|
+
scanSync(rootDir: string): string[] {
|
|
25
|
+
if (this.pattern !== "**/*.write.ts") return [];
|
|
26
|
+
return collectWriteTsRelPaths(rootDir).sort();
|
|
14
27
|
}
|
|
15
28
|
}
|
|
16
|
-
return { Glob:
|
|
29
|
+
return { Glob: FsGlob };
|
|
17
30
|
});
|
|
18
31
|
|
|
19
32
|
import {
|
|
@@ -21,6 +34,8 @@ import {
|
|
|
21
34
|
analyzeHandlerArrow,
|
|
22
35
|
convertFile,
|
|
23
36
|
generatePerformBlock,
|
|
37
|
+
runCodemod,
|
|
38
|
+
scanForCandidates,
|
|
24
39
|
} from "../codemod/index";
|
|
25
40
|
|
|
26
41
|
const tmpDir = join(__dirname, "__codemod_fixtures__");
|
|
@@ -31,6 +46,20 @@ function writeFixture(name: string, content: string): string {
|
|
|
31
46
|
return p;
|
|
32
47
|
}
|
|
33
48
|
|
|
49
|
+
function writeFixtureIn(dir: string, name: string, content: string): string {
|
|
50
|
+
const p = join(dir, name);
|
|
51
|
+
mkdirSync(join(p, ".."), { recursive: true });
|
|
52
|
+
writeFileSync(p, content, "utf8");
|
|
53
|
+
return p;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function freshScanRoot(name: string): string {
|
|
57
|
+
const dir = join(tmpDir, name);
|
|
58
|
+
rmSync(dir, { recursive: true, force: true });
|
|
59
|
+
mkdirSync(dir, { recursive: true });
|
|
60
|
+
return dir;
|
|
61
|
+
}
|
|
62
|
+
|
|
34
63
|
beforeAll(() => {
|
|
35
64
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
36
65
|
mkdirSync(tmpDir, { recursive: true });
|
|
@@ -399,6 +428,31 @@ export const h = defineWriteHandler({
|
|
|
399
428
|
// ts-morph should handle this gracefully (parse error → no call found)
|
|
400
429
|
expect(result.status).toBe("skipped");
|
|
401
430
|
});
|
|
431
|
+
|
|
432
|
+
it("returns error when the path is not a readable file", async () => {
|
|
433
|
+
const dirPath = join(tmpDir, "dir-not-file.write.ts");
|
|
434
|
+
rmSync(dirPath, { recursive: true, force: true });
|
|
435
|
+
mkdirSync(dirPath, { recursive: true });
|
|
436
|
+
const result = await convertFile(dirPath);
|
|
437
|
+
expect(result.status).toBe("error");
|
|
438
|
+
expect(result.reason).toBeDefined();
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it("verbose logs when skipping a non-trivial handler", async () => {
|
|
442
|
+
const p = writeFixture("verbose-skip-file.write.ts", complexContent);
|
|
443
|
+
const logs: string[] = [];
|
|
444
|
+
const origLog = console.log;
|
|
445
|
+
console.log = (...args: unknown[]) => {
|
|
446
|
+
logs.push(args.map(String).join(" "));
|
|
447
|
+
};
|
|
448
|
+
try {
|
|
449
|
+
const result = await convertFile(p, undefined, { verbose: true });
|
|
450
|
+
expect(result.status).toBe("skipped");
|
|
451
|
+
expect(logs.some((l) => l.includes("non-trivial handler"))).toBe(true);
|
|
452
|
+
} finally {
|
|
453
|
+
console.log = origLog;
|
|
454
|
+
}
|
|
455
|
+
});
|
|
402
456
|
});
|
|
403
457
|
});
|
|
404
458
|
|
|
@@ -509,6 +563,81 @@ describe("analyzeHandlerArrow", () => {
|
|
|
509
563
|
expect(result.isGuardedCreate).toBe(true);
|
|
510
564
|
expect(result.isSimpleExecutorCreate).toBe(false);
|
|
511
565
|
});
|
|
566
|
+
|
|
567
|
+
it("returns non-convertible defaults for async ( without =>", () => {
|
|
568
|
+
const result = analyzeHandlerArrow("async (event, ctx) { return { isSuccess: true } }");
|
|
569
|
+
expect(result.isStaticReturn).toBe(false);
|
|
570
|
+
expect(result.isSimpleExecutorCreate).toBe(false);
|
|
571
|
+
expect(result.isSimpleExecutorUpdate).toBe(false);
|
|
572
|
+
expect(result.hasConditionalLogic).toBe(false);
|
|
573
|
+
expect(result.isExpressionBodyCreate).toBe(false);
|
|
574
|
+
expect(result.isGuardedCreate).toBe(false);
|
|
575
|
+
});
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
describe("scanForCandidates", () => {
|
|
579
|
+
it("returns empty list when no *.write.ts files exist", () => {
|
|
580
|
+
const root = freshScanRoot("scan-empty");
|
|
581
|
+
expect(scanForCandidates(root)).toEqual([]);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it("finds and classifies *.write.ts fixtures under rootDir", () => {
|
|
585
|
+
const root = freshScanRoot("scan-mixed");
|
|
586
|
+
writeFixtureIn(root, "freeform.write.ts", staticReturnContent);
|
|
587
|
+
writeFixtureIn(root, "already-pipeline.write.ts", alreadyPipelineContent);
|
|
588
|
+
writeFixtureIn(root, "nested/deep.write.ts", queryHandlerContent);
|
|
589
|
+
|
|
590
|
+
const results = scanForCandidates(root);
|
|
591
|
+
expect(results.map((r) => r.filePath).sort()).toEqual(
|
|
592
|
+
[
|
|
593
|
+
join(root, "already-pipeline.write.ts"),
|
|
594
|
+
join(root, "freeform.write.ts"),
|
|
595
|
+
join(root, "nested/deep.write.ts"),
|
|
596
|
+
].sort(),
|
|
597
|
+
);
|
|
598
|
+
expect(results.find((r) => r.pattern === "free-form-write")?.convertible).toBe(true);
|
|
599
|
+
expect(results.find((r) => r.pattern === "pipeline-write")?.convertible).toBe(false);
|
|
600
|
+
expect(results.find((r) => r.pattern === "query-handler")?.convertible).toBe(false);
|
|
601
|
+
});
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
describe("runCodemod", () => {
|
|
605
|
+
it("returns empty report when scan finds no convertible handlers", async () => {
|
|
606
|
+
const root = freshScanRoot("run-empty");
|
|
607
|
+
const report = await runCodemod(root);
|
|
608
|
+
expect(report).toEqual({ results: [], converted: 0, skipped: 0, errors: 0 });
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it("converts free-form write handlers discovered by scan", async () => {
|
|
612
|
+
const root = freshScanRoot("run-convert");
|
|
613
|
+
const p = writeFixtureIn(root, "to-convert.write.ts", staticReturnContent);
|
|
614
|
+
const report = await runCodemod(root);
|
|
615
|
+
expect(report.converted).toBe(1);
|
|
616
|
+
expect(report.skipped).toBe(0);
|
|
617
|
+
expect(report.errors).toBe(0);
|
|
618
|
+
expect(readFileSync(p, "utf8")).toContain("perform: stepsPipeline");
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
it("verbose logs per-file outcomes", async () => {
|
|
622
|
+
const root = freshScanRoot("run-verbose");
|
|
623
|
+
writeFixtureIn(root, "verbose-convert.write.ts", staticReturnContent);
|
|
624
|
+
writeFixtureIn(root, "verbose-skip.write.ts", complexContent);
|
|
625
|
+
|
|
626
|
+
const logs: string[] = [];
|
|
627
|
+
const origLog = console.log;
|
|
628
|
+
console.log = (...args: unknown[]) => {
|
|
629
|
+
logs.push(args.map(String).join(" "));
|
|
630
|
+
};
|
|
631
|
+
try {
|
|
632
|
+
const report = await runCodemod(root, { verbose: true });
|
|
633
|
+
expect(report.converted).toBe(1);
|
|
634
|
+
expect(report.skipped).toBe(1);
|
|
635
|
+
expect(logs.some((l) => l.includes("verbose-convert.write.ts"))).toBe(true);
|
|
636
|
+
expect(logs.some((l) => l.includes("verbose-skip.write.ts"))).toBe(true);
|
|
637
|
+
} finally {
|
|
638
|
+
console.log = origLog;
|
|
639
|
+
}
|
|
640
|
+
});
|
|
512
641
|
});
|
|
513
642
|
|
|
514
643
|
describe("generatePerformBlock", () => {
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
createTextField,
|
|
15
15
|
} from "../factories";
|
|
16
16
|
import { createApp, createRegistry, defineFeature } from "../index";
|
|
17
|
+
import { buildMinimalCtx } from "./_pipeline-test-utils";
|
|
17
18
|
|
|
18
19
|
// --- defineFeature ---
|
|
19
20
|
|
|
@@ -118,6 +119,33 @@ describe("defineFeature", () => {
|
|
|
118
119
|
expect(ref?.name).toBe("order:list");
|
|
119
120
|
});
|
|
120
121
|
|
|
122
|
+
test("streamHandler registers on feature.streamHandlers with schema/access/handler intact", async () => {
|
|
123
|
+
let ref: { name: string } | undefined;
|
|
124
|
+
const feature = defineFeature("test", (r) => {
|
|
125
|
+
ref = r.streamHandler(
|
|
126
|
+
"chat:complete",
|
|
127
|
+
z.object({ prompt: z.string() }),
|
|
128
|
+
async function* (query) {
|
|
129
|
+
yield `echo:${query.payload.prompt}`;
|
|
130
|
+
},
|
|
131
|
+
{ access: { openToAll: true } },
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
expect(ref?.name).toBe("chat:complete");
|
|
135
|
+
|
|
136
|
+
const stored = feature.streamHandlers["chat:complete"];
|
|
137
|
+
expect(stored?.access).toEqual({ openToAll: true });
|
|
138
|
+
|
|
139
|
+
const chunks: string[] = [];
|
|
140
|
+
for await (const chunk of stored!.handler(
|
|
141
|
+
{ type: "chat:complete", payload: { prompt: "hi" }, user: createTestUser() },
|
|
142
|
+
buildMinimalCtx(),
|
|
143
|
+
)) {
|
|
144
|
+
chunks.push(chunk as string);
|
|
145
|
+
}
|
|
146
|
+
expect(chunks).toEqual(["echo:hi"]);
|
|
147
|
+
});
|
|
148
|
+
|
|
121
149
|
test("r.defineEvent returns typed EventDef and registers on feature", () => {
|
|
122
150
|
let eventRef: { name: string } | undefined;
|
|
123
151
|
const feature = defineFeature("orders", (r) => {
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Dilution reclaim: integration lcov registers every facade getter as DA:N,0
|
|
2
|
+
// when the file loads, but unit only instruments getters that a test touches.
|
|
3
|
+
// Calling every getter once in unit raises LF+LH so the badge merge stops
|
|
4
|
+
// counting those one-liners as uncovered.
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import { createRegistry } from "../registry";
|
|
8
|
+
|
|
9
|
+
describe("buildRegistryFacade — getter sweep", () => {
|
|
10
|
+
test("empty registry: every getter is callable without throw", () => {
|
|
11
|
+
const r = createRegistry([]);
|
|
12
|
+
|
|
13
|
+
expect(r.features.size).toBe(0);
|
|
14
|
+
expect(r.getFeature("x")).toBeUndefined();
|
|
15
|
+
expect(r.hasRateLimitedHandler()).toBe(false);
|
|
16
|
+
expect(r.getEntity("x")).toBeUndefined();
|
|
17
|
+
expect(r.getAllEntities().size).toBe(0);
|
|
18
|
+
expect(r.getWriteHandler("x")).toBeUndefined();
|
|
19
|
+
expect(r.getQueryHandler("x")).toBeUndefined();
|
|
20
|
+
expect(r.getAllQueryHandlers().size).toBe(0);
|
|
21
|
+
expect(r.getSearchableFields("x")).toEqual([]);
|
|
22
|
+
expect(r.getSortableFields("x")).toEqual([]);
|
|
23
|
+
expect(r.getRelations("x")).toEqual({});
|
|
24
|
+
expect(r.getSearchIncludes("x").size).toBe(0);
|
|
25
|
+
expect(r.getIncomingRelations("x")).toEqual([]);
|
|
26
|
+
expect(r.getPreSaveHooks("x")).toEqual([]);
|
|
27
|
+
expect(r.getPostSaveHooks("x")).toEqual([]);
|
|
28
|
+
expect(r.getPreDeleteHooks("x")).toEqual([]);
|
|
29
|
+
expect(r.getPostDeleteHooks("x")).toEqual([]);
|
|
30
|
+
expect(r.getPreQueryHooks("x")).toEqual([]);
|
|
31
|
+
expect(r.getPostQueryHooks("x")).toEqual([]);
|
|
32
|
+
expect(r.getEntityPostSaveHooks("x")).toEqual([]);
|
|
33
|
+
expect(r.getEntityPreDeleteHooks("x")).toEqual([]);
|
|
34
|
+
expect(r.getEntityPostDeleteHooks("x")).toEqual([]);
|
|
35
|
+
expect(r.getEntityPostQueryHooks("x")).toEqual([]);
|
|
36
|
+
expect(r.getSearchPayloadExtensions("x")).toEqual([]);
|
|
37
|
+
expect(r.getAllTranslations()).toBeDefined();
|
|
38
|
+
expect(r.getHandlerEntity("x")).toBeUndefined();
|
|
39
|
+
expect(r.isHandlerSystemScoped("x")).toBe(false);
|
|
40
|
+
expect(r.getHandlerFeature("x")).toBeUndefined();
|
|
41
|
+
expect(r.getAllMetrics().size).toBe(0);
|
|
42
|
+
expect(r.getAllSecretKeys().size).toBe(0);
|
|
43
|
+
expect(r.getSecretKey("x")).toBeUndefined();
|
|
44
|
+
expect(r.getConfigKey("x")).toBeUndefined();
|
|
45
|
+
expect(r.getAllConfigKeys().size).toBe(0);
|
|
46
|
+
expect(r.getJob("x")).toBeUndefined();
|
|
47
|
+
expect(r.getAllJobs().size).toBe(0);
|
|
48
|
+
expect(r.getEvent("x")).toBeUndefined();
|
|
49
|
+
expect(r.getEventUpcasters().size).toBe(0);
|
|
50
|
+
expect(r.getExtension("x")).toBeUndefined();
|
|
51
|
+
expect(r.getExtensionUsages("x")).toEqual([]);
|
|
52
|
+
expect(r.getAllExtensionSelectors().size).toBe(0);
|
|
53
|
+
expect(r.getAllNotifications().size).toBe(0);
|
|
54
|
+
expect(r.getAllReferenceData()).toEqual([]);
|
|
55
|
+
expect(r.getAllConfigSeeds()).toEqual([]);
|
|
56
|
+
expect(r.getProjectionsForSource("x")).toEqual([]);
|
|
57
|
+
expect(r.getAllProjections().size).toBe(0);
|
|
58
|
+
expect(r.getAllStoreTables().size).toBe(0);
|
|
59
|
+
expect(r.getAllMultiStreamProjections().size).toBe(0);
|
|
60
|
+
expect(r.getMultiStreamProjectionFeature("x")).toBeUndefined();
|
|
61
|
+
expect(r.getAuthClaimsHooks()).toEqual([]);
|
|
62
|
+
expect(r.getAllClaimKeys().size).toBe(0);
|
|
63
|
+
expect(r.getClaimKey("x")).toBeUndefined();
|
|
64
|
+
expect(r.getAllScreens().size).toBe(0);
|
|
65
|
+
expect(r.getScreen("x")).toBeUndefined();
|
|
66
|
+
expect(r.getScreenFeature("x")).toBeUndefined();
|
|
67
|
+
expect(r.getScreensByEntity("x")).toEqual([]);
|
|
68
|
+
expect(r.getAllNavs().size).toBe(0);
|
|
69
|
+
expect(r.getNav("x")).toBeUndefined();
|
|
70
|
+
expect(r.getNavFeature("x")).toBeUndefined();
|
|
71
|
+
expect(r.getNavsByParent("x")).toEqual([]);
|
|
72
|
+
expect(r.getTopLevelNavs()).toEqual([]);
|
|
73
|
+
expect(r.getAllWorkspaces().size).toBe(0);
|
|
74
|
+
expect(r.getWorkspace("x")).toBeUndefined();
|
|
75
|
+
expect(r.getWorkspaceFeature("x")).toBeUndefined();
|
|
76
|
+
expect(r.getWorkspaceNavs("x")).toEqual([]);
|
|
77
|
+
expect(r.getDefaultWorkspace()).toBeUndefined();
|
|
78
|
+
expect(r.getTreeActions("x")).toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -101,6 +101,46 @@ describe("getAllQueryHandlers", () => {
|
|
|
101
101
|
});
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
+
describe("getAllStreamHandlers", () => {
|
|
105
|
+
test("returns every registered stream handler, qualified, across multiple features", () => {
|
|
106
|
+
const aiFeature = defineFeature("registry-test-ai", (r) => {
|
|
107
|
+
r.streamHandler("chat:complete", z.object({ prompt: z.string() }), async function* () {}, {
|
|
108
|
+
access: { openToAll: true },
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
const otherFeature = defineFeature("registry-test-other", (r) => {
|
|
112
|
+
r.streamHandler("chat:complete", z.object({ prompt: z.string() }), async function* () {}, {
|
|
113
|
+
access: { openToAll: true },
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const registry = createRegistry([aiFeature, otherFeature]);
|
|
118
|
+
const handlers = registry.getAllStreamHandlers();
|
|
119
|
+
|
|
120
|
+
expect(handlers.get("registry-test-ai:stream:chat:complete")).toBeDefined();
|
|
121
|
+
expect(handlers.get("registry-test-other:stream:chat:complete")).toBeDefined();
|
|
122
|
+
// Same Map instance getStreamHandler reads from, not a copy that can drift.
|
|
123
|
+
expect(handlers.get("registry-test-ai:stream:chat:complete")).toBe(
|
|
124
|
+
registry.getStreamHandler("registry-test-ai:stream:chat:complete"),
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("empty registry returns an empty map, not undefined", () => {
|
|
129
|
+
const registry = createRegistry([]);
|
|
130
|
+
expect(registry.getAllStreamHandlers().size).toBe(0);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("duplicate stream-handler short-name across features qualifies independently, no collision", () => {
|
|
134
|
+
const aiFeature = defineFeature("registry-test-dup-a", (r) => {
|
|
135
|
+
r.streamHandler("chat:complete", z.object({}), async function* () {});
|
|
136
|
+
});
|
|
137
|
+
const otherFeature = defineFeature("registry-test-dup-b", (r) => {
|
|
138
|
+
r.streamHandler("chat:complete", z.object({}), async function* () {});
|
|
139
|
+
});
|
|
140
|
+
expect(() => createRegistry([aiFeature, otherFeature])).not.toThrow();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
104
144
|
describe("extensionSelector boot-validation", () => {
|
|
105
145
|
function foundationFeature() {
|
|
106
146
|
return defineFeature("probe-foundation", (r) => {
|
|
@@ -5,7 +5,11 @@
|
|
|
5
5
|
|
|
6
6
|
import { describe, expect, test } from "bun:test";
|
|
7
7
|
import { createEntity, defineFeature } from "../index";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
findTierResolverUsage,
|
|
10
|
+
isTierResolverPlugin,
|
|
11
|
+
TENANT_TIER_RESOLVER_EXT,
|
|
12
|
+
} from "../tier-resolver-extension";
|
|
9
13
|
|
|
10
14
|
function tierResolverFeature(name: string) {
|
|
11
15
|
return defineFeature(name, (r) => {
|
|
@@ -49,3 +53,17 @@ describe("findTierResolverUsage", () => {
|
|
|
49
53
|
expect(findTierResolverUsage([])).toBeUndefined();
|
|
50
54
|
});
|
|
51
55
|
});
|
|
56
|
+
|
|
57
|
+
describe("isTierResolverPlugin", () => {
|
|
58
|
+
test("true when build is a function", () => {
|
|
59
|
+
expect(isTierResolverPlugin({ build: async () => new Set() })).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("false for null, non-objects, and missing/non-fn build", () => {
|
|
63
|
+
expect(isTierResolverPlugin(null)).toBe(false);
|
|
64
|
+
expect(isTierResolverPlugin(undefined)).toBe(false);
|
|
65
|
+
expect(isTierResolverPlugin("x")).toBe(false);
|
|
66
|
+
expect(isTierResolverPlugin({})).toBe(false);
|
|
67
|
+
expect(isTierResolverPlugin({ build: 1 })).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
});
|