@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
@@ -1,4 +1,5 @@
1
1
  import { normalizeEventPiiSubject } from "@cosmicdrift/kumiko-types/handlers";
2
+ import { isFileField } from "../engine/field-helpers.js";
2
3
  import { isSelfPiiField } from "./is-self-pii-field.js";
3
4
  import { RECORD_ENTITY_PATTERN } from "./kms-adapter.js";
4
5
  // Thrown when a field IS pii-annotated but the row can't name its subject —
@@ -157,24 +158,18 @@ export function collectPiiSubjectFields(entity) {
157
158
  .filter(([, field]) => hasSubjectAnnotation(field))
158
159
  .map(([name]) => name);
159
160
  }
160
- function isFileReferenceField(field) {
161
- return (field.type === "file" ||
162
- field.type === "image" ||
163
- field.type === "files" ||
164
- field.type === "images");
165
- }
166
161
  // File columns hold only the fileRef uuid; the binary is erased by the file-ref
167
162
  // user-data hook, so these stay annotated (forget) but are never ciphertext.
168
163
  export function collectPiiEncryptedFields(entity) {
169
164
  return Object.entries(entity.fields)
170
- .filter(([, field]) => hasSubjectAnnotation(field) && !isFileReferenceField(field))
165
+ .filter(([, field]) => hasSubjectAnnotation(field) && !isFileField(field))
171
166
  .map(([name]) => name);
172
167
  }
173
168
  // The annotated file-reference fields: never ciphertext today, but events from the
174
169
  // old backfill (which encrypted them) still carry it and must replay.
175
170
  export function collectPiiFileReferenceFields(entity) {
176
171
  return Object.entries(entity.fields)
177
- .filter(([, field]) => hasSubjectAnnotation(field) && isFileReferenceField(field))
172
+ .filter(([, field]) => hasSubjectAnnotation(field) && isFileField(field))
178
173
  .map(([name]) => name);
179
174
  }
180
175
  /** Subject-annotated fields that may be plaintext in the derived search index. */
@@ -13,6 +13,7 @@
13
13
  import { collectLookupableFields } from "../crypto/blind-index.js";
14
14
  import { quoteIdent, subjectCiphertextLikePattern } from "../crypto/ciphertext-pattern.js";
15
15
  import { isSelfPiiField } from "../crypto/is-self-pii-field.js";
16
+ import { findEntity } from "../engine/find-entity.js";
16
17
  import { toSnakeCase } from "../utils/case.js";
17
18
  import { resolveTableName } from "./entity-table-meta.js";
18
19
  import { toPostgresArrayLiteral } from "./queries/postgres-array-literal.js";
@@ -115,14 +116,6 @@ export async function subjectRowExistsInTenant(db, features, subjectId, tenantId
115
116
  return false;
116
117
  });
117
118
  }
118
- function findEntityByExactName(features, entityName) {
119
- for (const feature of features.values()) {
120
- const entity = feature.entities?.[entityName];
121
- if (entity)
122
- return entity;
123
- }
124
- return undefined;
125
- }
126
119
  // Tighter than subjectRowExistsInTenant: the record subject names its own
127
120
  // entity, so this is a single-table lookup instead of a self-PII scan
128
121
  // across the whole registry.
@@ -132,7 +125,7 @@ function findEntityByExactName(features, entityName) {
132
125
  // at create) — a composite PK or shared table would break this.
133
126
  export async function recordRowExistsInTenant(db, features, entityName, recordId, tenantId) {
134
127
  return runInSystemRowLevelSecurityScope("record existence check spans tenants", async () => {
135
- const entity = findEntityByExactName(features, entityName);
128
+ const entity = findEntity(features, entityName);
136
129
  if (!entity)
137
130
  return false;
138
131
  const tableName = resolveTableName(entityName, entity, undefined);
@@ -159,7 +152,7 @@ export async function recordEventsOwnedExclusivelyByTenant(db, aggregateType, ag
159
152
  // tenant-scoped policy for the row's real owner (not the acting user's tenant).
160
153
  export async function recordRowOwningTenantId(db, features, entityName, recordId) {
161
154
  return runInSystemRowLevelSecurityScope("record owner lookup spans tenants", async () => {
162
- const entity = findEntityByExactName(features, entityName);
155
+ const entity = findEntity(features, entityName);
163
156
  if (!entity)
164
157
  return undefined;
165
158
  const tableName = resolveTableName(entityName, entity, undefined);
package/dist/db/query.js CHANGED
@@ -391,26 +391,11 @@ export async function insertOne(db, table, values) {
391
391
  if (managedDenied)
392
392
  throw managedDenied;
393
393
  const info = extractTableInfo(table);
394
- const entries = Object.entries(values)
395
- .filter(([k]) => info.hasColumn(k))
396
- .map(([k, v]) => {
397
- const col = info.columnOf(k);
398
- const pgType = info.pgTypeOf(col);
399
- return { col, prepared: prepareValue(v, pgType) };
400
- });
394
+ const entries = insertEntries(info, values);
401
395
  if (entries.length === 0)
402
396
  throw new Error("insertOne: empty values object");
403
- const cols = entries.map((e) => quoteIdent(e.col)).join(", ");
404
- const params = [];
405
- const placeholders = entries
406
- .map((e) => {
407
- if (e.prepared.kind === "literal")
408
- return e.prepared.literal;
409
- params.push(e.prepared.bound);
410
- return `$${params.length}${e.prepared.sql}`;
411
- })
412
- .join(", ");
413
- const sqlText = `INSERT INTO ${quoteIdent(info.name)} (${cols}) VALUES (${placeholders}) RETURNING *`;
397
+ const { sqlPrefix, params } = buildInsertSql(info, entries);
398
+ const sqlText = `${sqlPrefix} RETURNING *`;
414
399
  const rows = await unsafeLoggingClosedConnection(db, sqlText, params, {
415
400
  operation: "insertOne",
416
401
  table: info.name,
@@ -4,6 +4,10 @@ export declare function specHash(spec: VariantSpec): string;
4
4
  export declare const VARIANT_NAME_PATTERN: RegExp;
5
5
  export declare function variantSuffix(name: string, spec: VariantSpec): string;
6
6
  export declare function isDerivativeSuffix(suffix: string): boolean;
7
+ export declare function splitKey(key: string): {
8
+ readonly base: string;
9
+ readonly ext: string;
10
+ };
7
11
  export declare function derivativeListPrefix(originalKey: string): string;
8
12
  export declare function isDerivativeKeyOf(originalKey: string, candidateKey: string): boolean;
9
13
  export declare function parseDerivativeKey(candidateKey: string): {
@@ -41,9 +41,10 @@ const DERIVATIVE_SUFFIX_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9-]{0,31}-[0-9a-f]{16}$/
41
41
  export function isDerivativeSuffix(suffix) {
42
42
  return DERIVATIVE_SUFFIX_PATTERN.test(suffix);
43
43
  }
44
- // Mirrors deriveKey's own split so callers get the exact same base/ext this
45
- // key's derivatives were built from.
46
- function splitKey(key) {
44
+ // Splits on the last dot of the last path segment only, so `archive.v2/foo.jpg`
45
+ // yields base `archive.v2/foo`. No dot there: the whole key is the base, ext is
46
+ // empty. deriveKey uses the same split, so derivative keys line up with it.
47
+ export function splitKey(key) {
47
48
  const lastSlash = key.lastIndexOf("/");
48
49
  const lastSegment = lastSlash === -1 ? key : key.slice(lastSlash + 1);
49
50
  const lastDot = lastSegment.lastIndexOf(".");
@@ -1,5 +1,6 @@
1
1
  import { isFileSubField } from "../../files/file-field-slots.js";
2
2
  import { parseMaxSize } from "../../files/types.js";
3
+ import { isFileField } from "../field-helpers.js";
3
4
  import { conditionComparisonValues, LOCKABLE_FIELD_TYPES } from "./entity-handler.js";
4
5
  const STATE_KEYS = ["visibleWhen", "requiredWhen"];
5
6
  const SUB_CONDITION_TYPES = new Set([
@@ -178,15 +179,9 @@ function assertField(feature, entityName, entity, fieldName, field) {
178
179
  }
179
180
  if (field.type === "embedded")
180
181
  assertEmbeddedField(where, field);
181
- if (isFileAcceptField(field))
182
+ if (isFileField(field))
182
183
  assertAcceptEntries(where, field.accept);
183
184
  }
184
- function isFileAcceptField(field) {
185
- return (field.type === "file" ||
186
- field.type === "files" ||
187
- field.type === "image" ||
188
- field.type === "images");
189
- }
190
185
  export function validateFieldConditions(feature) {
191
186
  for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
192
187
  for (const [fieldName, field] of Object.entries(entity.fields)) {
@@ -1,7 +1,7 @@
1
1
  import { resolveEntityBehindListQuery } from "../entity-list-searchability.js";
2
+ import { findEntity } from "../find-entity.js";
2
3
  import { normalizeListColumn } from "../screen-helpers.js";
3
4
  import { entityAggregateFieldLookup, validateListTableAggregateFieldTypes, } from "./list-table-aggregate-options.js";
4
- import { findEntity } from "./parent-ref.js";
5
5
  import { getZodObjectShape } from "./zod-shape.js";
6
6
  function columnValueTypeLookup(columns) {
7
7
  const valueTypes = new Map(columns
@@ -1,4 +1,2 @@
1
- import type { EntityDefinition } from "@cosmicdrift/kumiko-types/fields";
2
1
  import type { FeatureDefinition } from "../types/index.ts";
3
- export declare function findEntity(featureMap: ReadonlyMap<string, FeatureDefinition>, entityName: string): EntityDefinition | undefined;
4
2
  export declare function validateParentRefs(feature: FeatureDefinition, featureMap: ReadonlyMap<string, FeatureDefinition>): void;
@@ -1,4 +1,5 @@
1
1
  import { ENTITY_CONVENTION_QUERY_BRAND } from "@cosmicdrift/kumiko-types/handlers";
2
+ import { findEntity } from "../find-entity.js";
2
3
  // --- Parent-ref boot validation ---
3
4
  //
4
5
  // Join-row entities (EntityDefinition.parentRef) derive both the write-path
@@ -8,14 +9,6 @@ import { ENTITY_CONVENTION_QUERY_BRAND } from "@cosmicdrift/kumiko-types/handler
8
9
  // was supplied — throwing there would 500 every custom handler and
9
10
  // framework-internal detail() call that doesn't wire it, so the enforcement
10
11
  // has to happen loudly here instead, once, at boot.
11
- export function findEntity(featureMap, entityName) {
12
- for (const f of featureMap.values()) {
13
- const entity = f.entities?.[entityName];
14
- if (entity)
15
- return entity;
16
- }
17
- return undefined;
18
- }
19
12
  function findQueryHandler(featureMap, handlerName) {
20
13
  for (const f of featureMap.values()) {
21
14
  const handler = f.queryHandlers[handlerName];
@@ -1,11 +1,11 @@
1
1
  import { isListQueryWithoutSearchableFields, resolveEntityBehindListQuery, } from "../entity-list-searchability.js";
2
+ import { findEntity } from "../find-entity.js";
2
3
  import { QnTypes, qualifyEntityName } from "../qualified-name.js";
3
4
  import { isEditLayoutScreen } from "../screen-helpers.js";
4
5
  import { bootRuleError } from "./boot-rule.js";
5
6
  import { SEARCHABLE_FALSE_WHITELIST } from "./entity-list-screens.js";
6
7
  import { validateListTableAggregateQuery } from "./list-table-aggregate-query.js";
7
8
  import { NAMED_FILTER_RULES } from "./named-filter-rules.js";
8
- import { findEntity } from "./parent-ref.js";
9
9
  import { getZodObjectShape } from "./zod-shape.js";
10
10
  // Sibling to entity-list-screens.ts rather than an extension of it:
11
11
  // validateOneEntityListScreen is typed to EntityListScreenDefinition and
@@ -1,6 +1,6 @@
1
1
  import { ENTITY_CONVENTION_QUERY_BRAND } from "@cosmicdrift/kumiko-types/handlers";
2
2
  import { FILE_REF_ENTITY_NAME } from "../../files/types.js";
3
- import { findEntity } from "./parent-ref.js";
3
+ import { findEntity } from "../find-entity.js";
4
4
  function fail(check, message) {
5
5
  throw new Error(`[Kumiko ReadGate] entity "${check.entityName}" (feature: "${check.feature.name}"): ${message}`);
6
6
  }
@@ -0,0 +1,3 @@
1
+ import type { EntityDefinition } from "./types/fields.ts";
2
+ import type { FeatureDefinition } from "./types/index.ts";
3
+ export declare function findEntity(features: ReadonlyMap<string, FeatureDefinition>, entityName: string): EntityDefinition | undefined;
@@ -0,0 +1,8 @@
1
+ export function findEntity(features, entityName) {
2
+ for (const feature of features.values()) {
3
+ const entity = feature.entities?.[entityName];
4
+ if (entity)
5
+ return entity;
6
+ }
7
+ return undefined;
8
+ }
@@ -255,62 +255,26 @@ function keepTreeAction(action, keptScreenQns) {
255
255
  return true; // target-based dispatch, not a screen navigate
256
256
  return keptScreenQns.has(action.screen);
257
257
  }
258
- // --- RowAction / ToolbarAction / RelatedListToolbarAction array projectors ---
259
- // Each returns the ORIGINAL array reference when nothing was filtered, and
260
- // `undefined` (never an empty array) when everything was — same JSON-safety
261
- // convention as buildAppSchema.
262
- function keepRowAction(action, feature, indices, keptScreenQns) {
258
+ function keepScreenTargetAction(action, feature, indices, keptScreenQns) {
263
259
  if (action.kind === "drawer")
264
260
  return isDrawerTargetKept(feature, action.screen, keptScreenQns);
265
261
  if (action.kind === "navigate")
266
262
  return isNavigateTargetKept(action, indices, keptScreenQns);
267
- return true; // writeHandler — no screen target
263
+ return true; // no screen target
268
264
  }
269
- function projectRowActionArray(actions, feature, indices, keptScreenQns) {
265
+ function filterKeptActions(actions, keep) {
270
266
  if (actions === undefined)
271
267
  return undefined;
272
- const kept = actions.filter((a) => keepRowAction(a, feature, indices, keptScreenQns));
268
+ const kept = actions.filter(keep);
273
269
  if (kept.length === actions.length)
274
270
  return actions;
275
271
  return kept.length > 0 ? kept : undefined;
276
272
  }
277
- function keepToolbarAction(action, feature, indices, keptScreenQns) {
278
- if (action.kind === "navigate")
279
- return isNavigateTargetKept(action, indices, keptScreenQns);
280
- if (action.kind === "drawer")
281
- return isDrawerTargetKept(feature, action.screen, keptScreenQns);
282
- return true;
283
- }
284
- function projectToolbarActionArray(actions, feature, indices, keptScreenQns) {
285
- if (actions === undefined)
286
- return undefined;
287
- const kept = actions.filter((a) => keepToolbarAction(a, feature, indices, keptScreenQns));
288
- if (kept.length === actions.length)
289
- return actions;
290
- return kept.length > 0 ? kept : undefined;
291
- }
292
- function keepRelatedListToolbarAction(action, feature, indices, keptScreenQns) {
293
- if (action.kind === "navigate")
294
- return isNavigateTargetKept(action, indices, keptScreenQns);
295
- if (action.kind === "drawer")
296
- return isDrawerTargetKept(feature, action.screen, keptScreenQns);
297
- return true;
298
- }
299
- function projectRelatedListToolbarActionArray(actions, feature, indices, keptScreenQns) {
300
- if (actions === undefined)
301
- return undefined;
302
- const kept = actions.filter((a) => keepRelatedListToolbarAction(a, feature, indices, keptScreenQns));
303
- if (kept.length === actions.length)
304
- return actions;
305
- return kept.length > 0 ? kept : undefined;
273
+ function projectActionArray(actions, feature, indices, keptScreenQns) {
274
+ return filterKeptActions(actions, (a) => keepScreenTargetAction(a, feature, indices, keptScreenQns));
306
275
  }
307
276
  function projectTreeActionArray(actions, keptScreenQns) {
308
- if (actions === undefined)
309
- return undefined;
310
- const kept = actions.filter((a) => keepTreeAction(a, keptScreenQns));
311
- if (kept.length === actions.length)
312
- return actions;
313
- return kept.length > 0 ? kept : undefined;
277
+ return filterKeptActions(actions, (a) => keepTreeAction(a, keptScreenQns));
314
278
  }
315
279
  function projectTreeAction(action, keptScreenQns) {
316
280
  if (action === undefined)
@@ -329,16 +293,16 @@ function projectEmptyState(emptyState, feature, indices, keptScreenQns) {
329
293
  return undefined;
330
294
  if (emptyState.action === undefined)
331
295
  return emptyState;
332
- if (keepRowAction(emptyState.action, feature, indices, keptScreenQns))
296
+ if (keepScreenTargetAction(emptyState.action, feature, indices, keptScreenQns))
333
297
  return emptyState;
334
298
  const { action, ...rest } = emptyState;
335
299
  return rest;
336
300
  }
337
301
  function projectRelatedListSection(section, feature, indices, keptScreenQns) {
338
302
  const { actions, rowActions, toolbarActions, rowClick, emptyState, ...rest } = section;
339
- const nextActions = projectRowActionArray(actions, feature, indices, keptScreenQns);
340
- const nextRowActions = projectRowActionArray(rowActions, feature, indices, keptScreenQns);
341
- const nextToolbarActions = projectRelatedListToolbarActionArray(toolbarActions, feature, indices, keptScreenQns);
303
+ const nextActions = projectActionArray(actions, feature, indices, keptScreenQns);
304
+ const nextRowActions = projectActionArray(rowActions, feature, indices, keptScreenQns);
305
+ const nextToolbarActions = projectActionArray(toolbarActions, feature, indices, keptScreenQns);
342
306
  const nextRowClick = rowClick === undefined || isRowClickEntityKept(rowClick.entity, indices, keptScreenQns)
343
307
  ? rowClick
344
308
  : undefined;
@@ -365,18 +329,18 @@ function projectEditSection(section, feature, indices, keptScreenQns) {
365
329
  return projectRelatedListSection(section, feature, indices, keptScreenQns);
366
330
  case "extension": {
367
331
  const { actions, ...rest } = section;
368
- const kept = projectRowActionArray(actions, feature, indices, keptScreenQns);
332
+ const kept = projectActionArray(actions, feature, indices, keptScreenQns);
369
333
  return kept === actions ? section : { ...rest, ...(kept !== undefined && { actions: kept }) };
370
334
  }
371
335
  case "writeForm": {
372
336
  const { actions, ...rest } = section;
373
- const kept = projectRowActionArray(actions, feature, indices, keptScreenQns);
337
+ const kept = projectActionArray(actions, feature, indices, keptScreenQns);
374
338
  return kept === actions ? section : { ...rest, ...(kept !== undefined && { actions: kept }) };
375
339
  }
376
340
  default: {
377
341
  // kind undefined or "fields"
378
342
  const { actions, ...rest } = section;
379
- const kept = projectRowActionArray(actions, feature, indices, keptScreenQns);
343
+ const kept = projectActionArray(actions, feature, indices, keptScreenQns);
380
344
  return kept === actions ? section : { ...rest, ...(kept !== undefined && { actions: kept }) };
381
345
  }
382
346
  }
@@ -471,8 +435,8 @@ function dropUnreachableCreateScreen(screen, feature, keptScreenQns) {
471
435
  function projectListScreen(original, feature, indices, keptScreenQns) {
472
436
  const screen = dropUnreachableCreateScreen(original, feature, keptScreenQns);
473
437
  const { rowActions, toolbarActions, ...rest } = screen;
474
- const nextRowActions = projectRowActionArray(rowActions, feature, indices, keptScreenQns);
475
- const nextToolbarActions = projectToolbarActionArray(toolbarActions, feature, indices, keptScreenQns);
438
+ const nextRowActions = projectActionArray(rowActions, feature, indices, keptScreenQns);
439
+ const nextToolbarActions = projectActionArray(toolbarActions, feature, indices, keptScreenQns);
476
440
  const expandableRow = screen.type === "entityList" ? screen.expandableRow : undefined;
477
441
  const nextExpandableRow = expandableRow === undefined
478
442
  ? undefined
@@ -493,7 +457,7 @@ function projectProjectionDetailScreen(screen, feature, indices, keptScreenQns)
493
457
  const { metrics, header, actions, layout, listScreenId, ...rest } = screen;
494
458
  const nextMetrics = projectMetrics(metrics, indices, keptScreenQns);
495
459
  const nextHeader = projectHeader(header, indices, keptScreenQns);
496
- const nextActions = projectRowActionArray(actions, feature, indices, keptScreenQns);
460
+ const nextActions = projectActionArray(actions, feature, indices, keptScreenQns);
497
461
  const nextLayout = projectEditLayout(layout, feature, indices, keptScreenQns);
498
462
  const nextListScreenId = projectListScreenId(listScreenId, indices, keptScreenQns);
499
463
  const unchanged = nextMetrics === metrics &&
@@ -515,7 +479,7 @@ function projectProjectionDetailScreen(screen, feature, indices, keptScreenQns)
515
479
  function projectEntityEditScreen(screen, feature, indices, keptScreenQns) {
516
480
  const { redirect, actions, layout, listScreenId, ...rest } = screen;
517
481
  const nextRedirect = projectRedirect(feature.featureName, redirect, keptScreenQns);
518
- const nextActions = projectRowActionArray(actions, feature, indices, keptScreenQns);
482
+ const nextActions = projectActionArray(actions, feature, indices, keptScreenQns);
519
483
  const nextLayout = projectEditLayout(layout, feature, indices, keptScreenQns);
520
484
  const nextListScreenId = projectListScreenId(listScreenId, indices, keptScreenQns);
521
485
  const unchanged = nextRedirect === redirect &&
@@ -592,7 +556,7 @@ function projectScreen(screen, feature, indices, keptScreenQns) {
592
556
  case "dashboard": {
593
557
  const { actions, ...rest } = screen;
594
558
  const nextPanels = projectDashboardPanels(screen.panels, feature, keptScreenQns);
595
- const nextActions = projectRowActionArray(actions, feature, indices, keptScreenQns);
559
+ const nextActions = projectActionArray(actions, feature, indices, keptScreenQns);
596
560
  if (nextPanels === screen.panels && nextActions === actions)
597
561
  return screen;
598
562
  return {
@@ -10,7 +10,7 @@
10
10
  // suffix before the file extension — `foo/bar.jpg` + `"medium-<16 hex>"` →
11
11
  // `foo/bar.medium-<16 hex>.jpg`. The suffix must be a `variantSuffix(...)` so
12
12
  // the erasure sweep recognizes it. Stable, reversible, no extra lookup tables.
13
- import { derivativeListPrefix, isDerivativeSuffix } from "../derivatives/variant-key.js";
13
+ import { derivativeListPrefix, isDerivativeSuffix, splitKey } from "../derivatives/variant-key.js";
14
14
  import { InternalError } from "../errors/index.js";
15
15
  import { assertSafeStorageKey } from "./types.js";
16
16
  // `getProvider` is a lazily-resolved, memoized accessor — the provider is
@@ -100,26 +100,13 @@ export function createFileContext(resolve, tenantId, uploadLedger) {
100
100
  export function createTenantFileContext(resolver, tenantId) {
101
101
  return createFileContext(() => resolver(tenantId), tenantId, resolver.uploadLedgerFor?.(tenantId));
102
102
  }
103
- // Splits a key into the part before the last dot of its last path segment
104
- // (the "stem") and the extension including the dot. No dot in the last
105
- // segment: the whole key is the stem, extension is empty.
106
- function splitKeyExtension(key) {
107
- const lastSlash = key.lastIndexOf("/");
108
- const lastSegment = lastSlash === -1 ? key : key.slice(lastSlash + 1);
109
- const lastDot = lastSegment.lastIndexOf(".");
110
- if (lastDot === -1)
111
- return { stem: key, ext: "" };
112
- const stem = key.slice(0, key.length - lastSegment.length + lastDot);
113
- const ext = lastSegment.slice(lastDot);
114
- return { stem, ext };
115
- }
116
103
  // Inserts a suffix before the file extension. Keys without an extension get
117
104
  // the suffix appended with a dot: `foo/bar` + `"small"` → `foo/bar.small`.
118
105
  // Keys with a dot earlier in the path (e.g. `archive.v2/foo.jpg`) correctly
119
106
  // split on the LAST segment only.
120
107
  export function deriveKey(key, suffix) {
121
- const { stem, ext } = splitKeyExtension(key);
122
- return ext === "" ? `${key}.${suffix}` : `${stem}.${suffix}${ext}`;
108
+ const { base, ext } = splitKey(key);
109
+ return ext === "" ? `${key}.${suffix}` : `${base}.${suffix}${ext}`;
123
110
  }
124
111
  // The prefix that covers a key AND every `derive()`d variant of it — `foo/bar.jpg` → `foo/bar.`,
125
112
  // matching `foo/bar.jpg` and `foo/bar.medium.jpg` alike, since derive() only ever inserts a suffix
@@ -2,14 +2,7 @@
2
2
  // collectors and UIs understand what we emit:
3
3
  // - traceId: 16 random bytes, rendered as 32 lowercase hex chars
4
4
  // - spanId: 8 random bytes, rendered as 16 lowercase hex chars
5
- function bytesToHex(bytes) {
6
- let out = "";
7
- for (let i = 0; i < bytes.length; i++) {
8
- const b = bytes[i] ?? 0;
9
- out += b.toString(16).padStart(2, "0");
10
- }
11
- return out;
12
- }
5
+ import { bytesToHex } from "../utils/hex.js";
13
6
  export function generateTraceId() {
14
7
  const bytes = new Uint8Array(16);
15
8
  crypto.getRandomValues(bytes);
@@ -68,7 +68,9 @@ export async function pruneEvents(db, options) {
68
68
  if (candidates.length === 0) {
69
69
  return { deletedCount: 0, cutoff, aggregateTypes, dryRun };
70
70
  }
71
- const maxCandidateId = candidates.reduce((acc, row) => (row.id > acc ? row.id : acc), candidates[0]?.id ?? 0n);
71
+ const maxCandidateId = candidates.reduce(
72
+ // biome-ignore lint/complexity/useMathMinMax: ids are bigint, Math.max throws a TypeError on BigInt
73
+ (acc, row) => (row.id > acc ? row.id : acc), candidates[0]?.id ?? 0n);
72
74
  // Step 2 — safety guard: check every ACTIVE consumer has moved past
73
75
  // the candidate set. Disabled consumers are intentionally excluded —
74
76
  // ops disables them precisely to park them while pruning happens.
@@ -6,8 +6,9 @@ import { runInSystemRowLevelSecurityScope } from "../db/row-level-security-scope
6
6
  import { withoutStatementTimeout } from "../db/statement-timeout.js";
7
7
  import { extractTableInfo } from "../db/table-info.js";
8
8
  import { withExecutorManagedWrites } from "../db/tenant-db.js";
9
- import { eventsTable, getEventsHighWaterMark, upcastStoredEvent, } from "../event-store/index.js";
9
+ import { eventsTable, getEventsHighWaterMark, upcastStoredEvent } from "../event-store/index.js";
10
10
  import { createRebuildDeadLetterTable, recordRebuildDeadLetters, } from "../event-store/rebuild-dead-letter.js";
11
+ import { toStoredEvent } from "../event-store/row-to-stored-event.js";
11
12
  import { createFallbackLogger } from "../logging/utils.js";
12
13
  import { emitProjectionRebuild } from "../observability/standard-metrics.js";
13
14
  import { projectionStateTable } from "./projection-state.js";
@@ -22,21 +23,6 @@ export const REBUILD_BATCH_SIZE = 1000;
22
23
  // live table is held ACCESS EXCLUSIVE).
23
24
  const MAX_UNLOCKED_BATCHES = 10_000;
24
25
  const DEFAULT_FENCE_LOCK_TIMEOUT_MS = 5_000;
25
- function rowToStoredEvent(row) {
26
- return {
27
- id: String(row.id),
28
- aggregateId: row.aggregateId,
29
- aggregateType: row.aggregateType,
30
- tenantId: row.tenantId,
31
- version: row.version,
32
- type: row.type,
33
- eventVersion: row.eventVersion,
34
- payload: row.payload,
35
- metadata: row.metadata,
36
- createdAt: row.createdAt,
37
- createdBy: row.createdBy,
38
- };
39
- }
40
26
  const TENANT_COLUMN_SUFFIX = ".tenant_id";
41
27
  // Ops-facing log line for countColumnDrift's result. No-op when clean.
42
28
  function warnOnColumnDrift(projectionName, tableName, drift) {
@@ -93,7 +79,7 @@ async function replayProjectionIntoShadow(projectionName, deps) {
93
79
  for (const r of rawEvents) {
94
80
  deps.signal?.throwIfAborted();
95
81
  const row = coerceRow(r, eventsInfo);
96
- const storedEvent = await upcastStoredEvent(rowToStoredEvent(row), upcasters, {
82
+ const storedEvent = await upcastStoredEvent(toStoredEvent(row), upcasters, {
97
83
  db: tx,
98
84
  tenantId: row.tenantId, // @cast-boundary db-row
99
85
  });
@@ -8,6 +8,7 @@
8
8
  // the write. The TTL below therefore bounds how long OTHER replicas (and
9
9
  // writes that bypass that path: migrations, seeds, direct DB edits) keep
10
10
  // serving the old value, hence the short default.
11
+ import { createLruCache } from "../utils/lru-cache.js";
11
12
  const DEFAULT_TTL_MS = 30 * 1000;
12
13
  const DEFAULT_MAX_ENTRIES = 1000;
13
14
  export function createTenantTimezoneCache(opts = {}) {
@@ -15,37 +16,17 @@ export function createTenantTimezoneCache(opts = {}) {
15
16
  // 0 or negative would never evict (size < max is never true) and grow unbounded.
16
17
  const maxEntries = Math.max(1, opts.maxEntries ?? DEFAULT_MAX_ENTRIES);
17
18
  const now = opts.now ?? (() => Date.now());
18
- // Map insertion order doubles as LRU order: touch = delete+re-insert.
19
- const entries = new Map();
19
+ const entries = createLruCache({
20
+ maxEntries,
21
+ ttlMs,
22
+ now,
23
+ });
20
24
  let generation = 0;
21
- function evictOldestIfFull() {
22
- // skip: cache has room, nothing to evict
23
- if (entries.size < maxEntries)
24
- return;
25
- const oldestKey = entries.keys().next().value;
26
- // skip: defensive — maxEntries >= 1 guarantees a non-empty map here
27
- if (oldestKey === undefined)
28
- return;
29
- entries.delete(oldestKey);
30
- }
31
25
  function set(tenantId, value) {
32
- entries.delete(tenantId);
33
- evictOldestIfFull();
34
- entries.set(tenantId, { value, expiresAt: now() + ttlMs });
26
+ entries.set(tenantId, { value });
35
27
  }
36
28
  return {
37
- get(tenantId) {
38
- const hit = entries.get(tenantId);
39
- if (!hit)
40
- return undefined;
41
- if (hit.expiresAt <= now()) {
42
- entries.delete(tenantId);
43
- return undefined;
44
- }
45
- entries.delete(tenantId);
46
- entries.set(tenantId, hit);
47
- return { value: hit.value };
48
- },
29
+ get: (tenantId) => entries.get(tenantId),
49
30
  set,
50
31
  generation() {
51
32
  return generation;
@@ -1,4 +1,7 @@
1
+ export { collectSearchableFieldNames } from "./configure-tenant-index.ts";
1
2
  export { createInMemorySearchAdapter } from "./in-memory-adapter.ts";
3
+ export { isEntitySearchable } from "./is-entity-searchable.ts";
2
4
  export { purgeSearchDocumentsForSubject } from "./purge-subject.ts";
3
5
  export { reindexEntity } from "./reindex-entity.ts";
6
+ export { type ReindexTenantsSearchOptions, type ReindexTenantsSearchResult, reindexTenantsSearch, } from "./reindex-tenants-search.ts";
4
7
  export type { SearchAdapter, SearchAdapterConfig, } from "./types.ts";
@@ -2,6 +2,9 @@
2
2
  // `@cosmicdrift/kumiko-framework/search/meilisearch`. This keeps the main barrel from loading a
3
3
  // Meilisearch client when consumers only use SearchAdapter types, so apps that do not use
4
4
  // Meilisearch do not pull in the client code.
5
+ export { collectSearchableFieldNames } from "./configure-tenant-index.js";
5
6
  export { createInMemorySearchAdapter } from "./in-memory-adapter.js";
7
+ export { isEntitySearchable } from "./is-entity-searchable.js";
6
8
  export { purgeSearchDocumentsForSubject } from "./purge-subject.js";
7
9
  export { reindexEntity } from "./reindex-entity.js";
10
+ export { reindexTenantsSearch, } from "./reindex-tenants-search.js";
@@ -0,0 +1,2 @@
1
+ import type { Registry } from "../engine/types/index.ts";
2
+ export declare function isEntitySearchable(registry: Registry, entityName: string): boolean;
@@ -0,0 +1,4 @@
1
+ export function isEntitySearchable(registry, entityName) {
2
+ return (registry.getSearchableFields(entityName).length > 0 ||
3
+ registry.getSearchPayloadExtensions(entityName).length > 0);
4
+ }
@@ -1,5 +1,5 @@
1
1
  export const MEILISEARCH_PEER = {
2
2
  name: "meilisearch",
3
- range: "^0.58.0",
3
+ range: "^0.62.0",
4
4
  requiredBy: "@cosmicdrift/kumiko-framework/search/meilisearch",
5
5
  };
@@ -0,0 +1,19 @@
1
+ import type { DbRunner } from "../db/connection.ts";
2
+ import { type Registry, type TenantId } from "../engine/types/index.ts";
3
+ import type { SearchAdapter } from "./types.ts";
4
+ export type ReindexTenantsSearchOptions = {
5
+ readonly tenantIds: readonly TenantId[];
6
+ readonly timeoutMs?: number;
7
+ readonly configureTimeoutMs?: number;
8
+ readonly dryRun?: boolean;
9
+ readonly warn?: (message: string) => void;
10
+ };
11
+ export type ReindexTenantsSearchResult = {
12
+ readonly tenants: number;
13
+ readonly entities: number;
14
+ readonly indexedRows: number;
15
+ readonly failedRows: number;
16
+ readonly failedReindexes: number;
17
+ readonly failedConfigures: number;
18
+ };
19
+ export declare function reindexTenantsSearch(db: DbRunner, registry: Registry, searchAdapter: SearchAdapter, options: ReindexTenantsSearchOptions): Promise<ReindexTenantsSearchResult>;