@cosmicdrift/kumiko-framework 0.404.0 → 0.405.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.
Files changed (75) hide show
  1. package/AGENTS.md +103 -103
  2. package/README.md +19 -17
  3. package/agents/examples/apex-landing.md +3 -3
  4. package/agents/examples/apex-surface-auth.md +13 -9
  5. package/agents/examples/auth-mfa.md +2 -4
  6. package/agents/examples/boot-check.md +2 -2
  7. package/agents/examples/compliance-profiles-demo.md +5 -5
  8. package/agents/examples/compose-stacks.md +8 -8
  9. package/agents/examples/cross-feature-events.md +1 -0
  10. package/agents/examples/crypto-shredding-hr.md +3 -3
  11. package/agents/examples/custom-fields-basic.md +1 -1
  12. package/agents/examples/documents.md +28 -0
  13. package/agents/examples/encrypted-tenant-config.md +8 -8
  14. package/agents/examples/env-composition.md +10 -10
  15. package/agents/examples/error-contract.md +9 -9
  16. package/agents/examples/event-sourcing.md +9 -9
  17. package/agents/examples/ledger-invoicing.md +6 -6
  18. package/agents/examples/legal-pages.md +16 -16
  19. package/agents/examples/managed-config.md +15 -11
  20. package/agents/examples/managed-pages.md +7 -7
  21. package/agents/examples/pipeline-basics.md +13 -13
  22. package/agents/examples/public-share-token.md +4 -4
  23. package/agents/examples/security-baseline.md +4 -1
  24. package/agents/examples/session-revocation.md +1 -1
  25. package/agents/examples/tenant-settings.md +4 -4
  26. package/agents/examples/tier-admin.md +2 -2
  27. package/agents/examples/user-data-rights.md +21 -19
  28. package/dist/api/auth-routes-mfa.js +2 -2
  29. package/dist/api/index.d.ts +2 -2
  30. package/dist/api/index.js +1 -1
  31. package/dist/api/readiness.d.ts +0 -11
  32. package/dist/api/readiness.js +1 -30
  33. package/dist/crypto/index.d.ts +1 -0
  34. package/dist/crypto/index.js +1 -0
  35. package/dist/crypto/opaque-token-hash.d.ts +3 -0
  36. package/dist/crypto/opaque-token-hash.js +14 -0
  37. package/dist/crypto/subject-resolver.js +3 -8
  38. package/dist/db/blind-index-cleanup.js +3 -10
  39. package/dist/db/query.js +3 -18
  40. package/dist/derivatives/variant-key.d.ts +4 -0
  41. package/dist/derivatives/variant-key.js +4 -3
  42. package/dist/engine/boot-validator/field-conditions.js +2 -7
  43. package/dist/engine/boot-validator/list-table-aggregate-query.js +1 -1
  44. package/dist/engine/boot-validator/parent-ref.d.ts +0 -2
  45. package/dist/engine/boot-validator/parent-ref.js +1 -8
  46. package/dist/engine/boot-validator/projection-list-screens.js +1 -1
  47. package/dist/engine/boot-validator/read-gate.js +1 -1
  48. package/dist/engine/find-entity.d.ts +3 -0
  49. package/dist/engine/find-entity.js +8 -0
  50. package/dist/engine/project-app-schema-for-roles.js +19 -55
  51. package/dist/files/file-handle.js +3 -16
  52. package/dist/observability/ids.js +1 -8
  53. package/dist/pipeline/event-retention.js +3 -1
  54. package/dist/pipeline/projection-rebuild.js +3 -17
  55. package/dist/pipeline/tenant-timezone-cache.js +8 -27
  56. package/dist/search/index.d.ts +3 -0
  57. package/dist/search/index.js +3 -0
  58. package/dist/search/is-entity-searchable.d.ts +2 -0
  59. package/dist/search/is-entity-searchable.js +4 -0
  60. package/dist/search/meilisearch-peer.js +1 -1
  61. package/dist/search/reindex-tenants-search.d.ts +19 -0
  62. package/dist/search/reindex-tenants-search.js +69 -0
  63. package/dist/utils/chunk.d.ts +1 -0
  64. package/dist/utils/chunk.js +7 -0
  65. package/dist/utils/hex.d.ts +3 -0
  66. package/dist/utils/hex.js +11 -0
  67. package/dist/utils/index.d.ts +3 -0
  68. package/dist/utils/index.js +3 -0
  69. package/dist/utils/lru-cache.d.ts +17 -0
  70. package/dist/utils/lru-cache.js +56 -0
  71. package/dist/utils/run-with-timeout.d.ts +11 -0
  72. package/dist/utils/run-with-timeout.js +30 -0
  73. package/package.json +12 -12
  74. package/presets/biome-app.json +1 -1
  75. package/src/changes.json +36 -0
@@ -0,0 +1,69 @@
1
+ import { SYSTEM_TENANT_ID } from "../engine/types/index.js";
2
+ import { createFallbackLogger } from "../logging/utils.js";
3
+ import { runWithTimeout } from "../utils/run-with-timeout.js";
4
+ import { collectSearchableFieldNames } from "./configure-tenant-index.js";
5
+ import { isEntitySearchable } from "./is-entity-searchable.js";
6
+ import { reindexEntity } from "./reindex-entity.js";
7
+ const defaultLog = createFallbackLogger("search");
8
+ const DEFAULT_CONFIGURE_TIMEOUT_MS = 5_000;
9
+ const DEFAULT_REINDEX_TIMEOUT_MS = 60_000;
10
+ function messageOf(error) {
11
+ return error instanceof Error ? error.message : String(error);
12
+ }
13
+ // Reindexing an unconfigured index falls back to searchableAttributes ["*"],
14
+ // so a tenant whose configure failed is reported and skipped, not fatal.
15
+ async function configureTolerant(tenantIds, registry, searchAdapter, timeoutMs, warn) {
16
+ const fields = collectSearchableFieldNames(registry);
17
+ const configured = [];
18
+ let failedConfigures = 0;
19
+ for (const tenantId of tenantIds) {
20
+ const outcome = await runWithTimeout(() => searchAdapter.configure(tenantId, { searchableFields: fields, rankingFields: fields }), timeoutMs);
21
+ if (outcome.ok) {
22
+ configured.push(tenantId);
23
+ }
24
+ else {
25
+ failedConfigures += 1;
26
+ warn(`[search] configure for tenant ${tenantId} failed: ${messageOf(outcome.error)}`);
27
+ }
28
+ }
29
+ return { configured, failedConfigures };
30
+ }
31
+ export async function reindexTenantsSearch(db, registry, searchAdapter, options) {
32
+ const warn = options.warn ?? ((message) => defaultLog.warn(message));
33
+ const reindexTimeoutMs = options.timeoutMs ?? DEFAULT_REINDEX_TIMEOUT_MS;
34
+ const dryRun = options.dryRun === true;
35
+ const { configured, failedConfigures } = dryRun
36
+ ? { configured: [...options.tenantIds], failedConfigures: 0 }
37
+ : await configureTolerant(options.tenantIds, registry, searchAdapter, options.configureTimeoutMs ?? DEFAULT_CONFIGURE_TIMEOUT_MS, warn);
38
+ let entities = 0;
39
+ let indexedRows = 0;
40
+ let failedRows = 0;
41
+ let failedReindexes = 0;
42
+ for (const [entityName, entity] of registry.getAllEntities()) {
43
+ if (!isEntitySearchable(registry, entityName))
44
+ continue;
45
+ entities += 1;
46
+ const targetTenantIds = entity.systemStream === true ? [SYSTEM_TENANT_ID] : configured;
47
+ for (const tenantId of targetTenantIds) {
48
+ const outcome = await runWithTimeout(() => reindexEntity(db, registry, searchAdapter, entityName, tenantId, dryRun ? { dryRun } : {}), reindexTimeoutMs);
49
+ if (!outcome.ok) {
50
+ failedReindexes += 1;
51
+ warn(`[search] reindex "${entityName}" tenant=${tenantId} failed: ${messageOf(outcome.error)}`);
52
+ continue;
53
+ }
54
+ indexedRows += outcome.value.indexedRows;
55
+ if (outcome.value.failures.length > 0) {
56
+ failedRows += outcome.value.failures.length;
57
+ warn(`[search] reindex "${entityName}" tenant=${tenantId}: ${outcome.value.failures.length} row(s) failed, first: ${outcome.value.failures[0]?.reason}`);
58
+ }
59
+ }
60
+ }
61
+ return {
62
+ tenants: options.tenantIds.length,
63
+ entities,
64
+ indexedRows,
65
+ failedRows,
66
+ failedReindexes,
67
+ failedConfigures,
68
+ };
69
+ }
@@ -0,0 +1 @@
1
+ export declare function chunk<T>(items: readonly T[], size: number): T[][];
@@ -0,0 +1,7 @@
1
+ export function chunk(items, size) {
2
+ const chunks = [];
3
+ for (let start = 0; start < items.length; start += size) {
4
+ chunks.push(items.slice(start, start + size));
5
+ }
6
+ return chunks;
7
+ }
@@ -0,0 +1,3 @@
1
+ export type DigestAlgorithm = "SHA-1" | "SHA-256" | "SHA-384" | "SHA-512";
2
+ export declare function bytesToHex(bytes: Uint8Array): string;
3
+ export declare function digestHex(algorithm: DigestAlgorithm, text: string): Promise<string>;
@@ -0,0 +1,11 @@
1
+ export function bytesToHex(bytes) {
2
+ let out = "";
3
+ for (let i = 0; i < bytes.length; i++) {
4
+ out += (bytes[i] ?? 0).toString(16).padStart(2, "0");
5
+ }
6
+ return out;
7
+ }
8
+ export async function digestHex(algorithm, text) {
9
+ const digest = await crypto.subtle.digest(algorithm, new TextEncoder().encode(text));
10
+ return bytesToHex(new Uint8Array(digest));
11
+ }
@@ -1,6 +1,9 @@
1
1
  export { assertUnreachable } from "./assert.ts";
2
+ export { chunk } from "./chunk.ts";
3
+ export { bytesToHex, type DigestAlgorithm, digestHex } from "./hex.ts";
2
4
  export { generateDeterministicId, generateId } from "./ids.ts";
3
5
  export { isPlainObject } from "./is-plain-object.ts";
6
+ export { createLruCache, type LruCache, type LruCacheOptions } from "./lru-cache.ts";
4
7
  export { parseStringArrayJson } from "./parse-string-array-json.ts";
5
8
  export { parseJsonOrThrow, parseJsonSafe } from "./safe-json.ts";
6
9
  export { parseRoles } from "./serialization.ts";
@@ -1,6 +1,9 @@
1
1
  export { assertUnreachable } from "./assert.js";
2
+ export { chunk } from "./chunk.js";
3
+ export { bytesToHex, digestHex } from "./hex.js";
2
4
  export { generateDeterministicId, generateId } from "./ids.js";
3
5
  export { isPlainObject } from "./is-plain-object.js";
6
+ export { createLruCache } from "./lru-cache.js";
4
7
  export { parseStringArrayJson } from "./parse-string-array-json.js";
5
8
  export { parseJsonOrThrow, parseJsonSafe } from "./safe-json.js";
6
9
  export { parseRoles } from "./serialization.js";
@@ -0,0 +1,17 @@
1
+ export type LruCacheOptions<V> = {
2
+ readonly maxEntries: number;
3
+ /** Entries at least this old count as misses; omitted = no expiry. */
4
+ readonly ttlMs?: number;
5
+ readonly now?: () => number;
6
+ /** Summed `sizeOf` budget; a value larger than the whole budget is never cached. */
7
+ readonly maxTotalSize?: number;
8
+ readonly sizeOf?: (value: V) => number;
9
+ };
10
+ export type LruCache<K, V> = {
11
+ get(key: K): V | undefined;
12
+ set(key: K, value: V): void;
13
+ delete(key: K): void;
14
+ clear(): void;
15
+ readonly size: number;
16
+ };
17
+ export declare function createLruCache<K, V>(options: LruCacheOptions<V>): LruCache<K, V>;
@@ -0,0 +1,56 @@
1
+ // Map iteration is insertion order: re-inserting on a hit keeps the most recently used entry last.
2
+ export function createLruCache(options) {
3
+ const now = options.now ?? Date.now;
4
+ const sizeOf = options.sizeOf ?? (() => 0);
5
+ const entries = new Map();
6
+ let totalSize = 0;
7
+ function remove(key) {
8
+ const stored = entries.get(key);
9
+ // skip: key not cached, nothing to remove
10
+ if (stored === undefined)
11
+ return;
12
+ totalSize -= stored.size;
13
+ entries.delete(key);
14
+ }
15
+ function overBudget() {
16
+ if (entries.size > options.maxEntries)
17
+ return true;
18
+ return options.maxTotalSize !== undefined && totalSize > options.maxTotalSize;
19
+ }
20
+ return {
21
+ get(key) {
22
+ const hit = entries.get(key);
23
+ if (hit === undefined)
24
+ return undefined;
25
+ remove(key);
26
+ if (options.ttlMs !== undefined && now() - hit.storedAt >= options.ttlMs)
27
+ return undefined;
28
+ entries.set(key, hit);
29
+ totalSize += hit.size;
30
+ return hit.value;
31
+ },
32
+ set(key, value) {
33
+ remove(key);
34
+ const size = sizeOf(value);
35
+ // skip: a value larger than the whole budget would evict everything and still not fit
36
+ if (options.maxTotalSize !== undefined && size > options.maxTotalSize)
37
+ return;
38
+ entries.set(key, { value, storedAt: now(), size });
39
+ totalSize += size;
40
+ while (overBudget()) {
41
+ const oldest = entries.keys().next();
42
+ if (oldest.done)
43
+ break;
44
+ remove(oldest.value);
45
+ }
46
+ },
47
+ delete: remove,
48
+ clear() {
49
+ entries.clear();
50
+ totalSize = 0;
51
+ },
52
+ get size() {
53
+ return entries.size;
54
+ },
55
+ };
56
+ }
@@ -0,0 +1,11 @@
1
+ export type TimedRunResult<T> = {
2
+ readonly ok: true;
3
+ readonly value: T;
4
+ readonly latencyMs: number;
5
+ } | {
6
+ readonly ok: false;
7
+ readonly error: unknown;
8
+ readonly timedOut: boolean;
9
+ readonly latencyMs: number;
10
+ };
11
+ export declare function runWithTimeout<T>(run: () => Promise<T>, timeoutMs: number): Promise<TimedRunResult<T>>;
@@ -0,0 +1,30 @@
1
+ class TimeoutError extends Error {
2
+ }
3
+ export async function runWithTimeout(run, timeoutMs) {
4
+ const start = performance.now();
5
+ const elapsedMs = () => Math.round(performance.now() - start);
6
+ let timer;
7
+ try {
8
+ const value = await Promise.race([
9
+ run(),
10
+ new Promise((_, reject) => {
11
+ timer = setTimeout(() => reject(new TimeoutError(`timeout after ${timeoutMs}ms`)), timeoutMs);
12
+ // Don't keep the event loop alive on a hung run during shutdown.
13
+ timer.unref?.();
14
+ }),
15
+ ]);
16
+ return { ok: true, value, latencyMs: elapsedMs() };
17
+ }
18
+ catch (error) {
19
+ return {
20
+ ok: false,
21
+ error,
22
+ timedOut: error instanceof TimeoutError,
23
+ latencyMs: elapsedMs(),
24
+ };
25
+ }
26
+ finally {
27
+ if (timer)
28
+ clearTimeout(timer);
29
+ }
30
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.404.0",
3
+ "version": "0.405.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -203,21 +203,21 @@
203
203
  "./package.json": "./package.json"
204
204
  },
205
205
  "dependencies": {
206
- "@cosmicdrift/kumiko-types": "0.404.0",
207
- "bullmq": "^5.76.7",
206
+ "@cosmicdrift/kumiko-types": "0.405.0",
207
+ "bullmq": "^5.81.5",
208
208
  "hono": "^4.13.14",
209
- "i18next": "^26.1.0",
210
- "ioredis": "^5.10.1",
211
- "jose": "^6.2.13",
212
- "pino": "^10.3.1",
209
+ "i18next": "^26.5.12",
210
+ "ioredis": "^5.11.1",
211
+ "jose": "^6.2.14",
212
+ "pino": "^10.4.0",
213
213
  "temporal-polyfill": "^0.3.2",
214
- "uuid": "^14.0.0",
214
+ "uuid": "^14.0.3",
215
215
  "write-excel-file": "^4.1.1",
216
216
  "zod": "^4.6.7"
217
217
  },
218
218
  "peerDependencies": {
219
219
  "bun-types": "^1.4.4",
220
- "meilisearch": "^0.58.0"
220
+ "meilisearch": "^0.62.0"
221
221
  },
222
222
  "peerDependenciesMeta": {
223
223
  "meilisearch": {
@@ -225,11 +225,11 @@
225
225
  }
226
226
  },
227
227
  "devDependencies": {
228
- "@cosmicdrift/kumiko-testing": "0.404.0",
228
+ "@cosmicdrift/kumiko-testing": "0.405.0",
229
229
  "bun-types": "^1.4.4",
230
230
  "fflate": "^0.8.3",
231
- "meilisearch": "^0.58.0",
232
- "pino-pretty": "^13.1.3"
231
+ "meilisearch": "^0.62.0",
232
+ "pino-pretty": "^13.2.0"
233
233
  },
234
234
  "publishConfig": {
235
235
  "registry": "https://registry.npmjs.org",
@@ -1,5 +1,5 @@
1
1
  {
2
- "$schema": "https://biomejs.dev/schemas/2.5.7/schema.json",
2
+ "$schema": "https://biomejs.dev/schemas/2.6.4/schema.json",
3
3
  "vcs": {
4
4
  "enabled": true,
5
5
  "clientKind": "git",
package/src/changes.json CHANGED
@@ -1,4 +1,40 @@
1
1
  [
2
+ {
3
+ "version": "0.405.0",
4
+ "type": "improvement",
5
+ "title": "chunk in framework utils",
6
+ "detail": "@cosmicdrift/kumiko-framework/utils exports chunk(items, size), which splits an array into slices of at most size elements. files-tenant-data, subscription-paddle and template-resolver use it instead of local copies."
7
+ },
8
+ {
9
+ "version": "0.405.0",
10
+ "type": "improvement",
11
+ "title": "Search sweep helpers in framework/search",
12
+ "detail": "@cosmicdrift/kumiko-framework/search exports collectSearchableFieldNames, isEntitySearchable and reindexTenantsSearch(db, registry, adapter, { tenantIds, timeoutMs?, configureTimeoutMs?, dryRun?, warn? }). The sweep configures each given tenant index, then reindexes every searchable entity for those tenants (system-stream entities once under the system tenant) and returns counts of indexed rows and failures. A failed configure or reindex is reported through warn and does not stop the sweep. Apps no longer need their own configure-on-boot seed: the server's default search config is applied lazily to a missing or factory-default index."
13
+ },
14
+ {
15
+ "version": "0.405.0",
16
+ "type": "improvement",
17
+ "title": "createLruCache in framework utils",
18
+ "detail": "@cosmicdrift/kumiko-framework/utils exports createLruCache({ maxEntries, ttlMs?, now?, maxTotalSize?, sizeOf? }) with get, set, delete, clear and size. The tenant timezone cache and the managed-pages body cache use it. runWithTimeout moves to the utils module; the api barrel export stays."
19
+ },
20
+ {
21
+ "version": "0.405.0",
22
+ "type": "improvement",
23
+ "title": "Engine and db helpers deduplicated",
24
+ "detail": "The engine uses one findEntity lookup instead of three copies, the PII subject resolver and the boot validator use isFileField, insertOne builds its SQL through the same insert helpers as insertMany, the projection rebuild maps event rows through the shared toStoredEvent, and the app-schema role projection filters row, toolbar and related-list actions through one helper. No behavior change."
25
+ },
26
+ {
27
+ "version": "0.405.0",
28
+ "type": "improvement",
29
+ "title": "One key split for file variants and derivatives",
30
+ "detail": "deriveKey and the derivative key helpers split a storage key into base and extension through the same function, so both always agree on where the extension starts. No behavior change."
31
+ },
32
+ {
33
+ "version": "0.405.0",
34
+ "type": "improvement",
35
+ "title": "Opaque-token hash and hex helpers exported",
36
+ "detail": "@cosmicdrift/kumiko-framework/crypto exports hashOpaqueToken (async, Web Crypto), hashOpaqueTokenSync (node:crypto, for synchronous server paths) and opaqueTokenHashMatches (timing-safe comparison against a stored SHA-256 hex hash). @cosmicdrift/kumiko-framework/utils exports bytesToHex and digestHex(algorithm, text). The framework's own token hashes (personal access tokens, single-use token store, preauth MFA key, export download tokens, termination revocation) use them and produce the same hex as before. Apps that hash share or heartbeat tokens themselves can switch to these helpers without migrating stored hashes."
37
+ },
2
38
  {
3
39
  "version": "0.404.0",
4
40
  "type": "improvement",