@cosmicdrift/kumiko-framework 0.164.0 → 0.165.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -3
- package/src/__tests__/consumer-cli.integration.test.ts +62 -0
- package/src/__tests__/schema-cli.integration.test.ts +1 -1
- package/src/api/__tests__/api.test.ts +326 -18
- 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 +18 -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 +146 -0
- package/src/api/__tests__/batch.integration.test.ts +21 -2
- package/src/api/__tests__/jwt.test.ts +52 -2
- package/src/api/__tests__/login-rate-limiter-sweep.test.ts +27 -18
- 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 +117 -30
- package/src/api/server.ts +39 -7
- package/src/api/sse-broker.ts +39 -0
- package/src/bun-db/connection.ts +3 -3
- package/src/bun-db/index.ts +1 -0
- package/src/bun-db/query.ts +12 -3
- package/src/consumer-cli.ts +60 -13
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +19 -13
- package/src/db/__tests__/located-timestamp.test.ts +19 -0
- package/src/db/__tests__/migrate-runner.test.ts +61 -0
- package/src/db/__tests__/replay-migration-sql.test.ts +131 -2
- package/src/db/__tests__/tenant-db-where-merge.test.ts +6 -2
- package/src/db/api.ts +2 -2
- package/src/db/bun-provider.ts +2 -2
- 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/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 +8 -3
- package/src/db/located-timestamp.ts +4 -0
- package/src/db/migrate-runner.ts +107 -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 -21
- 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/tenant-db.ts +2 -4
- package/src/engine/__tests__/boot-validator.test.ts +14 -0
- package/src/engine/__tests__/engine.test.ts +30 -0
- package/src/engine/__tests__/registry.test.ts +36 -0
- package/src/engine/__tests__/schema-builder.test.ts +18 -0
- package/src/engine/__tests__/store-table.test.ts +2 -2
- package/src/engine/boot-validator/entity-handler.ts +14 -25
- package/src/engine/boot-validator/nav.ts +5 -0
- package/src/engine/constants.ts +32 -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/__tests__/render-roundtrip.test.ts +4 -0
- package/src/engine/feature-ast/extractors/handlers.ts +13 -18
- package/src/engine/feature-ui-extensions.ts +2 -2
- 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 +4 -1
- package/src/engine/registry-validate.ts +7 -2
- 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/index.ts +0 -2
- 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 +5 -5
- 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 +60 -14
- package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +27 -12
- package/src/event-store/admin-api.ts +11 -4
- package/src/event-store/event-store.ts +20 -21
- package/src/event-store/index.ts +0 -1
- 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/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 +203 -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__/job-trigger-consumer.integration.test.ts +106 -0
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +111 -88
- package/src/pipeline/dispatch-shared.ts +59 -6
- package/src/pipeline/dispatch-stream.ts +44 -17
- package/src/pipeline/dispatcher.ts +7 -1
- package/src/pipeline/event-consumer-state.ts +16 -13
- package/src/pipeline/event-dispatcher-delivery.ts +22 -7
- package/src/pipeline/event-dispatcher.ts +22 -0
- package/src/pipeline/index.ts +2 -0
- package/src/pipeline/system-hooks.ts +137 -1
- 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/db/__tests__/encryption.test.ts +0 -39
- package/src/db/encryption.ts +0 -45
- package/src/engine/__tests__/registry-facade-sweep.test.ts +0 -80
|
@@ -12,6 +12,23 @@
|
|
|
12
12
|
import type { Snapshot } from "./migrate-generator";
|
|
13
13
|
import { loadMigrationsFromDir } from "./migrate-runner";
|
|
14
14
|
|
|
15
|
+
// Migration files comment out destructive ops (DROP TABLE/COLUMN) as
|
|
16
|
+
// `-- DESTRUCTIVE: <stmt>; -- uncomment + ensure backup` so the real
|
|
17
|
+
// migrate-runner never executes them unattended. For replay purposes the
|
|
18
|
+
// snapshot represents the INTENDED end state, so a commented-out drop must
|
|
19
|
+
// still count as applied here — otherwise a table/column the snapshot
|
|
20
|
+
// already omits shows up as "unexpected" forever. splitSqlStatements (the
|
|
21
|
+
// real runner's splitter) strips `--`-comments outright, which would erase
|
|
22
|
+
// these markers before they ever reach applyStatement.
|
|
23
|
+
const DESTRUCTIVE_MARKER = /^--\s*DESTRUCTIVE:\s*(.+?;)/i;
|
|
24
|
+
|
|
25
|
+
function expandDestructiveMarkers(sqlText: string): string {
|
|
26
|
+
return sqlText
|
|
27
|
+
.split("\n")
|
|
28
|
+
.map((line) => DESTRUCTIVE_MARKER.exec(line.trim())?.[1] ?? line)
|
|
29
|
+
.join("\n");
|
|
30
|
+
}
|
|
31
|
+
|
|
15
32
|
export type ReplayedTable = {
|
|
16
33
|
readonly columns: ReadonlySet<string>;
|
|
17
34
|
};
|
|
@@ -39,28 +56,67 @@ function splitTopLevel(body: string): readonly string[] {
|
|
|
39
56
|
return parts;
|
|
40
57
|
}
|
|
41
58
|
|
|
59
|
+
// Identifiers in hand-written migrations aren't always quoted (the generator
|
|
60
|
+
// always quotes, but the header explicitly invites hand-editing) — optional
|
|
61
|
+
// quotes so `CREATE TABLE foo (...)` parses the same as `CREATE TABLE "foo" (...)`.
|
|
62
|
+
const IDENT = `"?([^"\\s(;,.]+)"?`;
|
|
63
|
+
|
|
42
64
|
function parseColumnNames(body: string): Set<string> {
|
|
43
65
|
const columns = new Set<string>();
|
|
44
66
|
for (const part of splitTopLevel(body)) {
|
|
45
67
|
const trimmed = part.trim();
|
|
46
|
-
if (/^CONSTRAINT\b/i.test(trimmed)) continue; //
|
|
47
|
-
const match = trimmed.match(
|
|
68
|
+
if (/^(CONSTRAINT|PRIMARY|UNIQUE|CHECK|FOREIGN|EXCLUDE|LIKE)\b/i.test(trimmed)) continue; // table-constraint or LIKE clause, not a column
|
|
69
|
+
const match = trimmed.match(new RegExp(`^${IDENT}`));
|
|
48
70
|
if (match?.[1] !== undefined) columns.add(match[1]);
|
|
49
71
|
}
|
|
50
72
|
return columns;
|
|
51
73
|
}
|
|
52
74
|
|
|
53
|
-
|
|
75
|
+
// Clauses that can appear inside an ALTER TABLE body which do NOT change the
|
|
76
|
+
// table/column shape this replay tracks (presence only, not types/
|
|
77
|
+
// constraints/RLS/ownership) — recognized explicitly so they don't fall
|
|
78
|
+
// through to the fail-loud check as "unparsed". Deliberately does NOT
|
|
79
|
+
// include RENAME TO / RENAME COLUMN: those DO change identity in a way this
|
|
80
|
+
// replay can't track, so they must keep failing loud.
|
|
81
|
+
const SHAPE_NEUTRAL_ALTER_CLAUSE_RE = new RegExp(
|
|
82
|
+
[
|
|
83
|
+
`ALTER COLUMN\\s+${IDENT}\\s+TYPE\\b`, // #1085 int/bigint-catchup fixes
|
|
84
|
+
`ALTER COLUMN\\s+${IDENT}\\s+(SET|DROP)\\s+NOT NULL\\b`,
|
|
85
|
+
`ALTER COLUMN\\s+${IDENT}\\s+(SET DEFAULT\\b|DROP DEFAULT\\b)`,
|
|
86
|
+
`(ADD|DROP)\\s+CONSTRAINT\\s+${IDENT}`,
|
|
87
|
+
`\\b(ENABLE|DISABLE)\\s+ROW LEVEL SECURITY\\b`,
|
|
88
|
+
`^OWNER TO\\b`,
|
|
89
|
+
].join("|"),
|
|
90
|
+
"gi",
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
function applyStatement(
|
|
94
|
+
schema: Map<string, { columns: Set<string> }>,
|
|
95
|
+
statement: string,
|
|
96
|
+
context: { readonly file: string },
|
|
97
|
+
): void {
|
|
54
98
|
const create = statement.match(
|
|
55
|
-
|
|
99
|
+
new RegExp(`^CREATE TABLE\\s+(IF NOT EXISTS\\s+)?${IDENT}\\s*\\(([\\s\\S]*)\\);?\\s*$`, "i"),
|
|
56
100
|
);
|
|
57
|
-
if (create?.[
|
|
58
|
-
|
|
101
|
+
if (create?.[2] !== undefined && create[3] !== undefined) {
|
|
102
|
+
const hasIfNotExists = create[1] !== undefined;
|
|
103
|
+
// A real Postgres CREATE TABLE IF NOT EXISTS is a no-op when the table
|
|
104
|
+
// already exists — treating it as an overwrite here loses any columns
|
|
105
|
+
// an earlier ALTER TABLE added in between (0001 CREATE, 0002 ALTER ADD
|
|
106
|
+
// COLUMN, 0003 an accidental copy-paste of 0001) and reports a false
|
|
107
|
+
// column-drift for exactly the copy-paste bug this replay exists to
|
|
108
|
+
// catch. A bare CREATE TABLE (no IF NOT EXISTS) still overwrites — the
|
|
109
|
+
// explicit recreate path (migrate-generator.ts) always DROPs first, so
|
|
110
|
+
// reaching a second CREATE for the same name there is itself already
|
|
111
|
+
// the bug the replay should surface via the resulting drift.
|
|
112
|
+
if (!(hasIfNotExists && schema.has(create[2]))) {
|
|
113
|
+
schema.set(create[2], { columns: parseColumnNames(create[3]) });
|
|
114
|
+
}
|
|
59
115
|
// skip: CREATE TABLE fully handled above, no other clause can also match
|
|
60
116
|
return;
|
|
61
117
|
}
|
|
62
118
|
|
|
63
|
-
const dropTable = statement.match(
|
|
119
|
+
const dropTable = statement.match(new RegExp(`^DROP TABLE\\s+(?:IF EXISTS\\s+)?${IDENT}`, "i"));
|
|
64
120
|
if (dropTable?.[1] !== undefined) {
|
|
65
121
|
schema.delete(dropTable[1]);
|
|
66
122
|
// skip: DROP TABLE fully handled above, no other clause can also match
|
|
@@ -73,29 +129,75 @@ function applyStatement(schema: Map<string, { columns: Set<string> }>, statement
|
|
|
73
129
|
// instead of matching only the first clause, in statement order so an
|
|
74
130
|
// add-then-drop of the same column (unusual, but not impossible) resolves
|
|
75
131
|
// correctly.
|
|
76
|
-
const alterTable = statement.match(
|
|
132
|
+
const alterTable = statement.match(
|
|
133
|
+
new RegExp(`^ALTER TABLE\\s+${IDENT}\\s+([\\s\\S]*?);?\\s*$`, "i"),
|
|
134
|
+
);
|
|
77
135
|
const alterTableName = alterTable?.[1];
|
|
78
136
|
const alterBody = alterTable?.[2];
|
|
79
137
|
if (alterTableName !== undefined && alterBody !== undefined) {
|
|
80
138
|
const table = schema.get(alterTableName) ?? { columns: new Set<string>() };
|
|
81
139
|
schema.set(alterTableName, table);
|
|
82
|
-
const clauseRe =
|
|
140
|
+
const clauseRe = new RegExp(
|
|
141
|
+
`(ADD|DROP)\\s+COLUMN\\s+(?:IF (?:NOT )?EXISTS\\s+)?${IDENT}`,
|
|
142
|
+
"gi",
|
|
143
|
+
);
|
|
144
|
+
let matchedAClause = false;
|
|
83
145
|
for (const [, verb, name] of alterBody.matchAll(clauseRe)) {
|
|
84
146
|
if (verb === undefined || name === undefined) continue;
|
|
147
|
+
matchedAClause = true;
|
|
85
148
|
if (verb.toUpperCase() === "ADD") table.columns.add(name);
|
|
86
149
|
else table.columns.delete(name);
|
|
87
150
|
}
|
|
151
|
+
// Shape-neutral clauses (ALTER COLUMN TYPE, SET/DROP NOT NULL, SET/DROP
|
|
152
|
+
// DEFAULT, ADD/DROP CONSTRAINT, ENABLE/DISABLE ROW LEVEL SECURITY, OWNER
|
|
153
|
+
// TO) — none add/remove/rename a column, so this replay (which only
|
|
154
|
+
// tracks column presence) correctly has nothing to do for them.
|
|
155
|
+
if (alterBody.match(SHAPE_NEUTRAL_ALTER_CLAUSE_RE)) matchedAClause = true;
|
|
156
|
+
// An ALTER TABLE that matched the outer "ALTER TABLE <name> <body>" shape
|
|
157
|
+
// but whose body contains no recognized clause (e.g. RENAME TO/RENAME
|
|
158
|
+
// COLUMN, which DO change identity) would otherwise silently no-op here
|
|
159
|
+
// — fall through to the fail-loud check below instead of returning, so
|
|
160
|
+
// it's reported rather than vanishing.
|
|
161
|
+
// skip: at least one recognized clause matched — this ALTER TABLE is
|
|
162
|
+
// fully handled, nothing left to do.
|
|
163
|
+
if (matchedAClause) return;
|
|
88
164
|
}
|
|
89
165
|
// else: CREATE INDEX and everything else don't change the table/column
|
|
90
|
-
// shape this replay tracks
|
|
166
|
+
// shape this replay tracks — but a statement that clearly INTENDED to
|
|
167
|
+
// touch a table's shape (starts with CREATE/ALTER/DROP TABLE) and matched
|
|
168
|
+
// none of the recognized patterns above must fail loud, not vanish
|
|
169
|
+
// silently. Concretely this is RENAME TO / RENAME COLUMN (identity change
|
|
170
|
+
// this replay can't track) or genuinely unparsed hand-written DDL — either
|
|
171
|
+
// way a misleading missing-table/column-drift report is worse than
|
|
172
|
+
// pointing at the actual unparsed statement.
|
|
173
|
+
if (/^(CREATE|ALTER|DROP)\s+TABLE\b/i.test(statement)) {
|
|
174
|
+
const prefix = statement.slice(0, 200).replace(/\s+/g, " ").trim();
|
|
175
|
+
throw new Error(
|
|
176
|
+
`replayMigrationsDir: unparsed table-DDL statement in ${context.file} — ` +
|
|
177
|
+
`starts with CREATE/ALTER/DROP TABLE but matched none of the replay's ` +
|
|
178
|
+
`recognized patterns (CREATE TABLE, DROP TABLE, ALTER TABLE ADD/DROP ` +
|
|
179
|
+
`COLUMN, ALTER COLUMN ... TYPE, SET/DROP NOT NULL, SET/DROP DEFAULT, ` +
|
|
180
|
+
`ADD/DROP CONSTRAINT, ENABLE/DISABLE ROW LEVEL SECURITY, OWNER TO — ` +
|
|
181
|
+
`optionally-quoted identifiers). Likely RENAME TO/RENAME COLUMN (real ` +
|
|
182
|
+
`identity change, not trackable here) or genuinely unparsed hand-written ` +
|
|
183
|
+
`DDL. Statement: ${prefix}${statement.length > 200 ? "…" : ""}`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
91
186
|
}
|
|
92
187
|
|
|
93
188
|
// Reads `<migrationsDir>/*.sql` in sequence order and replays every
|
|
94
189
|
// CREATE/ALTER/DROP TABLE statement to reconstruct the resulting schema.
|
|
190
|
+
// Reuses the real runner's file-discovery + statement-splitting
|
|
191
|
+
// (loadMigrationsFromDir) instead of a second copy, so any future change to
|
|
192
|
+
// sub-directory handling, numeric sort order, or the .sql filter can't
|
|
193
|
+
// silently drift between the runner and this replay (#1522/9).
|
|
95
194
|
export function replayMigrationsDir(migrationsDir: string): ReplayedSchema {
|
|
96
195
|
const schema = new Map<string, { columns: Set<string> }>();
|
|
97
|
-
|
|
98
|
-
|
|
196
|
+
const migrations = loadMigrationsFromDir(migrationsDir, expandDestructiveMarkers);
|
|
197
|
+
for (const migration of migrations) {
|
|
198
|
+
for (const statement of migration.statements) {
|
|
199
|
+
applyStatement(schema, statement, { file: migration.id });
|
|
200
|
+
}
|
|
99
201
|
}
|
|
100
202
|
return schema;
|
|
101
203
|
}
|
package/src/db/tenant-db.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { KUMIKO_NAME_SYMBOL, type SchemaTable } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
2
2
|
import type { TenantDb, TenantDbMode } from "@cosmicdrift/kumiko-types/tenant-db-types";
|
|
3
3
|
import {
|
|
4
4
|
asEntityTableMeta,
|
|
@@ -24,10 +24,8 @@ export function castTenantRows<T>(rows: readonly Record<string, unknown>[]): rea
|
|
|
24
24
|
return rows as unknown as readonly T[];
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
const KUMIKO_NAME_SYMBOL = Symbol.for("kumiko:schema:Name");
|
|
28
|
-
|
|
29
27
|
function tableNameOf(table: Table): string {
|
|
30
|
-
const sym =
|
|
28
|
+
const sym = table[KUMIKO_NAME_SYMBOL];
|
|
31
29
|
return typeof sym === "string" ? sym : "<unknown>";
|
|
32
30
|
}
|
|
33
31
|
|
|
@@ -599,6 +599,20 @@ describe("boot-validator", () => {
|
|
|
599
599
|
expect(() => validateBoot(features)).toThrow(/a:stream:chat:complete.*missing an access rule/i);
|
|
600
600
|
});
|
|
601
601
|
|
|
602
|
+
test("object-form streamHandler with access rule passes boot", () => {
|
|
603
|
+
const features = [
|
|
604
|
+
defineFeature("a", (r) => {
|
|
605
|
+
r.streamHandler({
|
|
606
|
+
name: "chat:complete",
|
|
607
|
+
schema: z.object({}),
|
|
608
|
+
handler: async function* () {},
|
|
609
|
+
access: { roles: ["User"] },
|
|
610
|
+
});
|
|
611
|
+
}),
|
|
612
|
+
];
|
|
613
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
614
|
+
});
|
|
615
|
+
|
|
602
616
|
test("accepts openToAll access rule on a stream handler", () => {
|
|
603
617
|
const features = [
|
|
604
618
|
defineFeature("a", (r) => {
|
|
@@ -739,6 +739,36 @@ describe("createApp", () => {
|
|
|
739
739
|
).not.toThrow();
|
|
740
740
|
});
|
|
741
741
|
|
|
742
|
+
test("validates stream handler roles against app-defined roles (#1442)", () => {
|
|
743
|
+
const feature = defineFeature("admin", (r) => {
|
|
744
|
+
r.streamHandler("admin.tail", z.object({}), async function* () {}, {
|
|
745
|
+
access: { roles: ["SuperAdmin"] },
|
|
746
|
+
});
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
expect(() =>
|
|
750
|
+
createApp({
|
|
751
|
+
roles: ["Admin", "User"] as const,
|
|
752
|
+
features: [feature],
|
|
753
|
+
}),
|
|
754
|
+
).toThrow(/unknown role.*SuperAdmin/i);
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
test("passes when stream handler roles are valid (#1442)", () => {
|
|
758
|
+
const feature = defineFeature("admin", (r) => {
|
|
759
|
+
r.streamHandler("admin.tail", z.object({}), async function* () {}, {
|
|
760
|
+
access: { roles: ["Admin"] },
|
|
761
|
+
});
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
expect(() =>
|
|
765
|
+
createApp({
|
|
766
|
+
roles: ["Admin", "User"] as const,
|
|
767
|
+
features: [feature],
|
|
768
|
+
}),
|
|
769
|
+
).not.toThrow();
|
|
770
|
+
});
|
|
771
|
+
|
|
742
772
|
test("createApp returns registry", () => {
|
|
743
773
|
const feature = defineFeature("test", () => {});
|
|
744
774
|
const app = createApp({ roles: ["Admin"] as const, features: [feature] });
|
|
@@ -139,6 +139,42 @@ describe("getAllStreamHandlers", () => {
|
|
|
139
139
|
});
|
|
140
140
|
expect(() => createRegistry([aiFeature, otherFeature])).not.toThrow();
|
|
141
141
|
});
|
|
142
|
+
|
|
143
|
+
test("two distinct feature names that kebab-collide throw on the qualified stream-handler clash", () => {
|
|
144
|
+
// "registry-test-kebab-dup" and "registryTestKebabDup" are different raw
|
|
145
|
+
// feature.name values (so the earlier Duplicate-feature guard doesn't
|
|
146
|
+
// fire) but toKebab() collapses both to the same qualified name.
|
|
147
|
+
const featureA = defineFeature("registry-test-kebab-dup", (r) => {
|
|
148
|
+
r.streamHandler("chat:complete", z.object({}), async function* () {});
|
|
149
|
+
});
|
|
150
|
+
const featureB = defineFeature("registryTestKebabDup", (r) => {
|
|
151
|
+
r.streamHandler("chat:complete", z.object({}), async function* () {});
|
|
152
|
+
});
|
|
153
|
+
expect(() => createRegistry([featureA, featureB])).toThrow(/Duplicate stream handler/);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("object-form streamHandler registration preserves schema/access/rateLimit", () => {
|
|
157
|
+
const schema = z.object({ prompt: z.string() });
|
|
158
|
+
const handlerFn = async function* () {};
|
|
159
|
+
const feature = defineFeature("registry-test-object-form", (r) => {
|
|
160
|
+
r.streamHandler({
|
|
161
|
+
name: "chat:complete",
|
|
162
|
+
schema,
|
|
163
|
+
handler: handlerFn,
|
|
164
|
+
access: { openToAll: true },
|
|
165
|
+
rateLimit: { per: "ip+handler", limit: 5, windowSeconds: 60 },
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const registry = createRegistry([feature]);
|
|
170
|
+
const registered = registry.getStreamHandler("registry-test-object-form:stream:chat:complete");
|
|
171
|
+
|
|
172
|
+
expect(registered).toBeDefined();
|
|
173
|
+
expect(registered?.schema).toBe(schema);
|
|
174
|
+
expect(registered?.handler).toBe(handlerFn);
|
|
175
|
+
expect(registered?.access).toEqual({ openToAll: true });
|
|
176
|
+
expect(registered?.rateLimit).toEqual({ per: "ip+handler", limit: 5, windowSeconds: 60 });
|
|
177
|
+
});
|
|
142
178
|
});
|
|
143
179
|
|
|
144
180
|
describe("extensionSelector boot-validation", () => {
|
|
@@ -113,6 +113,24 @@ describe("buildInsertSchema", () => {
|
|
|
113
113
|
valid: { age: 25 },
|
|
114
114
|
invalid: { age: "old" },
|
|
115
115
|
},
|
|
116
|
+
{
|
|
117
|
+
name: "number field rejects below min",
|
|
118
|
+
fields: { age: createNumberField({ min: 0 }) },
|
|
119
|
+
valid: { age: 0 },
|
|
120
|
+
invalid: { age: -1 },
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
name: "number field rejects above max",
|
|
124
|
+
fields: { age: createNumberField({ max: 100 }) },
|
|
125
|
+
valid: { age: 100 },
|
|
126
|
+
invalid: { age: 101 },
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
name: "number field min+max bounds",
|
|
130
|
+
fields: { age: createNumberField({ min: 1, max: 10 }) },
|
|
131
|
+
valid: { age: 5 },
|
|
132
|
+
invalid: { age: 11 },
|
|
133
|
+
},
|
|
116
134
|
{
|
|
117
135
|
name: "date field",
|
|
118
136
|
fields: { born: createDateField() },
|
|
@@ -142,7 +142,7 @@ describe("createRegistry — storeTable aggregation", () => {
|
|
|
142
142
|
r.storeTable(probeMeta, { reason: "second" });
|
|
143
143
|
});
|
|
144
144
|
expect(() => createRegistry([featA, featB])).toThrow(
|
|
145
|
-
/
|
|
145
|
+
/Store-table "rt_probe" registered by both feature "a" and "b"/,
|
|
146
146
|
);
|
|
147
147
|
});
|
|
148
148
|
|
|
@@ -177,7 +177,7 @@ describe("createRegistry — storeTable aggregation", () => {
|
|
|
177
177
|
|
|
178
178
|
// Entity registered first, then the colliding store table.
|
|
179
179
|
expect(() => createRegistry([entityFeature, tableFeature])).toThrow(
|
|
180
|
-
new RegExp(`
|
|
180
|
+
new RegExp(`Store-table "${physical}".*collides with the physical table of entity "widget"`),
|
|
181
181
|
);
|
|
182
182
|
|
|
183
183
|
// Order-independent: store table registered first, then the entity.
|
|
@@ -101,32 +101,21 @@ const USER_BUCKETED_RATE_LIMIT_PER: ReadonlySet<string> = new Set(["user", "user
|
|
|
101
101
|
// at runtime, but we fail at boot to turn an easy-to-miss security regression
|
|
102
102
|
// into a loud configuration error.
|
|
103
103
|
export function validateHandlerAccess(feature: FeatureDefinition): void {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
);
|
|
119
|
-
}
|
|
120
|
-
validateAnonymousRateLimit(feature.name, "query", name, handler.access, handler.rateLimit);
|
|
121
|
-
}
|
|
122
|
-
for (const [name, handler] of Object.entries(feature.streamHandlers)) {
|
|
123
|
-
if (!handler.access) {
|
|
124
|
-
throw new Error(
|
|
125
|
-
`Stream handler "${feature.name}:stream:${name}" is missing an access rule. ` +
|
|
126
|
-
`Set { roles: [...] } for role-based access, or { openToAll: true } for any authenticated user.`,
|
|
127
|
-
);
|
|
104
|
+
const kinds = [
|
|
105
|
+
{ kind: "write" as const, label: "Write", handlers: feature.writeHandlers },
|
|
106
|
+
{ kind: "query" as const, label: "Query", handlers: feature.queryHandlers },
|
|
107
|
+
{ kind: "stream" as const, label: "Stream", handlers: feature.streamHandlers },
|
|
108
|
+
];
|
|
109
|
+
for (const { kind, label, handlers } of kinds) {
|
|
110
|
+
for (const [name, handler] of Object.entries(handlers)) {
|
|
111
|
+
if (!handler.access) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`${label} handler "${feature.name}:${kind}:${name}" is missing an access rule. ` +
|
|
114
|
+
`Set { roles: [...] } for role-based access, or { openToAll: true } for any authenticated user.`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
validateAnonymousRateLimit(feature.name, kind, name, handler.access, handler.rateLimit);
|
|
128
118
|
}
|
|
129
|
-
validateAnonymousRateLimit(feature.name, "stream", name, handler.access, handler.rateLimit);
|
|
130
119
|
}
|
|
131
120
|
}
|
|
132
121
|
|
|
@@ -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,24 @@ 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
|
+
// Access-invalidation channel key for a single user's live streams. Both
|
|
107
|
+
// the subscribe side (dispatch-stream.ts) and the publish side (session-
|
|
108
|
+
// revoke / tenant-membership consumers, issue #1559/#1560) must derive the
|
|
109
|
+
// key through this helper — never build the string inline on either side.
|
|
110
|
+
export function userAccessChannel(userId: string): string {
|
|
111
|
+
return `user:${userId}:access`;
|
|
112
|
+
}
|
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
|
|
@@ -258,6 +258,7 @@ describe("render → parse roundtrip — mixed patterns (header data + opaque bo
|
|
|
258
258
|
// from the parsed FeaturePattern shape alone.
|
|
259
259
|
const RAW_REF_FEATURE = `
|
|
260
260
|
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
261
|
+
import { chatCompleteHandler } from "./handlers";
|
|
261
262
|
|
|
262
263
|
const eventEntity = {
|
|
263
264
|
fields: { name: { type: "text", required: true } },
|
|
@@ -284,6 +285,7 @@ defineFeature("refs", (r) => {
|
|
|
284
285
|
r.entity("event", eventEntity);
|
|
285
286
|
r.entity("task", { fields: buildFields() });
|
|
286
287
|
r.writeHandler(makeHandler());
|
|
288
|
+
r.streamHandler(chatCompleteHandler);
|
|
287
289
|
r.screen(eventListScreen);
|
|
288
290
|
});
|
|
289
291
|
`;
|
|
@@ -297,6 +299,7 @@ describe("render → parse roundtrip — unresolved references (raw-ref sentinel
|
|
|
297
299
|
{ kind: "entity", entityName: "event", definition: { __raw: "eventEntity" } },
|
|
298
300
|
{ kind: "entity", entityName: "task", definition: { fields: { __raw: "buildFields()" } } },
|
|
299
301
|
{ kind: "writeHandler", handlerName: undefined },
|
|
302
|
+
{ kind: "streamHandler", handlerName: undefined },
|
|
300
303
|
{ kind: "screen", definition: { __raw: "eventListScreen" } },
|
|
301
304
|
]);
|
|
302
305
|
});
|
|
@@ -310,6 +313,7 @@ describe("render → parse roundtrip — unresolved references (raw-ref sentinel
|
|
|
310
313
|
expect(rendered).toContain("eventEntity");
|
|
311
314
|
expect(rendered).toContain("buildFields()");
|
|
312
315
|
expect(rendered).toContain("r.writeHandler(makeHandler())");
|
|
316
|
+
expect(rendered).toContain("r.streamHandler(chatCompleteHandler)");
|
|
313
317
|
expect(rendered).toContain("r.screen(eventListScreen);");
|
|
314
318
|
// Would only appear if buildFields()'s return value got inlined.
|
|
315
319
|
expect(rendered).not.toContain("title:");
|
|
@@ -204,21 +204,24 @@ export function extractWriteHandler(
|
|
|
204
204
|
});
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
sourceFile: SourceFile,
|
|
210
|
-
): ExtractOutput<QueryHandlerPattern> {
|
|
211
|
-
const parsed = parseHandlerCall(call, sourceFile, "queryHandler");
|
|
212
|
-
if (parsed.kind === "error") return parsed;
|
|
213
|
-
return ok({
|
|
214
|
-
kind: "queryHandler",
|
|
207
|
+
function readHandlerFields(parsed: Extract<ExtractOutput<ParsedHandlerCall>, { kind: "pattern" }>) {
|
|
208
|
+
return {
|
|
215
209
|
source: parsed.pattern.source,
|
|
216
210
|
handlerName: parsed.pattern.handlerName,
|
|
217
211
|
schemaSource: parsed.pattern.schemaSource,
|
|
218
212
|
handlerBody: parsed.pattern.handlerBody,
|
|
219
213
|
...(parsed.pattern.access !== undefined && { access: parsed.pattern.access }),
|
|
220
214
|
...(parsed.pattern.rateLimit !== undefined && { rateLimit: parsed.pattern.rateLimit }),
|
|
221
|
-
}
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function extractQueryHandler(
|
|
219
|
+
call: CallExpression,
|
|
220
|
+
sourceFile: SourceFile,
|
|
221
|
+
): ExtractOutput<QueryHandlerPattern> {
|
|
222
|
+
const parsed = parseHandlerCall(call, sourceFile, "queryHandler");
|
|
223
|
+
if (parsed.kind === "error") return parsed;
|
|
224
|
+
return ok({ kind: "queryHandler", ...readHandlerFields(parsed) });
|
|
222
225
|
}
|
|
223
226
|
|
|
224
227
|
export function extractStreamHandler(
|
|
@@ -227,13 +230,5 @@ export function extractStreamHandler(
|
|
|
227
230
|
): ExtractOutput<StreamHandlerPattern> {
|
|
228
231
|
const parsed = parseHandlerCall(call, sourceFile, "streamHandler");
|
|
229
232
|
if (parsed.kind === "error") return parsed;
|
|
230
|
-
return ok({
|
|
231
|
-
kind: "streamHandler",
|
|
232
|
-
source: parsed.pattern.source,
|
|
233
|
-
handlerName: parsed.pattern.handlerName,
|
|
234
|
-
schemaSource: parsed.pattern.schemaSource,
|
|
235
|
-
handlerBody: parsed.pattern.handlerBody,
|
|
236
|
-
...(parsed.pattern.access !== undefined && { access: parsed.pattern.access }),
|
|
237
|
-
...(parsed.pattern.rateLimit !== undefined && { rateLimit: parsed.pattern.rateLimit }),
|
|
238
|
-
});
|
|
233
|
+
return ok({ kind: "streamHandler", ...readHandlerFields(parsed) });
|
|
239
234
|
}
|