@cosmicdrift/kumiko-framework 0.404.0 → 0.405.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +103 -103
- package/README.md +19 -17
- package/agents/examples/apex-landing.md +3 -3
- package/agents/examples/apex-surface-auth.md +13 -9
- package/agents/examples/auth-mfa.md +2 -4
- package/agents/examples/boot-check.md +2 -2
- package/agents/examples/compliance-profiles-demo.md +5 -5
- package/agents/examples/compose-stacks.md +8 -8
- package/agents/examples/cross-feature-events.md +1 -0
- package/agents/examples/crypto-shredding-hr.md +3 -3
- package/agents/examples/custom-fields-basic.md +1 -1
- package/agents/examples/documents.md +28 -0
- package/agents/examples/encrypted-tenant-config.md +8 -8
- package/agents/examples/env-composition.md +10 -10
- package/agents/examples/error-contract.md +9 -9
- package/agents/examples/event-sourcing.md +9 -9
- package/agents/examples/ledger-invoicing.md +6 -6
- package/agents/examples/legal-pages.md +16 -16
- package/agents/examples/managed-config.md +15 -11
- package/agents/examples/managed-pages.md +7 -7
- package/agents/examples/pipeline-basics.md +13 -13
- package/agents/examples/public-share-token.md +4 -4
- package/agents/examples/security-baseline.md +4 -1
- package/agents/examples/session-revocation.md +1 -1
- package/agents/examples/tenant-settings.md +4 -4
- package/agents/examples/tier-admin.md +2 -2
- package/agents/examples/user-data-rights.md +21 -19
- 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/event-retention.js +3 -1
- 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/meilisearch-peer.js +1 -1
- 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 +12 -12
- package/presets/biome-app.json +1 -1
- package/src/changes.json +36 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { normalizeEventPiiSubject } from "@cosmicdrift/kumiko-types/handlers";
|
|
2
|
+
import { isFileField } from "../engine/field-helpers.js";
|
|
2
3
|
import { isSelfPiiField } from "./is-self-pii-field.js";
|
|
3
4
|
import { RECORD_ENTITY_PATTERN } from "./kms-adapter.js";
|
|
4
5
|
// Thrown when a field IS pii-annotated but the row can't name its subject —
|
|
@@ -157,24 +158,18 @@ export function collectPiiSubjectFields(entity) {
|
|
|
157
158
|
.filter(([, field]) => hasSubjectAnnotation(field))
|
|
158
159
|
.map(([name]) => name);
|
|
159
160
|
}
|
|
160
|
-
function isFileReferenceField(field) {
|
|
161
|
-
return (field.type === "file" ||
|
|
162
|
-
field.type === "image" ||
|
|
163
|
-
field.type === "files" ||
|
|
164
|
-
field.type === "images");
|
|
165
|
-
}
|
|
166
161
|
// File columns hold only the fileRef uuid; the binary is erased by the file-ref
|
|
167
162
|
// user-data hook, so these stay annotated (forget) but are never ciphertext.
|
|
168
163
|
export function collectPiiEncryptedFields(entity) {
|
|
169
164
|
return Object.entries(entity.fields)
|
|
170
|
-
.filter(([, field]) => hasSubjectAnnotation(field) && !
|
|
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);
|
|
@@ -68,7 +68,9 @@ export async function pruneEvents(db, options) {
|
|
|
68
68
|
if (candidates.length === 0) {
|
|
69
69
|
return { deletedCount: 0, cutoff, aggregateTypes, dryRun };
|
|
70
70
|
}
|
|
71
|
-
const maxCandidateId = candidates.reduce(
|
|
71
|
+
const maxCandidateId = candidates.reduce(
|
|
72
|
+
// biome-ignore lint/complexity/useMathMinMax: ids are bigint, Math.max throws a TypeError on BigInt
|
|
73
|
+
(acc, row) => (row.id > acc ? row.id : acc), candidates[0]?.id ?? 0n);
|
|
72
74
|
// Step 2 — safety guard: check every ACTIVE consumer has moved past
|
|
73
75
|
// the candidate set. Disabled consumers are intentionally excluded —
|
|
74
76
|
// ops disables them precisely to park them while pruning happens.
|
|
@@ -6,8 +6,9 @@ import { runInSystemRowLevelSecurityScope } from "../db/row-level-security-scope
|
|
|
6
6
|
import { withoutStatementTimeout } from "../db/statement-timeout.js";
|
|
7
7
|
import { extractTableInfo } from "../db/table-info.js";
|
|
8
8
|
import { withExecutorManagedWrites } from "../db/tenant-db.js";
|
|
9
|
-
import { eventsTable, getEventsHighWaterMark, upcastStoredEvent
|
|
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>;
|