@cosmicdrift/kumiko-framework 0.176.1 → 0.176.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.176.1",
3
+ "version": "0.176.2",
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>",
@@ -182,7 +182,7 @@
182
182
  "./package.json": "./package.json"
183
183
  },
184
184
  "dependencies": {
185
- "@cosmicdrift/kumiko-types": "0.176.1",
185
+ "@cosmicdrift/kumiko-types": "0.176.2",
186
186
  "bullmq": "^5.76.7",
187
187
  "bun-types": "^1.3.13",
188
188
  "hono": "^4.12.27",
@@ -198,7 +198,7 @@
198
198
  "zod": "^4.4.3"
199
199
  },
200
200
  "devDependencies": {
201
- "@cosmicdrift/kumiko-dispatcher-live": "0.176.1",
201
+ "@cosmicdrift/kumiko-dispatcher-live": "0.176.2",
202
202
  "bun-types": "^1.3.13",
203
203
  "pino-pretty": "^13.1.3"
204
204
  },
@@ -32,6 +32,10 @@ export function createSseBroker(): SseBroker {
32
32
  // Cross-replica fanout lives one level up: the SSE + access-invalidation
33
33
  // consumers (system-hooks.ts) run delivery: "per-instance" (#1718).
34
34
  const channels = new Map<string, Map<string, SseClient>>();
35
+ // Set, not Map<listenerId, fn> — dedup key is callback reference. Every
36
+ // subscriber must pass a distinct closure (dispatch-stream.ts does, one
37
+ // per stream). Two subscribes with the SAME reference for the same user
38
+ // collapse into one listener, and the first unsubscribe kills both.
35
39
  const accessInvalidationListeners = new Map<string, Set<() => void>>();
36
40
 
37
41
  function getOrCreateChannel(channel: string): Map<string, SseClient> {
@@ -591,7 +591,7 @@ describe("boot-validator", () => {
591
591
  });
592
592
 
593
593
  test("warns when a role is used by exactly one handler, reached through the real validateBoot wiring", () => {
594
- const warnSpy = spyOn(console, "warn");
594
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
595
595
  try {
596
596
  const features = [
597
597
  defineFeature("a", (r) => {
@@ -616,7 +616,7 @@ describe("boot-validator", () => {
616
616
  });
617
617
 
618
618
  test("does NOT warn on unique access roles by default — opt-in only (#1711)", () => {
619
- const warnSpy = spyOn(console, "warn");
619
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
620
620
  try {
621
621
  const features = [
622
622
  defineFeature("b", (r) => {
@@ -15,7 +15,7 @@ describe("warnOnUniqueAccessRoles", () => {
15
15
  let warnSpy: ReturnType<typeof spyOn<Console, "warn">>;
16
16
 
17
17
  beforeEach(() => {
18
- warnSpy = spyOn(console, "warn");
18
+ warnSpy = spyOn(console, "warn").mockImplementation(() => {});
19
19
  });
20
20
 
21
21
  afterEach(() => {
@@ -73,6 +73,13 @@ import {
73
73
  import type { IdempotencyGuard } from "./idempotency";
74
74
  import type { LifecycleHooks } from "./lifecycle-pipeline";
75
75
 
76
+ // Framework/pipeline stays bundled-features-free, so this can't import the
77
+ // `tenant` feature — the literal below IS the coupling to its `timezone`
78
+ // config key. Renaming that key (or the "tenant" feature name) must update
79
+ // this constant too; tenant-timezone-boot.integration.test.ts boots the real
80
+ // createTenantFeature() and would catch a drift.
81
+ const TENANT_TIMEZONE_CONFIG_KEY = "tenant:config:timezone";
82
+
76
83
  export type BatchCommand = {
77
84
  readonly type: string;
78
85
  readonly payload: unknown;
@@ -522,7 +529,7 @@ export async function buildHandlerContext(
522
529
  // comes from SessionUser.timezone (set at login), else falls back to
523
530
  // tenant (createTzContext's own default). An app-injected GeoTzProvider
524
531
  // (context.geoTzProvider) feeds ctx.tz.fromCoordinates / fromAddress.
525
- const tenantTz = config !== undefined ? await config("tenant:config:timezone") : undefined;
532
+ const tenantTz = config !== undefined ? await config(TENANT_TIMEZONE_CONFIG_KEY) : undefined;
526
533
  // Guarded against garbage: an unvalidated string here (free-form config
527
534
  // key, legacy JWT claim predating validation) blows up every ctx.tz call
528
535
  // for the whole tenant with a RangeError. Fall back to UTC/tenant instead
@@ -61,6 +61,66 @@ function ownershipPredicates(
61
61
  return { sql: parts.join(" OR "), params };
62
62
  }
63
63
 
64
+ type MatchedRow = { id: string; tenant_id: string };
65
+
66
+ // ponytail: LIMIT/OFFSET, not a keyset cursor — mirrors reindexEntity's same
67
+ // tradeoff (id type varies uuid/serial across entities). A tenant-destroy
68
+ // purge is a one-time sweep, not a hot path.
69
+ const PURGE_BATCH_SIZE = 500;
70
+
71
+ async function collectMatchingRowsForEntity(
72
+ db: DbRunner,
73
+ tableName: string,
74
+ whereSql: string,
75
+ params: readonly unknown[],
76
+ ): Promise<readonly MatchedRow[]> {
77
+ const rows: MatchedRow[] = [];
78
+ let offset = 0;
79
+ for (;;) {
80
+ const offsetN = params.length + 1;
81
+ const page = await executeRawQuery<MatchedRow>(
82
+ db,
83
+ `SELECT id, tenant_id FROM ${quoteIdent(tableName)} WHERE ${whereSql}
84
+ ORDER BY ${quoteIdent("id")} ASC
85
+ LIMIT ${PURGE_BATCH_SIZE} OFFSET $${offsetN}`,
86
+ [...params, offset],
87
+ );
88
+ if (page.length === 0) break;
89
+ rows.push(...page);
90
+ offset += page.length;
91
+ if (page.length < PURGE_BATCH_SIZE) break;
92
+ }
93
+ return rows;
94
+ }
95
+
96
+ function buildSubjectPredicate(
97
+ entity: EntityDefinition,
98
+ fields: readonly string[],
99
+ likePattern: string,
100
+ subject: SubjectId | undefined,
101
+ ): { sql: string; params: unknown[] } {
102
+ let paramIdx = 0;
103
+ const nextParam = () => ++paramIdx;
104
+ const params: unknown[] = [];
105
+ const orParts: string[] = [];
106
+
107
+ const likeN = nextParam();
108
+ params.push(likePattern);
109
+ orParts.push(
110
+ `(${fields.map((f) => `${quoteIdent(toSnakeCase(f))} LIKE $${likeN}`).join(" OR ")})`,
111
+ );
112
+
113
+ if (subject) {
114
+ const owned = ownershipPredicates(entity, fields, subject, nextParam);
115
+ if (owned) {
116
+ params.push(...owned.params);
117
+ orParts.push(`(${owned.sql})`);
118
+ }
119
+ }
120
+
121
+ return { sql: orParts.join(" OR "), params };
122
+ }
123
+
64
124
  export async function purgeSearchDocumentsForSubject(
65
125
  db: DbRunner,
66
126
  features: ReadonlyMap<string, FeatureDefinition>,
@@ -78,30 +138,12 @@ export async function purgeSearchDocumentsForSubject(
78
138
  const fields = collectSearchableSubjectFields(entity);
79
139
  if (fields.length === 0) continue;
80
140
  const tableName = resolveTableName(entityName, entity, undefined);
81
-
82
- let paramIdx = 0;
83
- const nextParam = () => ++paramIdx;
84
- const params: unknown[] = [];
85
- const orParts: string[] = [];
86
-
87
- const likeN = nextParam();
88
- params.push(likePattern);
89
- orParts.push(
90
- `(${fields.map((f) => `${quoteIdent(toSnakeCase(f))} LIKE $${likeN}`).join(" OR ")})`,
91
- );
92
-
93
- if (subject) {
94
- const owned = ownershipPredicates(entity, fields, subject, nextParam);
95
- if (owned) {
96
- params.push(...owned.params);
97
- orParts.push(`(${owned.sql})`);
98
- }
99
- }
100
-
101
- const rows = await executeRawQuery<{ id: string; tenant_id: string }>(
141
+ const predicate = buildSubjectPredicate(entity, fields, likePattern, subject);
142
+ const rows = await collectMatchingRowsForEntity(
102
143
  db,
103
- `SELECT id, tenant_id FROM ${quoteIdent(tableName)} WHERE ${orParts.join(" OR ")}`,
104
- params,
144
+ tableName,
145
+ predicate.sql,
146
+ predicate.params,
105
147
  );
106
148
  for (const row of rows) {
107
149
  const key = `${row.tenant_id}:${entityName}:${row.id}`;