@cosmicdrift/kumiko-framework 0.404.1 → 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 (45) hide show
  1. package/dist/api/auth-routes-mfa.js +2 -2
  2. package/dist/api/index.d.ts +2 -2
  3. package/dist/api/index.js +1 -1
  4. package/dist/api/readiness.d.ts +0 -11
  5. package/dist/api/readiness.js +1 -30
  6. package/dist/crypto/index.d.ts +1 -0
  7. package/dist/crypto/index.js +1 -0
  8. package/dist/crypto/opaque-token-hash.d.ts +3 -0
  9. package/dist/crypto/opaque-token-hash.js +14 -0
  10. package/dist/crypto/subject-resolver.js +3 -8
  11. package/dist/db/blind-index-cleanup.js +3 -10
  12. package/dist/db/query.js +3 -18
  13. package/dist/derivatives/variant-key.d.ts +4 -0
  14. package/dist/derivatives/variant-key.js +4 -3
  15. package/dist/engine/boot-validator/field-conditions.js +2 -7
  16. package/dist/engine/boot-validator/list-table-aggregate-query.js +1 -1
  17. package/dist/engine/boot-validator/parent-ref.d.ts +0 -2
  18. package/dist/engine/boot-validator/parent-ref.js +1 -8
  19. package/dist/engine/boot-validator/projection-list-screens.js +1 -1
  20. package/dist/engine/boot-validator/read-gate.js +1 -1
  21. package/dist/engine/find-entity.d.ts +3 -0
  22. package/dist/engine/find-entity.js +8 -0
  23. package/dist/engine/project-app-schema-for-roles.js +19 -55
  24. package/dist/files/file-handle.js +3 -16
  25. package/dist/observability/ids.js +1 -8
  26. package/dist/pipeline/projection-rebuild.js +3 -17
  27. package/dist/pipeline/tenant-timezone-cache.js +8 -27
  28. package/dist/search/index.d.ts +3 -0
  29. package/dist/search/index.js +3 -0
  30. package/dist/search/is-entity-searchable.d.ts +2 -0
  31. package/dist/search/is-entity-searchable.js +4 -0
  32. package/dist/search/reindex-tenants-search.d.ts +19 -0
  33. package/dist/search/reindex-tenants-search.js +69 -0
  34. package/dist/utils/chunk.d.ts +1 -0
  35. package/dist/utils/chunk.js +7 -0
  36. package/dist/utils/hex.d.ts +3 -0
  37. package/dist/utils/hex.js +11 -0
  38. package/dist/utils/index.d.ts +3 -0
  39. package/dist/utils/index.js +3 -0
  40. package/dist/utils/lru-cache.d.ts +17 -0
  41. package/dist/utils/lru-cache.js +56 -0
  42. package/dist/utils/run-with-timeout.d.ts +11 -0
  43. package/dist/utils/run-with-timeout.js +30 -0
  44. package/package.json +4 -4
  45. package/src/changes.json +36 -0
@@ -1,5 +1,5 @@
1
- import { createHash } from "node:crypto";
2
1
  import * as z from "zod";
2
+ import { hashOpaqueTokenSync } from "../crypto/opaque-token-hash.js";
3
3
  import { createAnonymousUser } from "../engine/system-user.js";
4
4
  import { SYSTEM_TENANT_ID } from "../engine/types/index.js";
5
5
  import { Routes } from "./api-constants.js";
@@ -36,7 +36,7 @@ const MfaPreauthConfirmBody = z.object({
36
36
  // login.write.ts), so hashing it into a bucket key still caps the replay
37
37
  // even when the IP-based bucket is bypassed via header rotation.
38
38
  function preauthTokenKeyOf(token) {
39
- return `preauth-token:${createHash("sha256").update(token).digest("hex")}`;
39
+ return `preauth-token:${hashOpaqueTokenSync(token)}`;
40
40
  }
41
41
  export function registerMfaVerifyRoute(ctx) {
42
42
  const { api, dispatcher, config, getClientIp, mintSessionAndRespond, landingPathFragment } = ctx;
@@ -1,4 +1,6 @@
1
1
  export { DEFAULT_GLOBAL_RATE_LIMIT } from "../rate-limit/middleware.ts";
2
+ export type { TimedRunResult } from "../utils/run-with-timeout.ts";
3
+ export { runWithTimeout } from "../utils/run-with-timeout.ts";
2
4
  export { setTenantCookie } from "./anonymous-cookie.ts";
3
5
  export { collectAnonymousLiveEntities } from "./anonymous-live-entities.ts";
4
6
  export { LOCALE_HEADER_NAME, NO_ROUTE_MATCH_HEADER_NAME } from "./api-constants.ts";
@@ -16,8 +18,6 @@ export type { JwtHelper } from "./jwt.ts";
16
18
  export { loadJwtSecretOrKeyring } from "./jwt.ts";
17
19
  export { publicOriginOf } from "./origin-middleware.ts";
18
20
  export type { PostAuthLandingArgs, PostAuthLandingResolver, } from "./post-auth-landing.ts";
19
- export type { TimedRunResult } from "./readiness.ts";
20
- export { runWithTimeout } from "./readiness.ts";
21
21
  export type { RedisSseBroker } from "./redis-sse-broker.ts";
22
22
  export { createDefaultSseBroker, createRedisSseBroker, isRedisSseBroker, } from "./redis-sse-broker.ts";
23
23
  export { readonlyRequestContext as requestContext } from "./request-context-scopes.ts";
package/dist/api/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { DEFAULT_GLOBAL_RATE_LIMIT } from "../rate-limit/middleware.js";
2
+ export { runWithTimeout } from "../utils/run-with-timeout.js";
2
3
  export { setTenantCookie } from "./anonymous-cookie.js";
3
4
  export { collectAnonymousLiveEntities } from "./anonymous-live-entities.js";
4
5
  export { LOCALE_HEADER_NAME, NO_ROUTE_MATCH_HEADER_NAME } from "./api-constants.js";
@@ -9,7 +10,6 @@ export { ExtraRouteRejection, signatureRoute } from "./extra-route.js";
9
10
  export { cacheControlHeader, cachedResponse, computeRevisionEtag, computeStrongEtag, computeWeakEtag, etagMatches, } from "./http-cache.js";
10
11
  export { loadJwtSecretOrKeyring } from "./jwt.js";
11
12
  export { publicOriginOf } from "./origin-middleware.js";
12
- export { runWithTimeout } from "./readiness.js";
13
13
  export { createDefaultSseBroker, createRedisSseBroker, isRedisSseBroker, } from "./redis-sse-broker.js";
14
14
  export { readonlyRequestContext as requestContext } from "./request-context-scopes.js";
15
15
  export { buildRequestContextDataFromRequest } from "./request-id-middleware.js";
@@ -18,17 +18,6 @@ export type ReadinessProbeOptions = {
18
18
  readonly timeoutMs?: number;
19
19
  };
20
20
  export declare function createReadinessProbe(checks: readonly ReadinessCheck[], opts?: ReadinessProbeOptions): () => Promise<ReadinessResult>;
21
- export type TimedRunResult<T> = {
22
- readonly ok: true;
23
- readonly value: T;
24
- readonly latencyMs: number;
25
- } | {
26
- readonly ok: false;
27
- readonly error: unknown;
28
- readonly timedOut: boolean;
29
- readonly latencyMs: number;
30
- };
31
- export declare function runWithTimeout<T>(run: () => Promise<T>, timeoutMs: number): Promise<TimedRunResult<T>>;
32
21
  export declare function dbPingCheck(db: DbConnection): ReadinessCheck;
33
22
  export declare function redisPingCheck(redis: Redis): ReadinessCheck;
34
23
  export declare function dispatcherLagCheck(db: DbConnection, consumerNames: readonly string[], maxLagEvents: bigint): ReadinessCheck;
@@ -12,6 +12,7 @@
12
12
  // so total latency must stay ≈ slowest check, not sum.
13
13
  import { pingDatabase } from "../db/queries/raw-sql.js";
14
14
  import { getAllConsumerProgress } from "../pipeline/event-dispatcher.js";
15
+ import { runWithTimeout } from "../utils/run-with-timeout.js";
15
16
  const DEFAULT_TIMEOUT_MS = 2_000;
16
17
  export function createReadinessProbe(checks, opts = {}) {
17
18
  const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
@@ -23,36 +24,6 @@ export function createReadinessProbe(checks, opts = {}) {
23
24
  };
24
25
  };
25
26
  }
26
- class TimeoutError extends Error {
27
- }
28
- export async function runWithTimeout(run, timeoutMs) {
29
- const start = performance.now();
30
- const elapsedMs = () => Math.round(performance.now() - start);
31
- let timer;
32
- try {
33
- const value = await Promise.race([
34
- run(),
35
- new Promise((_, reject) => {
36
- timer = setTimeout(() => reject(new TimeoutError(`timeout after ${timeoutMs}ms`)), timeoutMs);
37
- // Don't keep the event loop alive on a hung probe during shutdown.
38
- timer.unref?.();
39
- }),
40
- ]);
41
- return { ok: true, value, latencyMs: elapsedMs() };
42
- }
43
- catch (error) {
44
- return {
45
- ok: false,
46
- error,
47
- timedOut: error instanceof TimeoutError,
48
- latencyMs: elapsedMs(),
49
- };
50
- }
51
- finally {
52
- if (timer)
53
- clearTimeout(timer);
54
- }
55
- }
56
27
  async function runOne(check, timeoutMs) {
57
28
  const result = await runWithTimeout(check.run, timeoutMs);
58
29
  if (result.ok)
@@ -6,6 +6,7 @@ export { resolvePlatformKeks } from "./kek-source.ts";
6
6
  export { KeyAlreadyExistsError, KeyErasedError, type KmsAdapter, type KmsContext, type LocalKeyKmsAdapter, type SubjectId, subjectIdSchema, subjectIdToKey, subjectKeyForRecord, subjectKeyForUser, } from "./kms-adapter.ts";
7
7
  export { ENCRYPTION_UNAVAILABLE_CODE, EncryptionUnavailableError } from "./kms-outage.ts";
8
8
  export { type KmsWiring, requireKmsWiring, requireKmsWiringAsync, resolveKmsWiring, resolveKmsWiringAsync, } from "./kms-wiring.ts";
9
+ export { hashOpaqueToken, hashOpaqueTokenSync, opaqueTokenHashMatches, } from "./opaque-token-hash.ts";
9
10
  export { createPgKmsAdapter, rewrapSubjectKeys, } from "./pg-kms-adapter.ts";
10
11
  export { PII_CIPHERTEXT_CORRUPT_CODE, PiiCiphertextCorruptError, } from "./pii-ciphertext-corrupt.ts";
11
12
  export { configuredPiiSubjectKms, configurePiiSubjectKms, decryptPiiFieldValues, decryptPiiValueForSubject, encryptPiiFieldValues, encryptPiiJsonValueForSubject, encryptPiiValueForSubject, isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_ERASED_SENTINEL, } from "./pii-field-encryption.ts";
@@ -6,6 +6,7 @@ export { resolvePlatformKeks } from "./kek-source.js";
6
6
  export { KeyAlreadyExistsError, KeyErasedError, subjectIdSchema, subjectIdToKey, subjectKeyForRecord, subjectKeyForUser, } from "./kms-adapter.js";
7
7
  export { ENCRYPTION_UNAVAILABLE_CODE, EncryptionUnavailableError } from "./kms-outage.js";
8
8
  export { requireKmsWiring, requireKmsWiringAsync, resolveKmsWiring, resolveKmsWiringAsync, } from "./kms-wiring.js";
9
+ export { hashOpaqueToken, hashOpaqueTokenSync, opaqueTokenHashMatches, } from "./opaque-token-hash.js";
9
10
  export { createPgKmsAdapter, rewrapSubjectKeys, } from "./pg-kms-adapter.js";
10
11
  export { PII_CIPHERTEXT_CORRUPT_CODE, PiiCiphertextCorruptError, } from "./pii-ciphertext-corrupt.js";
11
12
  export { configuredPiiSubjectKms, configurePiiSubjectKms, decryptPiiFieldValues, decryptPiiValueForSubject, encryptPiiFieldValues, encryptPiiJsonValueForSubject, encryptPiiValueForSubject, isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_ERASED_SENTINEL, } from "./pii-field-encryption.js";
@@ -0,0 +1,3 @@
1
+ export declare function hashOpaqueToken(token: string): Promise<string>;
2
+ export declare function hashOpaqueTokenSync(token: string): string;
3
+ export declare function opaqueTokenHashMatches(token: string, storedHash: string): boolean;
@@ -0,0 +1,14 @@
1
+ import { createHash, timingSafeEqual } from "node:crypto";
2
+ import { digestHex } from "../utils/hex.js";
3
+ export function hashOpaqueToken(token) {
4
+ return digestHex("SHA-256", token);
5
+ }
6
+ export function hashOpaqueTokenSync(token) {
7
+ return createHash("sha256").update(token).digest("hex");
8
+ }
9
+ export function opaqueTokenHashMatches(token, storedHash) {
10
+ const actual = Buffer.from(hashOpaqueTokenSync(token));
11
+ const expected = Buffer.from(storedHash);
12
+ // timingSafeEqual throws on a length mismatch, and the throw itself is a timing signal.
13
+ return actual.length === expected.length && timingSafeEqual(actual, expected);
14
+ }
@@ -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);
@@ -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
+ }
@@ -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>;
@@ -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.1",
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,10 +203,10 @@
203
203
  "./package.json": "./package.json"
204
204
  },
205
205
  "dependencies": {
206
- "@cosmicdrift/kumiko-types": "0.404.1",
206
+ "@cosmicdrift/kumiko-types": "0.405.0",
207
207
  "bullmq": "^5.81.5",
208
208
  "hono": "^4.13.14",
209
- "i18next": "^26.5.11",
209
+ "i18next": "^26.5.12",
210
210
  "ioredis": "^5.11.1",
211
211
  "jose": "^6.2.14",
212
212
  "pino": "^10.4.0",
@@ -225,7 +225,7 @@
225
225
  }
226
226
  },
227
227
  "devDependencies": {
228
- "@cosmicdrift/kumiko-testing": "0.404.1",
228
+ "@cosmicdrift/kumiko-testing": "0.405.0",
229
229
  "bun-types": "^1.4.4",
230
230
  "fflate": "^0.8.3",
231
231
  "meilisearch": "^0.62.0",
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",