@cosmicdrift/kumiko-framework 0.65.0 → 0.67.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 +2 -2
- package/src/db/__tests__/event-store-executor-list.integration.test.ts +65 -0
- package/src/db/event-store-executor.ts +49 -36
- package/src/engine/__tests__/build-app-schema.test.ts +21 -0
- package/src/engine/build-app-schema.ts +3 -0
- package/src/engine/entity-handlers.ts +11 -0
- package/src/engine/index.ts +1 -0
- package/src/engine/tier-resolver-extension.ts +19 -2
- package/src/engine/types/nav.ts +17 -0
- package/src/engine/types/screen.ts +8 -1
- package/src/pipeline/__tests__/dispatcher.test.ts +31 -0
- package/src/pipeline/dispatcher.ts +13 -6
- package/src/ui-types/index.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.67.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -181,7 +181,7 @@
|
|
|
181
181
|
"zod": "^4.4.3"
|
|
182
182
|
},
|
|
183
183
|
"devDependencies": {
|
|
184
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
184
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.66.0",
|
|
185
185
|
"bun-types": "^1.3.13",
|
|
186
186
|
"pino-pretty": "^13.1.3"
|
|
187
187
|
},
|
|
@@ -256,6 +256,71 @@ describe("event-store-executor.list — filter (Tier 2.7c)", () => {
|
|
|
256
256
|
expect(res.rows).toHaveLength(4);
|
|
257
257
|
expect(res.total).toBe(4);
|
|
258
258
|
});
|
|
259
|
+
|
|
260
|
+
test("filters[] AND: zwei dynamische Filter werden mit AND verknüpft", async () => {
|
|
261
|
+
await seed(10);
|
|
262
|
+
const res = await exec.list(
|
|
263
|
+
{
|
|
264
|
+
limit: 50,
|
|
265
|
+
sort: "rank",
|
|
266
|
+
sortDirection: "asc",
|
|
267
|
+
filters: [
|
|
268
|
+
{ field: "rank", op: "gt", value: 2 },
|
|
269
|
+
{ field: "rank", op: "lt", value: 6 },
|
|
270
|
+
],
|
|
271
|
+
},
|
|
272
|
+
admin,
|
|
273
|
+
tdb,
|
|
274
|
+
);
|
|
275
|
+
expect(res.rows.map((r) => r["rank"])).toEqual([3, 4, 5]);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("filters[] in: Faceted-Multi-Select rank in [2,4,8]", async () => {
|
|
279
|
+
await seed(10);
|
|
280
|
+
const res = await exec.list(
|
|
281
|
+
{
|
|
282
|
+
limit: 50,
|
|
283
|
+
sort: "rank",
|
|
284
|
+
sortDirection: "asc",
|
|
285
|
+
filters: [{ field: "rank", op: "in", value: [2, 4, 8] }],
|
|
286
|
+
},
|
|
287
|
+
admin,
|
|
288
|
+
tdb,
|
|
289
|
+
);
|
|
290
|
+
expect(res.rows.map((r) => r["rank"])).toEqual([2, 4, 8]);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("statischer filter + dynamische filters[] kombinieren mit AND", async () => {
|
|
294
|
+
await seed(10);
|
|
295
|
+
const res = await exec.list(
|
|
296
|
+
{
|
|
297
|
+
limit: 50,
|
|
298
|
+
sort: "rank",
|
|
299
|
+
sortDirection: "asc",
|
|
300
|
+
filter: { field: "rank", op: "gt", value: 2 },
|
|
301
|
+
filters: [{ field: "rank", op: "in", value: [1, 3, 5, 9] }],
|
|
302
|
+
},
|
|
303
|
+
admin,
|
|
304
|
+
tdb,
|
|
305
|
+
);
|
|
306
|
+
// gt:2 AND in[1,3,5,9] → 3,5,9 (1 fällt durch gt:2 raus)
|
|
307
|
+
expect(res.rows.map((r) => r["rank"])).toEqual([3, 5, 9]);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("filters[] mit leerem in-Array: leeres Resultat (kein Match-All)", async () => {
|
|
311
|
+
await seed(5);
|
|
312
|
+
const res = await exec.list(
|
|
313
|
+
{
|
|
314
|
+
limit: 50,
|
|
315
|
+
sort: "rank",
|
|
316
|
+
sortDirection: "asc",
|
|
317
|
+
filters: [{ field: "rank", op: "in", value: [] }],
|
|
318
|
+
},
|
|
319
|
+
admin,
|
|
320
|
+
tdb,
|
|
321
|
+
);
|
|
322
|
+
expect(res.rows).toHaveLength(0);
|
|
323
|
+
});
|
|
259
324
|
});
|
|
260
325
|
|
|
261
326
|
describe("event-store-executor.list — runtime SearchAdapter (Tier 2.7e Audit-Fix #1)", () => {
|
|
@@ -200,6 +200,15 @@ export type EventStoreExecutor = {
|
|
|
200
200
|
readonly value: unknown;
|
|
201
201
|
}
|
|
202
202
|
| undefined;
|
|
203
|
+
// User-gewählte Faceted-Filter (dynamisch, additiv zum statischen
|
|
204
|
+
// `filter`). Alle werden mit AND verknüpft.
|
|
205
|
+
filters?:
|
|
206
|
+
| ReadonlyArray<{
|
|
207
|
+
readonly field: string;
|
|
208
|
+
readonly op: "eq" | "ne" | "lt" | "gt" | "in";
|
|
209
|
+
readonly value: unknown;
|
|
210
|
+
}>
|
|
211
|
+
| undefined;
|
|
203
212
|
},
|
|
204
213
|
user: SessionUser,
|
|
205
214
|
db: TenantDb,
|
|
@@ -875,45 +884,49 @@ export function createEventStoreExecutor(
|
|
|
875
884
|
whereSql.push(shifted.sqlText);
|
|
876
885
|
for (const p of shifted.params) params.push(p);
|
|
877
886
|
}
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
}
|
|
887
|
+
const applyFilter = (f: {
|
|
888
|
+
readonly field: string;
|
|
889
|
+
readonly op: "eq" | "ne" | "lt" | "gt" | "in";
|
|
890
|
+
readonly value: unknown;
|
|
891
|
+
}): void => {
|
|
892
|
+
if (table[f.field] === undefined) {
|
|
893
|
+
// skip: unknown field — not a real column, drop the filter (injection guard)
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
const screen = buildFilterWhere(f.field, f.op, f.value);
|
|
897
|
+
if (screen === null) {
|
|
898
|
+
whereSql.push("FALSE");
|
|
899
|
+
// skip: filter is unsatisfiable → emit FALSE, no params to bind
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
for (const [field, value] of Object.entries(screen)) {
|
|
903
|
+
if (Array.isArray(value)) {
|
|
904
|
+
const placeholders = value.map((v) => {
|
|
905
|
+
params.push(v);
|
|
906
|
+
return `$${params.length}`;
|
|
907
|
+
});
|
|
908
|
+
whereSql.push(`${colSql(field)} IN (${placeholders.join(", ")})`);
|
|
909
|
+
} else if (typeof value === "object" && value !== null) {
|
|
910
|
+
const opMap: Record<string, string> = {
|
|
911
|
+
gt: ">",
|
|
912
|
+
gte: ">=",
|
|
913
|
+
lt: "<",
|
|
914
|
+
lte: "<=",
|
|
915
|
+
ne: "<>",
|
|
916
|
+
};
|
|
917
|
+
for (const [opKey, opSym] of Object.entries(opMap)) {
|
|
918
|
+
if (!(opKey in value)) continue;
|
|
919
|
+
params.push((value as Record<string, unknown>)[opKey]);
|
|
920
|
+
whereSql.push(`${colSql(field)} ${opSym} $${params.length}`);
|
|
913
921
|
}
|
|
922
|
+
} else {
|
|
923
|
+
params.push(value);
|
|
924
|
+
whereSql.push(`${colSql(field)} = $${params.length}`);
|
|
914
925
|
}
|
|
915
926
|
}
|
|
916
|
-
}
|
|
927
|
+
};
|
|
928
|
+
if (payload.filter !== undefined) applyFilter(payload.filter);
|
|
929
|
+
if (payload.filters !== undefined) for (const f of payload.filters) applyFilter(f);
|
|
917
930
|
|
|
918
931
|
const orderByClause =
|
|
919
932
|
payload.sort && table[payload.sort]
|
|
@@ -134,6 +134,27 @@ describe("buildAppSchema", () => {
|
|
|
134
134
|
expect(fields["label"]?.["default"]).toBe("");
|
|
135
135
|
});
|
|
136
136
|
|
|
137
|
+
test("filterable kommt ins Client-Schema (steuert die Faceted-Filter)", () => {
|
|
138
|
+
const entity = {
|
|
139
|
+
fields: {
|
|
140
|
+
status: { type: "select", options: ["draft", "published"], filterable: true },
|
|
141
|
+
name: { type: "text" },
|
|
142
|
+
},
|
|
143
|
+
} as unknown as EntityDefinition;
|
|
144
|
+
const f = defineFeature("ent", (r) => {
|
|
145
|
+
r.entity("thing", entity);
|
|
146
|
+
});
|
|
147
|
+
const app = buildAppSchema(createRegistry([f]));
|
|
148
|
+
const fields = (
|
|
149
|
+
app.features[0]?.entities["thing"] as unknown as {
|
|
150
|
+
fields: Record<string, Record<string, unknown>>;
|
|
151
|
+
}
|
|
152
|
+
).fields;
|
|
153
|
+
expect(fields["status"]?.["filterable"]).toBe(true);
|
|
154
|
+
// Felder ohne filterable tragen den Key nicht (kein false-Müll).
|
|
155
|
+
expect(fields["name"]?.["filterable"]).toBeUndefined();
|
|
156
|
+
});
|
|
157
|
+
|
|
137
158
|
test("AppSchema ist via JSON.stringify roundtrip-sicher", () => {
|
|
138
159
|
// Echter Smoke-Test des Vertrags — wenn jemand in den project-
|
|
139
160
|
// Helper eine Function reinschmuggelt, würde das hier brennen.
|
|
@@ -311,6 +311,9 @@ function projectField(fieldDef: FieldDefinition): FieldDefinition {
|
|
|
311
311
|
if (typeof def["type"] === "string") out["type"] = def["type"];
|
|
312
312
|
if (typeof def["required"] === "boolean") out["required"] = def["required"];
|
|
313
313
|
if (typeof def["sortable"] === "boolean") out["sortable"] = def["sortable"];
|
|
314
|
+
// filterable steuert die Faceted-Filter-Dropdowns im Renderer (select/
|
|
315
|
+
// boolean) — muss daher ins Client-Schema.
|
|
316
|
+
if (typeof def["filterable"] === "boolean") out["filterable"] = def["filterable"];
|
|
314
317
|
if (isLiteral(def["default"])) out["default"] = def["default"];
|
|
315
318
|
// Select: options-Liste ist plain JSON, durchschicken.
|
|
316
319
|
if (Array.isArray(def["options"])) out["options"] = def["options"];
|
|
@@ -95,6 +95,17 @@ const listSchema = z.object({
|
|
|
95
95
|
value: z.unknown(),
|
|
96
96
|
})
|
|
97
97
|
.optional(),
|
|
98
|
+
// User-gewählte Faceted-Filter (dynamisch). Additiv zum statischen
|
|
99
|
+
// `filter` — executor.list verknüpft alle mit AND.
|
|
100
|
+
filters: z
|
|
101
|
+
.array(
|
|
102
|
+
z.object({
|
|
103
|
+
field: z.string(),
|
|
104
|
+
op: z.enum(["eq", "ne", "lt", "gt", "in"]),
|
|
105
|
+
value: z.unknown(),
|
|
106
|
+
}),
|
|
107
|
+
)
|
|
108
|
+
.optional(),
|
|
98
109
|
});
|
|
99
110
|
|
|
100
111
|
function parseHandlerName<TVerb extends string>(
|
package/src/engine/index.ts
CHANGED
|
@@ -41,12 +41,29 @@ export const TENANT_TIER_RESOLVER_EXT = "tenantTierResolver";
|
|
|
41
41
|
* **System-context convention:** call mit SYSTEM_TENANT_ID erwartet die union
|
|
42
42
|
* aller tier-features (siehe DispatcherOptions.effectiveFeatures doc-block).
|
|
43
43
|
*/
|
|
44
|
-
export type EffectiveFeaturesResolver = (tenantId: TenantId) => ReadonlySet<string
|
|
44
|
+
export type EffectiveFeaturesResolver = ((tenantId: TenantId) => ReadonlySet<string>) & {
|
|
45
|
+
/**
|
|
46
|
+
* Optionaler Live-Trial-Gate, vom dispatcher-feature-gate NUR konsultiert
|
|
47
|
+
* wenn der synchrone Resolver ein Feature als disabled meldet. Liest das
|
|
48
|
+
* Signup-Datum des Tenants live (tenant.inserted_at) und returnt true wenn
|
|
49
|
+
* der Tenant im Trial-Fenster ist UND das Feature zum Trial-Tier gehört.
|
|
50
|
+
* Async + nicht im Boot-Cache, weil der Trial zeit-abgeleitet ist (ändert
|
|
51
|
+
* sich zwischen Requests). Nur die 2 Gate-Aufrufstellen awaiten ihn; der
|
|
52
|
+
* synchrone Hot-Path (ctx.hasFeature, Feature-Set) bleibt unberührt.
|
|
53
|
+
*/
|
|
54
|
+
readonly trialGate?: TrialGate;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Live-Trial-Gate-Shape. Siehe EffectiveFeaturesResolver.trialGate.
|
|
59
|
+
*/
|
|
60
|
+
export type TrialGate = (tenantId: TenantId, featureName: string) => Promise<boolean>;
|
|
45
61
|
|
|
46
62
|
/**
|
|
47
63
|
* Plugin-shape für tier-resolver-extension. Plugins implementieren `build`
|
|
48
64
|
* als boot-time factory: kriegen `db` + `registry` (post-stack-setup),
|
|
49
|
-
* laden initial cache aus DB, returnen den synchronen resolver-callback
|
|
65
|
+
* laden initial cache aus DB, returnen den synchronen resolver-callback
|
|
66
|
+
* (optional mit angehängtem `trialGate`).
|
|
50
67
|
*/
|
|
51
68
|
export type TierResolverPlugin = {
|
|
52
69
|
readonly build: (deps: {
|
package/src/engine/types/nav.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { AccessRule } from "./handlers";
|
|
2
|
+
import type { TargetRef } from "./target-ref";
|
|
3
|
+
import type { TreeAction } from "./tree-node";
|
|
2
4
|
|
|
3
5
|
// Nav entry declaration. Every feature that wants to appear in the app's
|
|
4
6
|
// navigation tree registers one or more entries via r.nav(). The engine
|
|
@@ -34,6 +36,21 @@ export type NavDefinition = {
|
|
|
34
36
|
// ("<feature>:screen:<id>"). Omit for pure grouping entries (a parent-only
|
|
35
37
|
// nav node that renders a sub-tree but has no target screen itself).
|
|
36
38
|
readonly screen?: string;
|
|
39
|
+
// Polymorphes Klick-Ziel (öffnet die EditorPanel-Maske via Target-
|
|
40
|
+
// Resolver) — Alternative zu `screen`. Ein Knoten trägt screen XOR
|
|
41
|
+
// target; der Renderer dispatcht das target statt einen Route-Link zu
|
|
42
|
+
// rendern. Gespiegelt aus dem alten Visual-Tree (TreeNode.target).
|
|
43
|
+
readonly target?: TargetRef;
|
|
44
|
+
// Hover-Actions rechts in der Zeile (VS-Code-Pattern) — erst bei Hover
|
|
45
|
+
// sichtbar. Reihenfolge wie deklariert.
|
|
46
|
+
readonly actions?: readonly TreeAction[];
|
|
47
|
+
// „+"-Affordance am Knoten. Klick dispatcht createAction.target; der
|
|
48
|
+
// Provider weiß was „leer befüllen" für ihn heißt (neuer Page-Slug etc.).
|
|
49
|
+
readonly createAction?: TreeAction;
|
|
50
|
+
// Children kommen zur Laufzeit aus einem registrierten nav-provider
|
|
51
|
+
// (lazy beim Ausklappen, SSE-live via treeEntities), keyed auf diese
|
|
52
|
+
// Nav-QN. Macht den Knoten expandable auch ohne statische children.
|
|
53
|
+
readonly provider?: boolean;
|
|
37
54
|
// Role / openToAll gate. The nav resolver hides entries the user can't
|
|
38
55
|
// reach; leave unset to always show (engine stays un-opinionated about
|
|
39
56
|
// who sees what — apps that need default-deny can set { roles: [] }).
|
|
@@ -309,7 +309,10 @@ export type EditSectionSpec = EditFieldsSection | EditExtensionSection;
|
|
|
309
309
|
|
|
310
310
|
export type EditFieldsSection = {
|
|
311
311
|
readonly kind?: "fields";
|
|
312
|
-
|
|
312
|
+
/** Optional. Ohne Titel rendert die Section nur ihre Felder (keine h3-
|
|
313
|
+
* Überschrift) — für flache Forms (Card-Titel + Felder direkt, ein
|
|
314
|
+
* einzelner Abschnitt) wie bei den meisten shadcn-Form-Mustern. */
|
|
315
|
+
readonly title?: string;
|
|
313
316
|
readonly columns?: number;
|
|
314
317
|
readonly fields: readonly EditFieldSpec[];
|
|
315
318
|
};
|
|
@@ -333,6 +336,10 @@ export type EntityEditScreenDefinition = {
|
|
|
333
336
|
readonly type: "entityEdit";
|
|
334
337
|
readonly entity: string;
|
|
335
338
|
readonly layout: EditLayout;
|
|
339
|
+
/** Optionaler i18n-Key (oder Roh-String) für den Submit-Button. Default
|
|
340
|
+
* `kumiko.actions.save`. Lässt den Auto-Edit-Screen domain-spezifische
|
|
341
|
+
* CTAs zeigen ("Save Address", "Create item") statt generisch "Speichern". */
|
|
342
|
+
readonly submitLabel?: string;
|
|
336
343
|
/** Default true. `false` für Entities deren Create über einen eigenen
|
|
337
344
|
* Lifecycle-Write läuft (z.B. incident:open mit Event-Stream + Joins)
|
|
338
345
|
* statt über `<entity>:create`: unterdrückt den automatischen
|
|
@@ -376,6 +376,37 @@ describe("dispatcher feature-gate", () => {
|
|
|
376
376
|
});
|
|
377
377
|
});
|
|
378
378
|
|
|
379
|
+
test("trialGate: disabled feature is allowed on the gate's cold path when trialGate returns true", async () => {
|
|
380
|
+
// Beweist die Live-Trial-Mechanik: der sync resolver hat das Feature NICHT
|
|
381
|
+
// im Set (alles disabled), aber checkFeatureEnabled konsultiert den async
|
|
382
|
+
// trialGate auf dem disabled-Pfad. Deckt beide Aufrufstellen ab (query =
|
|
383
|
+
// ensureFeatureEnabled, write = checkFeatureEnabled).
|
|
384
|
+
const registry = createRegistry([toggled()]);
|
|
385
|
+
let trialOpen = false;
|
|
386
|
+
const effectiveFeatures = Object.assign(() => new Set<string>(), {
|
|
387
|
+
trialGate: async (_tenantId: TenantId, feature: string) => trialOpen && feature === "toggled",
|
|
388
|
+
});
|
|
389
|
+
const dispatcher = createDispatcher(registry, {}, { effectiveFeatures });
|
|
390
|
+
|
|
391
|
+
// Trial zu → 403 (resolver-Set leer, Gate verweigert).
|
|
392
|
+
await expect(dispatcher.query("toggled:query:widget:list", {}, user)).rejects.toThrow(
|
|
393
|
+
/feature toggled is disabled/,
|
|
394
|
+
);
|
|
395
|
+
const writeClosed = await dispatcher.write("toggled:write:widget:create", { name: "x" }, user);
|
|
396
|
+
expect(writeClosed.isSuccess).toBe(false);
|
|
397
|
+
if (!writeClosed.isSuccess) expect(writeClosed.error.code).toBe("feature_disabled");
|
|
398
|
+
|
|
399
|
+
// Trial offen → Gate lässt durch, obwohl der Resolver das Feature nicht
|
|
400
|
+
// führt. Query passiert; Write passiert das Gate (scheitert ggf. später an
|
|
401
|
+
// fehlender DB, aber NICHT mehr an feature_disabled).
|
|
402
|
+
trialOpen = true;
|
|
403
|
+
await expect(dispatcher.query("toggled:query:widget:list", {}, user)).resolves.toEqual({
|
|
404
|
+
items: [],
|
|
405
|
+
});
|
|
406
|
+
const writeOpen = await dispatcher.write("toggled:write:widget:create", { name: "x" }, user);
|
|
407
|
+
if (!writeOpen.isSuccess) expect(writeOpen.error.code).not.toBe("feature_disabled");
|
|
408
|
+
});
|
|
409
|
+
|
|
379
410
|
test("Sprint 8a: per-tenant gating — Tenant A passes, Tenant B gets feature_disabled", async () => {
|
|
380
411
|
// Beweist die Phase-1-Architektur: dispatcher ruft effectiveFeatures
|
|
381
412
|
// mit user.tenantId, resolver kann pro Tenant unterschiedliche Sets
|
|
@@ -659,10 +659,10 @@ export function createDispatcher(
|
|
|
659
659
|
// When `effectiveFeatures` is not wired (tests, apps without feature-toggles
|
|
660
660
|
// loaded), every handler is treated as enabled — the gate is a pure
|
|
661
661
|
// pass-through in that common case.
|
|
662
|
-
function checkFeatureEnabled(
|
|
662
|
+
async function checkFeatureEnabled(
|
|
663
663
|
qualifiedHandler: string,
|
|
664
664
|
tenantId: TenantId,
|
|
665
|
-
): import("../errors").FeatureDisabledError | undefined {
|
|
665
|
+
): Promise<import("../errors").FeatureDisabledError | undefined> {
|
|
666
666
|
if (!effectiveFeatures) return undefined;
|
|
667
667
|
const owner = registry.getHandlerFeature(qualifiedHandler);
|
|
668
668
|
// skip: handler without an owning feature cannot be toggled — shouldn't
|
|
@@ -671,11 +671,18 @@ export function createDispatcher(
|
|
|
671
671
|
if (!owner) return undefined;
|
|
672
672
|
const set = effectiveFeatures(tenantId);
|
|
673
673
|
if (set.has(owner)) return undefined;
|
|
674
|
+
// Feature is off for the stored tier — give the live trial-gate a last
|
|
675
|
+
// chance. Time-derived (tenant.inserted_at + window), so it can't live in
|
|
676
|
+
// the boot-cached sync resolver; consulted only on this already-disabled
|
|
677
|
+
// cold path, never on the hot enabled path.
|
|
678
|
+
if (effectiveFeatures.trialGate && (await effectiveFeatures.trialGate(tenantId, owner))) {
|
|
679
|
+
return undefined;
|
|
680
|
+
}
|
|
674
681
|
return new FeatureDisabledError(owner, qualifiedHandler);
|
|
675
682
|
}
|
|
676
683
|
|
|
677
|
-
function ensureFeatureEnabled(qualifiedHandler: string, tenantId: TenantId): void {
|
|
678
|
-
const err = checkFeatureEnabled(qualifiedHandler, tenantId);
|
|
684
|
+
async function ensureFeatureEnabled(qualifiedHandler: string, tenantId: TenantId): Promise<void> {
|
|
685
|
+
const err = await checkFeatureEnabled(qualifiedHandler, tenantId);
|
|
679
686
|
if (err) throw err;
|
|
680
687
|
}
|
|
681
688
|
|
|
@@ -737,7 +744,7 @@ export function createDispatcher(
|
|
|
737
744
|
// disabled feature must not consume the rate-limit quota — the call
|
|
738
745
|
// never happened from the feature's perspective. Order is: lookup →
|
|
739
746
|
// feature-gate → rate-limit → access → validation → handler.
|
|
740
|
-
ensureFeatureEnabled(type, user.tenantId);
|
|
747
|
+
await ensureFeatureEnabled(type, user.tenantId);
|
|
741
748
|
|
|
742
749
|
// Rate-limit gate runs BEFORE access-check on purpose: anonymous /
|
|
743
750
|
// unauthorized callers must hit the cap too (otherwise the limit
|
|
@@ -1028,7 +1035,7 @@ export function createDispatcher(
|
|
|
1028
1035
|
|
|
1029
1036
|
// Feature-toggle gate: disabled handlers must short-circuit before any
|
|
1030
1037
|
// rate-limit/access/validation work — see executeQueryInner comment.
|
|
1031
|
-
const disabledErr = checkFeatureEnabled(type, user.tenantId);
|
|
1038
|
+
const disabledErr = await checkFeatureEnabled(type, user.tenantId);
|
|
1032
1039
|
if (disabledErr) return writeFailure(disabledErr);
|
|
1033
1040
|
|
|
1034
1041
|
// Rate-limit gate before access (same reasoning as in executeQueryInner).
|
package/src/ui-types/index.ts
CHANGED
|
@@ -74,5 +74,7 @@ export {
|
|
|
74
74
|
normalizeEditField,
|
|
75
75
|
normalizeListColumn,
|
|
76
76
|
} from "../engine/types/screen";
|
|
77
|
+
export type { TargetRef } from "../engine/types/target-ref";
|
|
78
|
+
export type { TreeAction, TreeNode, TreeNodeState } from "../engine/types/tree-node";
|
|
77
79
|
export type { WorkspaceDefinition } from "../engine/types/workspace";
|
|
78
80
|
export type { AppSchema, FeatureSchema, WorkspaceSchema } from "./app-schema";
|