@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.
|
|
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.
|
|
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.
|
|
201
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.176.2",
|
|
202
202
|
"bun-types": "^1.3.13",
|
|
203
203
|
"pino-pretty": "^13.1.3"
|
|
204
204
|
},
|
package/src/api/sse-broker.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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}`;
|