@cosmicdrift/kumiko-bundled-features 0.235.3 → 0.236.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.
@@ -1,4 +1,4 @@
1
- import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
1
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
3
3
  import {
4
4
  buildEntityTable,
@@ -18,21 +18,22 @@ import {
18
18
  } from "@cosmicdrift/kumiko-framework/engine";
19
19
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
20
20
  import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
21
- import { createEventDispatcher, type EventConsumer } from "@cosmicdrift/kumiko-framework/pipeline";
22
21
  import {
22
+ createTestRedis,
23
23
  createTestUser,
24
24
  setupTestStack,
25
+ type TestRedis,
25
26
  type TestStack,
26
27
  unsafeCreateEntityTable,
27
28
  } from "@cosmicdrift/kumiko-framework/stack";
28
- import { createLateBoundHolder, seedRow } from "@cosmicdrift/kumiko-framework/testing";
29
+ import { createLateBoundHolder, seedRow, waitFor } from "@cosmicdrift/kumiko-framework/testing";
29
30
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
30
31
  import { Temporal } from "temporal-polyfill";
31
32
  import { z } from "zod";
32
- import { FEATURE_TOGGLE_SET_EVENT_NAME } from "../constants";
33
33
  import { createFeatureTogglesFeature } from "../feature";
34
34
  import { globalFeatureStateTable } from "../global-feature-state-table";
35
35
  import { GlobalFeatureToggleRuntime } from "../toggle-runtime";
36
+ import { createRedisToggleSyncSignal, type RedisToggleSyncSignal } from "../toggle-sync-signal";
36
37
 
37
38
  // Widget — the "tenant" under test. toggleable(default=true), owns a
38
39
  // simple entity and a create-handler that writes via the event-store
@@ -595,98 +596,70 @@ describe("feature-toggles queries + audit automation", () => {
595
596
 
596
597
  // --- Multi-instance cache-sync (toggle-cache-sync MSP) ---
597
598
  //
598
- // Production scenario: two API instances share a DB. Instance A runs the
599
- // set-handler (flips widget off), Instance B didn't. Before this MSP,
600
- // B's runtime stayed stuck on the pre-flip snapshot until it was
601
- // restarted. Now B's dispatcher picks up the toggle-set event from the
602
- // events table (via the per-instance consumer cursor) and converges its
603
- // own snapshot.
604
- //
605
- // We simulate B with a hand-rolled second dispatcher against the same DB
606
- // — same pattern as the Welle-2.7 multi-instance tests in
607
- // event-dispatcher-multi-instance.integration.ts. Building a second
608
- // setupTestStack would need a shared DB pool across stacks, which adds
609
- // more test-infra than the scenario is worth; the hand-rolled consumer
610
- // mirrors exactly what the feature-toggles feature registers on the
611
- // primary stack.
599
+ // Production scenario: two API instances share a DB + Redis. Instance A
600
+ // runs the set-handler (flips widget off); its toggle-cache-sync MSP wins
601
+ // the shared cursor and calls runtime.broadcastToggle, which publishes on
602
+ // its syncSignal. Instance B's own GlobalFeatureToggleRuntime, subscribed
603
+ // to the same Redis channel, applies the flip without ever running the
604
+ // MSP itself — delivery is "shared", so only ONE process's dispatcher
605
+ // processes a given toggle-set event; the signal is what makes every
606
+ // OTHER already-running process learn about it. This is "der Kern" this
607
+ // PR adds: without a real transport under broadcastToggle, only the
608
+ // cursor-winning process would ever converge.
612
609
  describe("multi-instance cache-sync via toggle-cache-sync MSP", () => {
613
- test("flip on instance A propagates to instance B after its dispatcher ticks", async () => {
614
- // Instance B's runtime — same DB as instance A's `runtime`, but its
615
- // own in-memory snapshot. `initialize()` loads the pre-existing rows;
616
- // at this point there are no override rows (beforeEach wiped the
617
- // table), so `widget` is on via its toggleable default.
618
- const runtimeB = new GlobalFeatureToggleRuntime(stack.db, stack.registry);
619
- await runtimeB.initialize();
620
- expect(runtimeB.effectiveFeatures().has("widget")).toBe(true);
610
+ let testRedis: TestRedis;
611
+ let signalA: RedisToggleSyncSignal | undefined;
612
+ let signalB: RedisToggleSyncSignal | undefined;
621
613
 
622
- // Instance B's dispatcher — same consumer name as the feature's MSP
623
- // so per-instance cursor rows stay aligned with production reality
624
- // (each instance owns one cursor row keyed by (name, instance_id)).
625
- // The handler mirrors the MSP's apply: narrow the payload, call
626
- // runtime.apply. In production this code lives in the
627
- // r.multiStreamProjection declaration; here we hand-roll it to keep
628
- // the second runtime a local object.
629
- const consumer: EventConsumer = {
630
- name: "feature-toggles:projection:toggle-cache-sync",
631
- delivery: "per-instance",
632
- handler: async (event) => {
633
- if (event.type !== FEATURE_TOGGLE_SET_EVENT_NAME) return;
634
- const payload = event.payload as { featureName: string; enabled: boolean };
635
- runtimeB.apply(payload.featureName, payload.enabled);
636
- },
637
- };
638
- const dispatcherB = createEventDispatcher({
639
- db: stack.db,
640
- consumers: [consumer],
641
- context: { db: stack.db, registry: stack.registry },
642
- instanceId: "test-instance-B",
643
- batchSize: 200,
644
- pollIntervalMs: 5000,
645
- });
646
- await dispatcherB.ensureRegistered();
614
+ beforeAll(async () => {
615
+ testRedis = await createTestRedis();
616
+ });
647
617
 
648
- // Flip widget off on "instance A" via the HTTP path. This triggers:
649
- // 1. DB row write (globalFeatureStateTable)
650
- // 2. ctx.appendEvent (toggle-set into events-table)
651
- // 3. runtime.apply on A's in-memory snapshot (fast-path)
652
- await stack.http.write(
653
- "feature-toggles:write:set",
654
- { featureName: "widget", enabled: false },
655
- admin,
656
- );
618
+ afterAll(async () => {
619
+ await testRedis.cleanup();
620
+ });
657
621
 
658
- // A sees the flip immediately (local apply in set-handler).
659
- expect(runtime.effectiveFeatures().has("widget")).toBe(false);
622
+ afterEach(async () => {
623
+ await Promise.all([signalA?.close(), signalB?.close()]);
624
+ signalA = undefined;
625
+ signalB = undefined;
626
+ });
660
627
 
661
- // B hasn't ticked yet — its snapshot still says widget is on.
662
- // This is the exact stale-cache bug this MSP is solving.
628
+ test("flip via instance A's runtime converges instance B's runtime over Redis", async () => {
629
+ signalA = createRedisToggleSyncSignal(testRedis.redisUrl);
630
+ signalB = createRedisToggleSyncSignal(testRedis.redisUrl);
631
+ const runtimeA = new GlobalFeatureToggleRuntime(stack.db, stack.registry, signalA);
632
+ const runtimeB = new GlobalFeatureToggleRuntime(stack.db, stack.registry, signalB);
633
+ await runtimeA.initialize();
634
+ await runtimeB.initialize();
635
+ expect(runtimeA.effectiveFeatures().has("widget")).toBe(true);
663
636
  expect(runtimeB.effectiveFeatures().has("widget")).toBe(true);
664
637
 
665
- // B's dispatcher ticks: consumes the toggle-set event, apply fires,
666
- // runtimeB converges.
667
- await dispatcherB.runOnce();
638
+ // Retry the broadcast inside the predicate — same psubscribe-ack race
639
+ // as the SSE broker's Redis tests (a signal's subscription may not
640
+ // have landed the instant it's constructed).
641
+ await waitFor(() => {
642
+ runtimeA.broadcastToggle("widget", false);
643
+ return !runtimeB.effectiveFeatures().has("widget");
644
+ });
668
645
  expect(runtimeB.effectiveFeatures().has("widget")).toBe(false);
669
646
 
670
647
  // Flip back on — regression-check: propagation works both ways, not
671
648
  // just a one-shot on the first event.
672
- await stack.http.write(
673
- "feature-toggles:write:set",
674
- { featureName: "widget", enabled: true },
675
- admin,
676
- );
677
- expect(runtime.effectiveFeatures().has("widget")).toBe(true);
678
- expect(runtimeB.effectiveFeatures().has("widget")).toBe(false);
679
- await dispatcherB.runOnce();
649
+ await waitFor(() => {
650
+ runtimeA.broadcastToggle("widget", true);
651
+ return runtimeB.effectiveFeatures().has("widget");
652
+ });
680
653
  expect(runtimeB.effectiveFeatures().has("widget")).toBe(true);
681
654
  });
682
655
 
683
- test("per-instance delivery: the primary stack's dispatcher also fires the MSP (double-apply is idempotent)", async () => {
684
- // The feature registers the MSP with delivery="per-instance", so the
685
- // primary stack's dispatcher also owns a cursor row and fires the
686
- // handler on every toggle-set event — even on the instance that just
687
- // wrote locally via the set-handler. `runtime.apply` is Map.set, so
688
- // the second write is a no-op. We verify the dispatcher-tick path
689
- // doesn't corrupt state.
656
+ test("shared delivery: the primary stack's own dispatcher tick re-applies idempotently", async () => {
657
+ // The feature registers the MSP with delivery="shared" — a single
658
+ // process (here, the primary test stack, which has no syncSignal
659
+ // configured) can win its own cursor and re-apply a value it already
660
+ // set locally via the set-handler. `runtime.apply` is Map.set, so the
661
+ // second write is a no-op. We verify the dispatcher-tick path doesn't
662
+ // corrupt state.
690
663
  await stack.http.write(
691
664
  "feature-toggles:write:set",
692
665
  { featureName: "widget", enabled: false },
@@ -45,7 +45,7 @@ export function createFeatureTogglesFeature(
45
45
  ): FeatureDefinition {
46
46
  return defineFeature("feature-toggles", (r) => {
47
47
  r.describe(
48
- 'Persists per-feature enabled/disabled state in the `store_global_feature_state` table and exposes a `set` write-handler plus `list`/`registered` query-handlers so operators can flip features at runtime without redeploying. Each API instance keeps an in-memory `GlobalFeatureToggleRuntime` snapshot (initialize it via `createFeatureToggleRuntime`, pass a `() => runtime` accessor to `createFeatureTogglesFeature`) that the dispatcher gate reads on every request; a `toggle-cache-sync` multi-stream projection with `delivery: "per-instance"` syncs the snapshot across instances whenever a `toggle-set` event is appended.',
48
+ 'Persists per-feature enabled/disabled state in the `store_global_feature_state` table and exposes a `set` write-handler plus `list`/`registered` query-handlers so operators can flip features at runtime without redeploying. Each API instance keeps an in-memory `GlobalFeatureToggleRuntime` snapshot (initialize it via `createFeatureToggleRuntime`, pass a `() => runtime` accessor to `createFeatureTogglesFeature`) that the dispatcher gate reads on every request; a `toggle-cache-sync` multi-stream projection with `delivery: "shared"` syncs the snapshot across instances whenever a `toggle-set` event is appended.',
49
49
  );
50
50
  r.uiHints({
51
51
  displayLabel: "Feature Toggles · Operator Switches",
@@ -103,24 +103,30 @@ export function createFeatureTogglesFeature(
103
103
 
104
104
  r.translations({ keys: FEATURE_TOGGLES_I18N });
105
105
 
106
- // toggle-cache-sync — multi-instance snapshot propagation. Every
107
- // API/worker instance runs its own dispatcher cursor on this MSP
108
- // (delivery: "per-instance") and converges its in-memory snapshot on
109
- // every toggle-set event it observes. Named "cache-sync" (not
110
- // "projection" or "audit") because it's side-effect-only
111
- // infrastructure — the framework's boot-validator also rejects
112
- // per-instance MSPs that carry a `table`.
106
+ // toggle-cache-sync — multi-instance snapshot propagation. One shared
107
+ // cursor reads every toggle-set event once (fw#2625); the winning
108
+ // process's handler calls runtime.broadcastToggle, which publishes on
109
+ // GlobalFeatureToggleRuntime's syncSignal so every process (including
110
+ // the one that won the cursor) applies the flip to its own snapshot —
111
+ // see toggle-runtime.ts and toggle-sync-signal.ts. Without a syncSignal
112
+ // configured (no REDIS_URL — single-process dev/test), broadcastToggle
113
+ // applies directly; there is nobody else to reach. Named "cache-sync"
114
+ // (not "projection" or "audit") because it's side-effect-only
115
+ // infrastructure.
113
116
  //
114
117
  // Why this is correct alongside the set-handler's own `runtime.apply`:
115
118
  // - local apply = immediate response-latency optimization so the
116
119
  // next request on the same instance sees the flip without a
117
- // dispatcher-tick round-trip
120
+ // dispatcher-tick + Pub/Sub round-trip
118
121
  // - MSP = multi-instance propagation + crash-recovery. If a process
119
122
  // crashes between appendEvent (persisted) and the local apply
120
- // (volatile), the MSP rebuilds the snapshot on restart; if
121
- // instance B never ran the write, the MSP is how it learns. Both
122
- // paths are idempotent — apply is Map.set, replay on boot just
123
- // converges to the DB state that initialize() already loaded.
123
+ // (volatile), the MSP rebuilds the snapshot on restart via
124
+ // initialize(); if another instance never ran the write, the MSP
125
+ // + syncSignal is how it learns. All paths are idempotent — apply
126
+ // is Map.set.
127
+ //
128
+ // startFrom: "now" — history is meaningless here, a flip is only ever
129
+ // relevant to processes already running when it happens.
124
130
  //
125
131
  // Requires: options.getRuntime() must resolve by the time the
126
132
  // dispatcher processes its first toggle-set event. The holder-based
@@ -128,7 +134,8 @@ export function createFeatureTogglesFeature(
128
134
  // this in setupTestStack and production boot.
129
135
  r.multiStreamProjection({
130
136
  name: "toggle-cache-sync",
131
- delivery: "per-instance",
137
+ delivery: "shared",
138
+ startFrom: "now",
132
139
  apply: {
133
140
  [FEATURE_TOGGLE_SET_EVENT_NAME]: async (event) => {
134
141
  // The event payload shape is guaranteed by featureToggleSetSchema
@@ -142,7 +149,7 @@ export function createFeatureTogglesFeature(
142
149
  "was wired up without `getRuntime`. Wire the accessor in your app-config.",
143
150
  );
144
151
  }
145
- options.getRuntime().apply(payload.featureName, payload.enabled);
152
+ options.getRuntime().broadcastToggle(payload.featureName, payload.enabled);
146
153
  },
147
154
  },
148
155
  });
@@ -158,4 +165,6 @@ export { globalFeatureStateTable, globalFeatureStateTableMeta } from "./global-f
158
165
  export {
159
166
  createFeatureToggleRuntime,
160
167
  GlobalFeatureToggleRuntime,
168
+ type ToggleSyncSignal,
161
169
  } from "./toggle-runtime";
170
+ export { createRedisToggleSyncSignal, type RedisToggleSyncSignal } from "./toggle-sync-signal";
@@ -8,10 +8,13 @@ export {
8
8
  export {
9
9
  createFeatureToggleRuntime,
10
10
  createFeatureTogglesFeature,
11
+ createRedisToggleSyncSignal,
11
12
  FEATURE_TOGGLE_SET_EVENT_NAME,
12
13
  FeatureToggleErrors,
13
14
  type FeatureTogglesOptions,
14
15
  GlobalFeatureToggleRuntime,
15
16
  globalFeatureStateTable,
16
17
  globalFeatureStateTableMeta,
18
+ type RedisToggleSyncSignal,
19
+ type ToggleSyncSignal,
17
20
  } from "./feature";
@@ -7,23 +7,43 @@ import {
7
7
  } from "@cosmicdrift/kumiko-framework/engine";
8
8
  import { globalFeatureStateTable } from "./global-feature-state-table";
9
9
 
10
+ // Cross-replica transport for a toggle flip (fw#2625). Deliberately narrow
11
+ // (featureName + enabled, not a generic channel/payload pair) so a caller
12
+ // can't wire the wrong signal instance in by accident — see
13
+ // createRedisToggleSyncSignal (toggle-sync-signal.ts) for the Redis-backed
14
+ // implementation, built on the framework's generic PubSubSignal.
15
+ export type ToggleSyncSignal = {
16
+ publish(featureName: string, enabled: boolean): void;
17
+ onMessage(listener: (featureName: string, enabled: boolean) => void): void;
18
+ };
19
+
10
20
  // Holds the current global-override snapshot in memory and exposes a
11
21
  // synchronous reader — the dispatcher's feature-gate calls it on every
12
22
  // handler invocation, so this must not do I/O on the hot path. The
13
23
  // snapshot is loaded once at boot via `.initialize()`, refreshed by the
14
- // set-handler on the local instance, and kept in sync across
15
- // instances by the `toggle-cache-sync` MSP (declared on the
16
- // feature-toggles feature, delivery: "per-instance"). Every API/worker
17
- // process observes every toggle-set event and applies it to its local
18
- // snapshot — no Redis / SSE / polling needed; the existing events-table
19
- // + event-dispatcher pipeline handles propagation.
24
+ // set-handler on the local instance, and kept in sync across instances by
25
+ // the `toggle-cache-sync` MSP (declared on the feature-toggles feature,
26
+ // delivery: "shared") calling `broadcastToggle()`. With a `syncSignal`
27
+ // configured (Redis — the same REDIS_URL-gated assumption the SSE broker
28
+ // makes), that fans out to every process; without one (single-process
29
+ // dev/test), `broadcastToggle` applies directly, matching the old
30
+ // per-instance behavior for the only process there is.
20
31
  export class GlobalFeatureToggleRuntime {
21
32
  private snapshot = new Map<string, boolean>();
22
33
 
23
34
  constructor(
24
35
  private readonly db: DbConnection,
25
36
  private readonly registry: Registry,
26
- ) {}
37
+ private readonly syncSignal?: ToggleSyncSignal,
38
+ ) {
39
+ // Every process, including the one that calls broadcastToggle, learns
40
+ // of a flip through this same subscription — mirrors the SSE broker's
41
+ // publish-then-echo-to-self pattern instead of a separate local-apply
42
+ // path that could drift from the signal-delivered one.
43
+ this.syncSignal?.onMessage((featureName, enabled) => {
44
+ this.apply(featureName, enabled);
45
+ });
46
+ }
27
47
 
28
48
  async initialize(): Promise<void> {
29
49
  type Row = { featureName: string; enabled: boolean };
@@ -47,6 +67,23 @@ export class GlobalFeatureToggleRuntime {
47
67
  this.snapshot.set(featureName, enabled);
48
68
  }
49
69
 
70
+ // Called by the toggle-cache-sync MSP handler (feature.ts) — the one
71
+ // process whose shared cursor won a given toggle-set event. With a
72
+ // syncSignal configured, publishing (rather than applying directly) is
73
+ // what reaches every OTHER already-running process; this process learns
74
+ // of its own publish the same way, through the constructor's
75
+ // subscription. Without a syncSignal there is nobody else to reach, so
76
+ // apply directly — this is the "no REDIS_URL" / single-process path.
77
+ broadcastToggle(featureName: string, enabled: boolean): void {
78
+ if (this.syncSignal) {
79
+ this.syncSignal.publish(featureName, enabled);
80
+ // skip: apply() runs when the publish echoes back through this
81
+ // process's own subscription, not here — see the comment above.
82
+ return;
83
+ }
84
+ this.apply(featureName, enabled);
85
+ }
86
+
50
87
  // Raw per-feature override, bypassing the requires() cascade —
51
88
  // `undefined` means "no explicit row, inherits toggleableDefault"
52
89
  // (distinct from an explicit `false`). Used by composeTierResolver to
@@ -77,8 +114,9 @@ export class GlobalFeatureToggleRuntime {
77
114
  export async function createFeatureToggleRuntime(
78
115
  db: DbConnection,
79
116
  registry: Registry,
117
+ syncSignal?: ToggleSyncSignal,
80
118
  ): Promise<GlobalFeatureToggleRuntime> {
81
- const runtime = new GlobalFeatureToggleRuntime(db, registry);
119
+ const runtime = new GlobalFeatureToggleRuntime(db, registry, syncSignal);
82
120
  await runtime.initialize();
83
121
  return runtime;
84
122
  }
@@ -0,0 +1,57 @@
1
+ import { createRedisPubSubSignal } from "@cosmicdrift/kumiko-framework/redis";
2
+ import type { ToggleSyncSignal } from "./toggle-runtime";
3
+
4
+ // Single fixed channel — unlike the SSE broker there's no per-tenant/
5
+ // per-user variance to multiplex, every toggle flip is global.
6
+ const TOGGLE_SYNC_CHANNEL = "kumiko:feature-toggles:cache-sync";
7
+
8
+ export type RedisToggleSyncSignal = ToggleSyncSignal & {
9
+ // Not on ToggleSyncSignal — the in-memory/no-signal path (GlobalFeature
10
+ // ToggleRuntime without a signal) has nothing to release, but this one
11
+ // owns two live ioredis connections via the shared PubSubSignal.
12
+ close(): Promise<void>;
13
+ };
14
+
15
+ function isTogglePayload(value: unknown): value is { featureName: string; enabled: boolean } {
16
+ return (
17
+ typeof value === "object" &&
18
+ value !== null &&
19
+ typeof (value as { featureName?: unknown }).featureName === "string" &&
20
+ typeof (value as { enabled?: unknown }).enabled === "boolean"
21
+ );
22
+ }
23
+
24
+ // fw#2625: gives toggle-cache-sync the cross-replica transport its shared
25
+ // dispatcher cursor needs — without this, only the one process that won
26
+ // the shared cursor's toggle-set event would ever learn about a flip.
27
+ // Same REDIS_URL-gated assumption as the SSE broker: a deployment with
28
+ // replicas > 1 is expected to set REDIS_URL, so app-boot code should build
29
+ // this only when REDIS_URL is present and pass it into
30
+ // createFeatureToggleRuntime.
31
+ export function createRedisToggleSyncSignal(redisUrl: string): RedisToggleSyncSignal {
32
+ const signal = createRedisPubSubSignal({
33
+ redisUrl,
34
+ channelPattern: TOGGLE_SYNC_CHANNEL,
35
+ label: "feature-toggles",
36
+ });
37
+
38
+ return {
39
+ publish(featureName, enabled) {
40
+ signal.publish(TOGGLE_SYNC_CHANNEL, { featureName, enabled });
41
+ },
42
+ onMessage(listener) {
43
+ signal.onMessage((_channel, payload) => {
44
+ if (!isTogglePayload(payload)) {
45
+ // biome-ignore lint/suspicious/noConsole: ops-visible fallback — no ctx.log in a raw PubSubSignal handler.
46
+ console.error(
47
+ `[kumiko:feature-toggles] dropping malformed cache-sync message on "${TOGGLE_SYNC_CHANNEL}"`,
48
+ );
49
+ // skip: malformed message already logged above, nothing to apply
50
+ return;
51
+ }
52
+ listener(payload.featureName, payload.enabled);
53
+ });
54
+ },
55
+ close: signal.close,
56
+ };
57
+ }
@@ -0,0 +1,202 @@
1
+ // H.2 — row-level READ ownership for note-entry. Before this fix,
2
+ // noteEntryEntity had no `access`, so buildOwnershipClause always returned
3
+ // PASS_CLAUSE and any dispatch-eligible user could list every note in the
4
+ // tenant, including notes on host entities they can't otherwise see.
5
+ //
6
+ // This is also the framework's first production exercise of a `where`-rule
7
+ // WITH A SUBQUERY (existing ownership tests only use a literal
8
+ // `sqlText: "custom_expr_42 = 1"`), so the scenario is built to genuinely
9
+ // stress shiftParams: the ownership fragment's `$N` placeholder must be
10
+ // renumbered past the tenant-scope filter's already-consumed slots
11
+ // (event-store-executor-read.ts), not just happen to work at `$1`.
12
+
13
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
14
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
15
+ import type { EntityDefinition } from "@cosmicdrift/kumiko-framework/engine";
16
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
17
+ import {
18
+ createTestUser,
19
+ setupTestStack,
20
+ type TestStack,
21
+ unsafeCreateEntityTable,
22
+ } from "@cosmicdrift/kumiko-framework/stack";
23
+ import { NotesHistoryHandlers, NotesHistoryQueries } from "../constants";
24
+ import { createNoteEntryEntity, noteEntryEntity } from "../entity";
25
+ import { createNotesHistoryFeature } from "../feature";
26
+
27
+ // Minimal fixture standing in for a real host projection: which team a host
28
+ // entity (identified by entityId) belongs to. A real feature would source
29
+ // this from its own read-model — this table exists only to give the
30
+ // where-rule's subquery something real to join against.
31
+ const TEAMS_TABLE = "notes_ownership_test_teams";
32
+
33
+ const teamOwnership: NonNullable<EntityDefinition["access"]> = {
34
+ read: {
35
+ TenantMember: {
36
+ kind: "where",
37
+ where: (user, ctx) => ({
38
+ // Qualify the outer reference with ctx.tableName — the subquery's own
39
+ // `t.entity_id` column would otherwise shadow an unqualified bare
40
+ // `entity_id`, since Postgres resolves unqualified names to the
41
+ // innermost scope first. Confirmed by a raw-SQL repro during test
42
+ // development: an unqualified reference silently turned this into a
43
+ // self-join tautology (`t.entity_id = t.entity_id`), matching every
44
+ // row regardless of team.
45
+ sqlText: `EXISTS (SELECT 1 FROM ${TEAMS_TABLE} t WHERE t.entity_id = ${ctx.tableName}.entity_id AND t.team_id = $${ctx.paramStart})`,
46
+ params: [user.claims?.["team"] ?? null],
47
+ }),
48
+ },
49
+ },
50
+ };
51
+
52
+ type TestUser = ReturnType<typeof createTestUser>;
53
+
54
+ let scopedStack: TestStack;
55
+ let defaultStack: TestStack;
56
+
57
+ // Same tenant, different team claim — the ownership rule (not tenant
58
+ // scoping) is what must separate them.
59
+ const userA: TestUser = createTestUser({
60
+ id: 20,
61
+ roles: ["TenantMember"],
62
+ claims: { team: "team-a" },
63
+ });
64
+ const userB: TestUser = createTestUser({
65
+ id: 21,
66
+ roles: ["TenantMember"],
67
+ claims: { team: "team-b" },
68
+ });
69
+
70
+ beforeAll(async () => {
71
+ scopedStack = await setupTestStack({
72
+ features: [createNotesHistoryFeature({ ownership: teamOwnership })],
73
+ });
74
+ await unsafeCreateEntityTable(scopedStack.db, createNoteEntryEntity(teamOwnership));
75
+ await createEventsTable(scopedStack.db);
76
+ await asRawClient(scopedStack.db).unsafe(
77
+ `CREATE TABLE IF NOT EXISTS ${TEAMS_TABLE} (entity_id text PRIMARY KEY, team_id text NOT NULL)`,
78
+ );
79
+
80
+ // Regression control, dedicated stack (mirrors tags.integration.test.ts's
81
+ // openStack/defaultStack): a plain (unscoped) mount of the SAME feature,
82
+ // used to prove the 0-rows result below is the ownership rule doing real
83
+ // work, not an empty table or a broken query.
84
+ defaultStack = await setupTestStack({ features: [createNotesHistoryFeature()] });
85
+ await unsafeCreateEntityTable(defaultStack.db, noteEntryEntity);
86
+ await createEventsTable(defaultStack.db);
87
+ });
88
+
89
+ afterAll(async () => {
90
+ await scopedStack.cleanup();
91
+ await defaultStack.cleanup();
92
+ });
93
+
94
+ beforeEach(async () => {
95
+ await asRawClient(scopedStack.db).unsafe("DELETE FROM kumiko_events");
96
+ await asRawClient(scopedStack.db).unsafe("DELETE FROM read_note_entries");
97
+ await asRawClient(scopedStack.db).unsafe(`DELETE FROM ${TEAMS_TABLE}`);
98
+ await asRawClient(defaultStack.db).unsafe("DELETE FROM kumiko_events");
99
+ await asRawClient(defaultStack.db).unsafe("DELETE FROM read_note_entries");
100
+ });
101
+
102
+ async function addNote(stack: TestStack, entityId: string, user: TestUser): Promise<void> {
103
+ await stack.http.writeOk(
104
+ NotesHistoryHandlers.addNote,
105
+ { entityType: "project", entityId, body: "note" },
106
+ user,
107
+ );
108
+ }
109
+
110
+ async function listNotes(
111
+ stack: TestStack,
112
+ user: TestUser,
113
+ filter?: { field: string; op: "eq"; value: unknown },
114
+ ): Promise<Array<Record<string, unknown>>> {
115
+ const res = await stack.http.queryOk<{ rows: Array<Record<string, unknown>> }>(
116
+ NotesHistoryQueries.noteList,
117
+ filter ? { filter } : {},
118
+ user,
119
+ );
120
+ return res.rows;
121
+ }
122
+
123
+ describe("notes-history integration — row ownership (where-rule)", () => {
124
+ test("a team-scoped where-rule limits list() to the caller's team, with a filter applied", async () => {
125
+ // Both notes are authored by the SAME user (A), in the SAME tenant, both
126
+ // rows exist in TEAMS_TABLE — the only thing that can tell them apart is
127
+ // team_id. This is the discriminator that rules out both failure modes a
128
+ // weaker test would miss: a broken shiftParams (team_id compared against
129
+ // the tenant UUID or nothing → everyone sees 0) and a trivially-true
130
+ // subquery (EXISTS matches regardless of team_id → everyone sees both).
131
+ await addNote(scopedStack, "proj-1", userA); // will be team-a
132
+ await addNote(scopedStack, "proj-9", userA); // will be team-b — same author, different team
133
+ await asRawClient(scopedStack.db).unsafe(
134
+ `INSERT INTO ${TEAMS_TABLE} (entity_id, team_id) VALUES ($1, $2), ($3, $4)`,
135
+ ["proj-1", "team-a", "proj-9", "team-b"],
136
+ );
137
+
138
+ // User B (different team) sees nothing, even filtered down to the exact row.
139
+ expect(
140
+ await listNotes(scopedStack, userB, { field: "entityId", op: "eq", value: "proj-1" }),
141
+ ).toHaveLength(0);
142
+
143
+ // User A (own team) sees it. This is the real shiftParams exercise: the
144
+ // ownership fragment's `$N` is emitted starting at ctx.paramStart, then
145
+ // shifted again by the outer query's already-consumed tenant-scope
146
+ // params (2) plus the filter param (1) — if that arithmetic were wrong,
147
+ // A would ALSO see 0 rows instead of comparing team_id against garbage.
148
+ const ownRows = await listNotes(scopedStack, userA, {
149
+ field: "entityId",
150
+ op: "eq",
151
+ value: "proj-1",
152
+ });
153
+ expect(ownRows).toHaveLength(1);
154
+ expect(ownRows[0]?.["body"]).toBe("note");
155
+
156
+ // Unfiltered: A authored BOTH notes, but must see only the team-a one —
157
+ // proves the rule filters by team_id, not by authorship or a trivially-true
158
+ // subquery (which would return both).
159
+ const aRows = await listNotes(scopedStack, userA);
160
+ expect(aRows).toHaveLength(1);
161
+ expect(aRows[0]?.["entityId"]).toBe("proj-1");
162
+
163
+ // B authored NEITHER note, yet must see exactly the team-b one — proves
164
+ // the rule grants access by team membership, not by who wrote the row.
165
+ const bRows = await listNotes(scopedStack, userB);
166
+ expect(bRows).toHaveLength(1);
167
+ expect(bRows[0]?.["entityId"]).toBe("proj-9");
168
+ });
169
+
170
+ test("the same data on an unscoped mount leaks across teams (regression control)", async () => {
171
+ await addNote(defaultStack, "proj-1", userA);
172
+ // No ownership option on this stack's feature — the pre-fix behavior.
173
+ // Proves the 0-rows result above comes from the ownership rule, not
174
+ // from an unrelated empty-table/broken-query artifact.
175
+ expect(await listNotes(defaultStack, userB)).toHaveLength(1);
176
+ });
177
+
178
+ test("no explicit filter — ownership fragment still shifts correctly against the bare tenant scope", async () => {
179
+ await addNote(scopedStack, "proj-2", userA);
180
+ await asRawClient(scopedStack.db).unsafe(
181
+ `INSERT INTO ${TEAMS_TABLE} (entity_id, team_id) VALUES ($1, $2)`,
182
+ ["proj-2", "team-a"],
183
+ );
184
+
185
+ expect(await listNotes(scopedStack, userB)).toHaveLength(0);
186
+ expect(await listNotes(scopedStack, userA)).toHaveLength(1);
187
+ });
188
+ });
189
+
190
+ describe("notes-history — boot guard rejects a where-rule in ownership.write", () => {
191
+ test("createNotesHistoryFeature throws instead of shipping a create()-time landmine", () => {
192
+ expect(() =>
193
+ createNotesHistoryFeature({
194
+ ownership: {
195
+ write: {
196
+ TenantMember: { kind: "where", where: () => ({ sqlText: "1=1", params: [] }) },
197
+ },
198
+ },
199
+ }),
200
+ ).toThrow(/ownership\.write must not contain a.*where/);
201
+ });
202
+ });