@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.
- package/dist/api/auth-routes-mfa.js +2 -2
- package/dist/api/index.d.ts +2 -2
- package/dist/api/index.js +1 -1
- package/dist/api/readiness.d.ts +0 -11
- package/dist/api/readiness.js +1 -30
- package/dist/crypto/index.d.ts +1 -0
- package/dist/crypto/index.js +1 -0
- package/dist/crypto/opaque-token-hash.d.ts +3 -0
- package/dist/crypto/opaque-token-hash.js +14 -0
- package/dist/crypto/subject-resolver.js +3 -8
- package/dist/db/blind-index-cleanup.js +3 -10
- package/dist/db/query.js +3 -18
- package/dist/derivatives/variant-key.d.ts +4 -0
- package/dist/derivatives/variant-key.js +4 -3
- package/dist/engine/boot-validator/field-conditions.js +2 -7
- package/dist/engine/boot-validator/list-table-aggregate-query.js +1 -1
- package/dist/engine/boot-validator/parent-ref.d.ts +0 -2
- package/dist/engine/boot-validator/parent-ref.js +1 -8
- package/dist/engine/boot-validator/projection-list-screens.js +1 -1
- package/dist/engine/boot-validator/read-gate.js +1 -1
- package/dist/engine/find-entity.d.ts +3 -0
- package/dist/engine/find-entity.js +8 -0
- package/dist/engine/project-app-schema-for-roles.js +19 -55
- package/dist/files/file-handle.js +3 -16
- package/dist/observability/ids.js +1 -8
- package/dist/pipeline/projection-rebuild.js +3 -17
- package/dist/pipeline/tenant-timezone-cache.js +8 -27
- package/dist/search/index.d.ts +3 -0
- package/dist/search/index.js +3 -0
- package/dist/search/is-entity-searchable.d.ts +2 -0
- package/dist/search/is-entity-searchable.js +4 -0
- package/dist/search/reindex-tenants-search.d.ts +19 -0
- package/dist/search/reindex-tenants-search.js +69 -0
- package/dist/utils/chunk.d.ts +1 -0
- package/dist/utils/chunk.js +7 -0
- package/dist/utils/hex.d.ts +3 -0
- package/dist/utils/hex.js +11 -0
- package/dist/utils/index.d.ts +3 -0
- package/dist/utils/index.js +3 -0
- package/dist/utils/lru-cache.d.ts +17 -0
- package/dist/utils/lru-cache.js +56 -0
- package/dist/utils/run-with-timeout.d.ts +11 -0
- package/dist/utils/run-with-timeout.js +30 -0
- package/package.json +4 -4
- 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:${
|
|
39
|
+
return `preauth-token:${hashOpaqueTokenSync(token)}`;
|
|
40
40
|
}
|
|
41
41
|
export function registerMfaVerifyRoute(ctx) {
|
|
42
42
|
const { api, dispatcher, config, getClientIp, mintSessionAndRespond, landingPathFragment } = ctx;
|
package/dist/api/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/api/readiness.d.ts
CHANGED
|
@@ -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;
|
package/dist/api/readiness.js
CHANGED
|
@@ -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)
|
package/dist/crypto/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/crypto/index.js
CHANGED
|
@@ -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,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) && !
|
|
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) &&
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
404
|
-
const
|
|
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
|
-
//
|
|
45
|
-
// key
|
|
46
|
-
|
|
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 (
|
|
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 "
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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; //
|
|
263
|
+
return true; // no screen target
|
|
268
264
|
}
|
|
269
|
-
function
|
|
265
|
+
function filterKeptActions(actions, keep) {
|
|
270
266
|
if (actions === undefined)
|
|
271
267
|
return undefined;
|
|
272
|
-
const kept = actions.filter(
|
|
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
|
|
278
|
-
|
|
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
|
-
|
|
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 (
|
|
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 =
|
|
340
|
-
const nextRowActions =
|
|
341
|
-
const nextToolbarActions =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
475
|
-
const nextToolbarActions =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 {
|
|
122
|
-
return ext === "" ? `${key}.${suffix}` : `${
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
19
|
-
|
|
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.
|
|
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;
|
package/dist/search/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/search/index.js
CHANGED
|
@@ -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,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,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
|
+
}
|
package/dist/utils/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/utils/index.js
CHANGED
|
@@ -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.
|
|
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.
|
|
206
|
+
"@cosmicdrift/kumiko-types": "0.405.0",
|
|
207
207
|
"bullmq": "^5.81.5",
|
|
208
208
|
"hono": "^4.13.14",
|
|
209
|
-
"i18next": "^26.5.
|
|
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.
|
|
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",
|