@cosmicdrift/kumiko-framework 0.364.0 → 0.365.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 +36 -5
- package/agents/examples/anonymous-access-multitenant.md +9 -6
- package/agents/examples/anonymous-access.md +14 -26
- package/agents/examples/auth-mfa.md +5 -9
- package/agents/examples/custom-fields-basic.md +7 -6
- package/agents/examples/custom-handlers.md +72 -25
- package/agents/examples/dashboard-stats.md +229 -0
- package/agents/examples/encrypted-tenant-config.md +17 -8
- package/agents/examples/error-contract.md +1 -5
- package/agents/examples/feature-toggles.md +28 -17
- package/agents/examples/ledger-banking.md +7 -6
- package/agents/examples/ledger-invoicing.md +7 -6
- package/agents/examples/legal-pages.md +2 -2
- package/agents/examples/managed-pages.md +2 -2
- package/agents/examples/notes-history-basic.md +7 -6
- package/agents/examples/ownership.md +30 -22
- package/agents/examples/pdf-audit-export.md +87 -51
- package/agents/examples/session-revocation.md +1 -10
- package/agents/examples/tags-basic.md +7 -7
- package/agents/examples/user-data-rights.md +25 -17
- package/agents/examples/workflow-engine.md +2 -2
- package/agents/examples.md +2 -1
- package/agents/features.md +3 -3
- package/dist/api/auth-routes.d.ts +0 -1
- package/dist/api/auth-routes.js +44 -43
- package/dist/api/index.d.ts +1 -1
- package/dist/api/index.js +1 -1
- package/dist/api/server.d.ts +2 -2
- package/dist/api/server.js +21 -4
- package/dist/bun-db/connection.js +3 -16
- package/dist/bun-db/index.d.ts +1 -1
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +7 -0
- package/dist/bun-db/query.js +50 -18
- package/dist/crypto/owner-transition-rekey.d.ts +1 -0
- package/dist/crypto/owner-transition-rekey.js +7 -3
- package/dist/crypto/pii-field-encryption.d.ts +1 -0
- package/dist/crypto/pii-field-encryption.js +9 -2
- package/dist/crypto/subject-resolver.d.ts +1 -0
- package/dist/crypto/subject-resolver.js +7 -0
- package/dist/db/api.d.ts +4 -3
- package/dist/db/apply-entity-event.js +26 -2
- package/dist/db/blind-index-cleanup.js +2 -1
- package/dist/db/bun-provider.js +9 -18
- package/dist/db/dialect.d.ts +1 -1
- package/dist/db/dialect.js +2 -0
- package/dist/db/eagerload.js +2 -1
- package/dist/db/event-store-executor-context.d.ts +1 -0
- package/dist/db/event-store-executor-context.js +1 -0
- package/dist/db/event-store-executor-write.d.ts +1 -1
- package/dist/db/event-store-executor-write.js +70 -47
- package/dist/db/index.d.ts +1 -0
- package/dist/db/index.js +1 -0
- package/dist/db/migrate-runner.d.ts +1 -0
- package/dist/db/migrate-runner.js +56 -31
- package/dist/db/queries/backfill-pii.js +63 -25
- package/dist/db/queries/event-consumer.js +2 -1
- package/dist/db/queries/event-store-transfer.js +15 -4
- package/dist/db/queries/event-store.js +11 -10
- package/dist/db/queries/pii-backfill-state.js +3 -2
- package/dist/db/queries/postgres-array-literal.d.ts +2 -0
- package/dist/db/queries/postgres-array-literal.js +6 -0
- package/dist/db/queries/projection-rebuild.js +3 -2
- package/dist/db/queries/shadow-swap.d.ts +1 -1
- package/dist/db/queries/shadow-swap.js +12 -8
- package/dist/db/query.d.ts +1 -1
- package/dist/db/query.js +1 -1
- package/dist/db/retired-framework-tables.js +22 -4
- package/dist/db/table-builder.js +6 -2
- package/dist/db/tenant-db.d.ts +2 -0
- package/dist/db/tenant-db.js +20 -1
- package/dist/derivatives/delete-stored-file.d.ts +4 -0
- package/dist/derivatives/delete-stored-file.js +9 -0
- package/dist/derivatives/index.d.ts +1 -1
- package/dist/derivatives/index.js +1 -1
- package/dist/engine/boot-validator/entity-handler.js +3 -0
- package/dist/engine/boot-validator/query-refs.js +14 -2
- package/dist/engine/dedupe-features.js +31 -4
- package/dist/engine/entity-handlers.js +14 -7
- package/dist/engine/index.d.ts +1 -1
- package/dist/engine/index.js +1 -1
- package/dist/engine/screen-helpers.d.ts +1 -0
- package/dist/engine/screen-helpers.js +2 -2
- package/dist/engine/steps/_read-source.js +5 -2
- package/dist/engine/tier-resolver-extension.d.ts +1 -0
- package/dist/engine/tier-resolver-extension.js +6 -0
- package/dist/entrypoint/index.d.ts +2 -2
- package/dist/errors/docs-url.js +6 -6
- package/dist/errors/index.d.ts +1 -1
- package/dist/errors/index.js +1 -1
- package/dist/errors/subject-erased.d.ts +1 -0
- package/dist/errors/subject-erased.js +6 -0
- package/dist/event-store/admin-api.js +3 -2
- package/dist/event-store/errors.d.ts +9 -0
- package/dist/event-store/errors.js +20 -0
- package/dist/event-store/event-store.d.ts +6 -0
- package/dist/event-store/event-store.js +28 -6
- package/dist/event-store/index.d.ts +1 -1
- package/dist/event-store/index.js +1 -1
- package/dist/files/create-file-ref.d.ts +2 -1
- package/dist/files/create-file-ref.js +16 -2
- package/dist/files/dispose-removed-file-refs.js +21 -6
- package/dist/files/feature.d.ts +1 -0
- package/dist/files/feature.js +2 -1
- package/dist/files/file-handle.js +4 -0
- package/dist/files/file-routes.js +9 -5
- package/dist/files/index.d.ts +1 -1
- package/dist/files/index.js +1 -1
- package/dist/files/purge-expired-file-ref-bytes.js +2 -3
- package/dist/files/resolve-content-type.js +40 -4
- package/dist/files/storage-tracking.js +5 -4
- package/dist/files/types.js +5 -8
- package/dist/files/zip-entry-names.d.ts +1 -0
- package/dist/files/zip-entry-names.js +46 -0
- package/dist/jobs/job-runner.d.ts +1 -0
- package/dist/jobs/job-runner.js +116 -33
- package/dist/observability/escape-hatch-report.d.ts +1 -0
- package/dist/observability/escape-hatch-report.js +31 -2
- package/dist/observability/index.d.ts +1 -1
- package/dist/observability/index.js +1 -1
- package/dist/observability/metrics-handle.d.ts +1 -0
- package/dist/observability/metrics-handle.js +7 -0
- package/dist/observability/standard-metrics.js +4 -4
- package/dist/pipeline/dispatch-query.d.ts +2 -1
- package/dist/pipeline/dispatch-query.js +6 -6
- package/dist/pipeline/dispatch-shared.d.ts +1 -1
- package/dist/pipeline/dispatch-shared.js +41 -30
- package/dist/pipeline/dispatch-stream.js +10 -4
- package/dist/pipeline/dispatcher.js +3 -1
- package/dist/pipeline/event-dispatcher.d.ts +2 -2
- package/dist/pipeline/event-dispatcher.js +11 -9
- package/dist/pipeline/event-retention.d.ts +1 -0
- package/dist/pipeline/event-retention.js +7 -1
- package/dist/pipeline/notify-scope.js +3 -1
- package/dist/pipeline/pre-transaction-reservations.js +2 -1
- package/dist/schema-cli.js +12 -1
- package/dist/stack/db.d.ts +2 -2
- package/dist/stack/test-stack.js +2 -1
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.js +1 -0
- package/dist/testing/multipart-helper.d.ts +6 -0
- package/dist/testing/multipart-helper.js +1 -1
- package/dist/testing/zip-fixture.d.ts +1 -0
- package/dist/testing/zip-fixture.js +33 -0
- package/dist/ui-types/card-column-roles.js +4 -2
- package/dist/ui-types/index.d.ts +1 -1
- package/dist/ui-types/index.js +1 -1
- package/dist/upgrade-cli.js +46 -13
- package/package.json +4 -4
- package/src/changes.json +305 -0
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Bun.SQL stringifies JS arrays via toString, so array params travel as a
|
|
2
|
+
// Postgres array-literal string (`$1::text[]`) — identical on postgres-js and Bun.SQL.
|
|
3
|
+
export function toPostgresArrayLiteral(values) {
|
|
4
|
+
const elements = values.map((value) => value === null ? "NULL" : `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`);
|
|
5
|
+
return `{${elements.join(",")}}`;
|
|
6
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { asRawClient, unsafeReadRetrying } from "../query.js";
|
|
2
|
+
import { toPostgresArrayLiteral } from "./postgres-array-literal.js";
|
|
2
3
|
export async function markProjectionRebuilding(db, projectionName) {
|
|
3
4
|
await asRawClient(db).unsafe(`INSERT INTO "kumiko_projections" ("name", "status") VALUES ($1, 'rebuilding')
|
|
4
5
|
ON CONFLICT ("name") DO UPDATE SET
|
|
@@ -23,7 +24,7 @@ export async function selectEventsForProjectionRebuildBatch(db, aggregateTypes,
|
|
|
23
24
|
WHERE a."tenant_id" = e."tenant_id" AND a."aggregate_id" = e."aggregate_id"
|
|
24
25
|
)
|
|
25
26
|
ORDER BY e."id" ASC
|
|
26
|
-
LIMIT $4`, [aggregateTypes, eventTypes, afterId, limit]);
|
|
27
|
+
LIMIT $4`, [toPostgresArrayLiteral(aggregateTypes), toPostgresArrayLiteral(eventTypes), afterId, limit]);
|
|
27
28
|
}
|
|
28
29
|
// Total subscribed events in the log — same source/type filter as
|
|
29
30
|
// selectEventsForProjectionRebuildBatch, no id cursor. Under the cutover fence
|
|
@@ -39,7 +40,7 @@ export async function countSubscribedEvents(db, aggregateTypes, eventTypes) {
|
|
|
39
40
|
AND NOT EXISTS (
|
|
40
41
|
SELECT 1 FROM "kumiko_archived_streams" a
|
|
41
42
|
WHERE a."tenant_id" = e."tenant_id" AND a."aggregate_id" = e."aggregate_id"
|
|
42
|
-
)`, [aggregateTypes, eventTypes]);
|
|
43
|
+
)`, [toPostgresArrayLiteral(aggregateTypes), toPostgresArrayLiteral(eventTypes)]);
|
|
43
44
|
const raw = rows[0]?.n;
|
|
44
45
|
if (typeof raw === "bigint")
|
|
45
46
|
return raw;
|
|
@@ -9,7 +9,7 @@ export declare function assertLiveColumnsMatchMeta(db: DbConnection | DbTx, meta
|
|
|
9
9
|
export declare function assertLiveTableHasNoRowLevelSecurity(tx: AnyDb, tableName: string, projectionName?: string): Promise<void>;
|
|
10
10
|
export declare function buildShadowTable(tx: AnyDb, meta: EntityTableMeta): Promise<void>;
|
|
11
11
|
export declare function fenceLiveTable(tx: AnyDb, tableName: string, lockTimeoutMs: number): Promise<void>;
|
|
12
|
-
export declare function assertNoUnreachableLiveRows(tx: AnyDb, projectionName: string, tableName: string, aggregateTypes: readonly string[], derivedRowIds?: Readonly<Record<string, ProjectionRowIdOf
|
|
12
|
+
export declare function assertNoUnreachableLiveRows(tx: AnyDb, projectionName: string, tableName: string, aggregateTypes: readonly string[], derivedRowIds?: Readonly<Record<string, ProjectionRowIdOf>>, derivedRowIdBatchSize?: number): Promise<void>;
|
|
13
13
|
export declare function assertNoBlindIndexLoss(tx: AnyDb, tableName: string, meta: EntityTableMeta, projectionName: string): Promise<void>;
|
|
14
14
|
export type ColumnDriftResult = {
|
|
15
15
|
readonly rowCount: number;
|
|
@@ -22,6 +22,7 @@ import { configuredBlindIndexKey } from "../../crypto/index.js";
|
|
|
22
22
|
import { asEntityTableMeta, asRawClient } from "../query.js";
|
|
23
23
|
import { renderTableDdl } from "../render-ddl.js";
|
|
24
24
|
import { columnNamesOf, tableExists } from "../schema-inspection.js";
|
|
25
|
+
import { toPostgresArrayLiteral } from "./postgres-array-literal.js";
|
|
25
26
|
import { quoteTableIdent } from "./table-ops.js";
|
|
26
27
|
export const PROJECTION_REBUILD_SCHEMA = "kumiko_rebuild";
|
|
27
28
|
const SCHEMA_IDENT = quoteTableIdent(PROJECTION_REBUILD_SCHEMA);
|
|
@@ -187,15 +188,18 @@ const UNREACHABLE_SAMPLE_LIMIT = 20;
|
|
|
187
188
|
// strict — a row keyed by the raw aggregate id of a derived stream is a ghost.
|
|
188
189
|
const DERIVED_ROW_ID_BATCH_SIZE = 5000;
|
|
189
190
|
const DERIVED_ROW_ID_TABLE = "pg_temp.kumiko_derived_row_ids";
|
|
190
|
-
async function fillDerivedRowIdTable(raw, projectionName, aggregateType, rowIdOf) {
|
|
191
|
+
async function fillDerivedRowIdTable(raw, projectionName, aggregateType, rowIdOf, batchSize) {
|
|
191
192
|
let cursor;
|
|
192
193
|
for (;;) {
|
|
193
|
-
const page = await raw.unsafe(
|
|
194
|
+
const page = await raw.unsafe(
|
|
195
|
+
// Selecting the uuid columns (no text alias) keeps ORDER BY and the keyset
|
|
196
|
+
// comparison on the same type; text ordering is collation-dependent.
|
|
197
|
+
`SELECT DISTINCT "tenant_id", "aggregate_id"
|
|
194
198
|
FROM "kumiko_events"
|
|
195
199
|
WHERE "aggregate_type" = $1
|
|
196
200
|
${cursor ? `AND ("tenant_id", "aggregate_id") > ($2::uuid, $3::uuid)` : ""}
|
|
197
201
|
ORDER BY "tenant_id", "aggregate_id"
|
|
198
|
-
LIMIT ${
|
|
202
|
+
LIMIT ${batchSize}`, cursor ? [aggregateType, cursor.tenant_id, cursor.aggregate_id] : [aggregateType]);
|
|
199
203
|
// skip: keyset exhausted — every source event of this type has been mapped
|
|
200
204
|
if (page.length === 0)
|
|
201
205
|
return;
|
|
@@ -212,14 +216,14 @@ async function fillDerivedRowIdTable(raw, projectionName, aggregateType, rowIdOf
|
|
|
212
216
|
}
|
|
213
217
|
return derived;
|
|
214
218
|
});
|
|
215
|
-
await raw.unsafe(`INSERT INTO ${DERIVED_ROW_ID_TABLE} ("id") SELECT unnest($1::uuid[]) ON CONFLICT DO NOTHING`, [derivedIds]);
|
|
219
|
+
await raw.unsafe(`INSERT INTO ${DERIVED_ROW_ID_TABLE} ("id") SELECT unnest($1::uuid[]) ON CONFLICT DO NOTHING`, [toPostgresArrayLiteral(derivedIds)]);
|
|
216
220
|
// skip: a short page is the last one
|
|
217
|
-
if (page.length <
|
|
221
|
+
if (page.length < batchSize)
|
|
218
222
|
return;
|
|
219
223
|
cursor = page[page.length - 1];
|
|
220
224
|
}
|
|
221
225
|
}
|
|
222
|
-
export async function assertNoUnreachableLiveRows(tx, projectionName, tableName, aggregateTypes, derivedRowIds = {}) {
|
|
226
|
+
export async function assertNoUnreachableLiveRows(tx, projectionName, tableName, aggregateTypes, derivedRowIds = {}, derivedRowIdBatchSize = DERIVED_ROW_ID_BATCH_SIZE) {
|
|
223
227
|
// skip: no source streams → no events could back any row anyway; a rebuild
|
|
224
228
|
// of a subscription-less projection swaps an empty shadow (handled upstream).
|
|
225
229
|
if (aggregateTypes.length === 0)
|
|
@@ -237,7 +241,7 @@ export async function assertNoUnreachableLiveRows(tx, projectionName, tableName,
|
|
|
237
241
|
// skip: filtered by derivedTypes above, narrows the Record lookup for the compiler
|
|
238
242
|
if (rowIdOf === undefined)
|
|
239
243
|
continue;
|
|
240
|
-
await fillDerivedRowIdTable(raw, projectionName, aggregateType, rowIdOf);
|
|
244
|
+
await fillDerivedRowIdTable(raw, projectionName, aggregateType, rowIdOf, derivedRowIdBatchSize);
|
|
241
245
|
}
|
|
242
246
|
}
|
|
243
247
|
const unbackedClauses = [];
|
|
@@ -252,7 +256,7 @@ export async function assertNoUnreachableLiveRows(tx, projectionName, tableName,
|
|
|
252
256
|
}
|
|
253
257
|
const ghosts = await raw.unsafe(`SELECT l."id" FROM public.${t} l
|
|
254
258
|
WHERE ${unbackedClauses.join(" AND ")}
|
|
255
|
-
LIMIT ${UNREACHABLE_SAMPLE_LIMIT}`, directTypes.length > 0 ? [directTypes] : []);
|
|
259
|
+
LIMIT ${UNREACHABLE_SAMPLE_LIMIT}`, directTypes.length > 0 ? [toPostgresArrayLiteral(directTypes)] : []);
|
|
256
260
|
// skip: every live row has a backing event — nothing unreachable, swap is safe
|
|
257
261
|
if (ghosts.length === 0)
|
|
258
262
|
return;
|
package/dist/db/query.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { type AnyDb, aggregateWhere, asEntityTableMeta, asRawClient, coerceRow, countWhere, type DeleteManyBatchedOptions, type DeleteManyBatchedResult, deleteMany, deleteManyBatched, extractTableInfo, fetchOne, type IncrementCounterOptions, incrementCounter, insertMany, insertOne, type JsonTextMatch, type OrderByClause, runInNewTransaction, runInSavepoint, runInSavepointIfSupported, type SelectOptions, selectMany, type TableInfo, transaction, type UpsertOnConflictOptions, unsafeReadRetrying, updateMany, upsertByPk, upsertOnConflict, type WhereObject, type WhereOperator, type WhereValue, } from "../bun-db/query.js";
|
|
1
|
+
export { type AnyDb, aggregateWhere, asEntityTableMeta, asRawClient, coerceRow, countWhere, type DeleteManyBatchedOptions, type DeleteManyBatchedResult, deleteMany, deleteManyBatched, extractTableInfo, fetchOne, type IncrementCounterOptions, incrementCounter, insertMany, insertOne, type JsonTextMatch, type OrderByClause, runInNewTransaction, runInSavepoint, runInSavepointIfSupported, runnerCanBeginTransaction, type SelectOptions, selectMany, type TableInfo, transaction, type UpsertOnConflictOptions, unsafeReadRetrying, updateMany, upsertByPk, upsertOnConflict, type WhereObject, type WhereOperator, type WhereValue, } from "../bun-db/query.js";
|
package/dist/db/query.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
// Provider-neutrale Query-API. Re-exported aus bun-db/query.
|
|
2
2
|
// Alle Consumer importieren von hier, nicht direkt aus bun-db/.
|
|
3
|
-
export { aggregateWhere, asEntityTableMeta, asRawClient, coerceRow, countWhere, deleteMany, deleteManyBatched, extractTableInfo, fetchOne, incrementCounter, insertMany, insertOne, runInNewTransaction, runInSavepoint, runInSavepointIfSupported, selectMany, transaction, unsafeReadRetrying, updateMany, upsertByPk, upsertOnConflict, } from "../bun-db/query.js";
|
|
3
|
+
export { aggregateWhere, asEntityTableMeta, asRawClient, coerceRow, countWhere, deleteMany, deleteManyBatched, extractTableInfo, fetchOne, incrementCounter, insertMany, insertOne, runInNewTransaction, runInSavepoint, runInSavepointIfSupported, runnerCanBeginTransaction, selectMany, transaction, unsafeReadRetrying, updateMany, upsertByPk, upsertOnConflict, } from "../bun-db/query.js";
|
|
@@ -14,10 +14,23 @@ export const retiredFrameworkTables = [
|
|
|
14
14
|
explanation: "replaced by store_job_runs; the job run history was not migrated (operational data, not a system of record)",
|
|
15
15
|
},
|
|
16
16
|
];
|
|
17
|
-
|
|
17
|
+
// Optional schema prefix is skipped; only the table name is captured.
|
|
18
|
+
const IDENT = `(?:"?[^"\\s(;,.]+"?\\.)?"?([^"\\s(;,.]+)"?`;
|
|
18
19
|
const CREATE_TABLE_RE = new RegExp(`^CREATE TABLE\\s+(?:IF NOT EXISTS\\s+)?${IDENT}`, "i");
|
|
19
|
-
|
|
20
|
+
// DROP TABLE accepts a comma list; the list is split and each entry parsed with IDENT.
|
|
21
|
+
const DROP_TABLE_LIST_RE = /^DROP TABLE\s+(?:IF EXISTS\s+)?([^;]+)/i;
|
|
22
|
+
const IDENT_ONLY_RE = new RegExp(`^\\s*${IDENT}`);
|
|
23
|
+
const RENAME_TABLE_RE = new RegExp(`^ALTER TABLE\\s+(?:IF EXISTS\\s+)?(?:ONLY\\s+)?${IDENT}\\s+RENAME TO\\s+${IDENT}`, "i");
|
|
20
24
|
const COMMENTED_DROP_TABLE_RE = new RegExp(`^--\\s*DESTRUCTIVE:\\s*DROP TABLE\\s+(?:IF EXISTS\\s+)?${IDENT}`, "i");
|
|
25
|
+
function droppedTableNames(line) {
|
|
26
|
+
const list = DROP_TABLE_LIST_RE.exec(line)?.[1];
|
|
27
|
+
if (list === undefined)
|
|
28
|
+
return [];
|
|
29
|
+
return list
|
|
30
|
+
.split(",")
|
|
31
|
+
.map((entry) => IDENT_ONLY_RE.exec(entry)?.[1])
|
|
32
|
+
.filter((name) => name !== undefined);
|
|
33
|
+
}
|
|
21
34
|
// Last known state per table across all migrations, in order — so a
|
|
22
35
|
// DROP followed by a re-CREATE (managed-projection recreate) counts as
|
|
23
36
|
// "created", and a commented drop followed by an executable one as "dropped".
|
|
@@ -38,8 +51,13 @@ function scanTableLifecycle(sqlTexts) {
|
|
|
38
51
|
states.set(created, "created");
|
|
39
52
|
continue;
|
|
40
53
|
}
|
|
41
|
-
const
|
|
42
|
-
if (
|
|
54
|
+
const renamed = RENAME_TABLE_RE.exec(line);
|
|
55
|
+
if (renamed?.[1] !== undefined && renamed[2] !== undefined) {
|
|
56
|
+
states.set(renamed[1], "dropped");
|
|
57
|
+
states.set(renamed[2], "created");
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
for (const dropped of droppedTableNames(line))
|
|
43
61
|
states.set(dropped, "dropped");
|
|
44
62
|
}
|
|
45
63
|
}
|
package/dist/db/table-builder.js
CHANGED
|
@@ -378,8 +378,12 @@ export function buildEntityTable(entityName, entity, options) {
|
|
|
378
378
|
// entity.indexes[].where is now a SqlExpression (was drizzle SQL).
|
|
379
379
|
// Pass through to the IndexBuilderWithCols.where()-API.
|
|
380
380
|
if (def.where !== undefined) {
|
|
381
|
-
|
|
382
|
-
|
|
381
|
+
// A silently dropped where would build a full (unique) index that
|
|
382
|
+
// entity-table-meta flags as needsManualWhere.
|
|
383
|
+
if (!isSqlExpression(def.where)) {
|
|
384
|
+
throw new Error(`[${tableName}] index "${indexName}": where must be a sql\`...\` expression`);
|
|
385
|
+
}
|
|
386
|
+
chain = chain.where(def.where);
|
|
383
387
|
}
|
|
384
388
|
else if (def.unique === true && entity.softDelete === true) {
|
|
385
389
|
// Soft-deleted rows keep their value, so a full unique index would
|
package/dist/db/tenant-db.d.ts
CHANGED
|
@@ -19,6 +19,7 @@ export declare function createSystemDbView(db: TenantDb, dbOutsideTransaction?:
|
|
|
19
19
|
export declare function scopeTenantDbToTenant(db: TenantDb, tenantId: TenantId): TenantDb;
|
|
20
20
|
export declare function castTenantRows<T>(rows: readonly Record<string, unknown>[]): readonly T[];
|
|
21
21
|
export { tableNameOf };
|
|
22
|
+
export declare function requestedTenantIds(requested: unknown): readonly unknown[] | undefined;
|
|
22
23
|
export declare function hasTenantColumn(table: Table | EntityTableMeta): boolean;
|
|
23
24
|
export type TenantDbGrants = {
|
|
24
25
|
readonly globalWrites?: EscapeHatchDeclaration;
|
|
@@ -37,4 +38,5 @@ export declare function acknowledgeConventionCrossTenant(tenantDb: TenantDb, rea
|
|
|
37
38
|
export declare function reportConventionCrossTenantUse(acknowledged: TenantDb, target?: EscapeHatchTarget): void;
|
|
38
39
|
export declare function runInOwnTransaction<T>(tenantDb: TenantDb, fn: (txDb: TenantDb) => Promise<T>): Promise<T>;
|
|
39
40
|
export declare function createTenantDb(db: DbRunner, tenantId: TenantId, mode?: TenantDbMode, tracer?: Tracer, meter?: Meter, signal?: AbortSignal, grants?: TenantDbGrants): TenantDb;
|
|
41
|
+
export declare function createProjectingTenantDb(db: DbRunner, tenantId: TenantId, projectionRegistry: Registry | undefined): TenantDb;
|
|
40
42
|
export { asRawClient };
|
package/dist/db/tenant-db.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { SYSTEM_SCOPE_CHECK_BRAND, } from "@cosmicdrift/kumiko-types/tenant-db-types";
|
|
2
|
+
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
2
3
|
import { asEntityTableMeta, asRawClient, aggregateWhere as bunAggregateWhere, countWhere as bunCountWhere, deleteMany as bunDeleteMany, fetchOne as bunFetchOne, insertOne as bunInsertOne, selectMany as bunSelectMany, updateMany as bunUpdateMany, runInNewTransaction, } from "../db/query.js";
|
|
3
4
|
import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
|
|
4
5
|
import { AccessDeniedError, InternalError, memberResolutionReadOnlyDenied, } from "../errors/index.js";
|
|
@@ -235,6 +236,19 @@ export { tableNameOf };
|
|
|
235
236
|
function hasGrant(decl) {
|
|
236
237
|
return decl !== undefined && decl.reason.trim().length > 0;
|
|
237
238
|
}
|
|
239
|
+
// Tenant ids a where.tenantId filter asks for; undefined for operator forms
|
|
240
|
+
// that cannot narrow to a tenant list (ne, like, ...).
|
|
241
|
+
export function requestedTenantIds(requested) {
|
|
242
|
+
if (Array.isArray(requested))
|
|
243
|
+
return requested;
|
|
244
|
+
if (!isPlainObject(requested))
|
|
245
|
+
return [requested];
|
|
246
|
+
if (Array.isArray(requested["in"]))
|
|
247
|
+
return requested["in"];
|
|
248
|
+
if ("eq" in requested)
|
|
249
|
+
return [requested["eq"]];
|
|
250
|
+
return undefined;
|
|
251
|
+
}
|
|
238
252
|
function isForeignTenantId(tenantIdValue) {
|
|
239
253
|
return tenantIdValue !== undefined && tenantIdValue !== SYSTEM_TENANT_ID;
|
|
240
254
|
}
|
|
@@ -360,7 +374,7 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
|
|
|
360
374
|
const allowed = [tenantId, SYSTEM_TENANT_ID];
|
|
361
375
|
const requested = where?.["tenantId"];
|
|
362
376
|
if (requested !== undefined) {
|
|
363
|
-
const requestedList =
|
|
377
|
+
const requestedList = requestedTenantIds(requested) ?? [];
|
|
364
378
|
const narrowed = requestedList.filter((t) => typeof t === "string" && allowed.includes(t));
|
|
365
379
|
return { ...where, tenantId: narrowed.length > 0 ? narrowed : allowed };
|
|
366
380
|
}
|
|
@@ -605,4 +619,9 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
|
|
|
605
619
|
bindProjectionRegistry(tenantDb, projectionRegistry);
|
|
606
620
|
return tenantDb;
|
|
607
621
|
}
|
|
622
|
+
// Tenant-mode TenantDb whose writes reach the registry's custom projections. The positional
|
|
623
|
+
// undefineds of createTenantDb would hide a moved grants parameter from the type checker.
|
|
624
|
+
export function createProjectingTenantDb(db, tenantId, projectionRegistry) {
|
|
625
|
+
return createTenantDb(db, tenantId, "tenant", undefined, undefined, undefined, projectionRegistry ? { projectionRegistry } : undefined);
|
|
626
|
+
}
|
|
608
627
|
export { asRawClient };
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
import type { FileContext } from "@cosmicdrift/kumiko-types/file-handle-types";
|
|
1
2
|
export type StoredFileStore = {
|
|
2
3
|
list(prefix: string): Promise<readonly string[]>;
|
|
3
4
|
delete(key: string): Promise<void>;
|
|
4
5
|
};
|
|
5
6
|
export declare function listDerivativeKeys(originalKey: string, store: Pick<StoredFileStore, "list">, errorContext: string): Promise<readonly string[]>;
|
|
6
7
|
export declare function deleteStoredFileAndDerivatives(originalKey: string, store: StoredFileStore, errorContext: string): Promise<readonly string[]>;
|
|
8
|
+
export declare function deleteFileRefBytes(files: FileContext, row: {
|
|
9
|
+
readonly storageKey: string;
|
|
10
|
+
}, errorContext: string): Promise<readonly string[]>;
|
|
@@ -68,3 +68,12 @@ export async function deleteStoredFileAndDerivatives(originalKey, store, errorCo
|
|
|
68
68
|
}
|
|
69
69
|
return failedKeys;
|
|
70
70
|
}
|
|
71
|
+
// deleteStoredFileAndDerivatives through ctx.files for a key read from a tenant-filtered fileRef
|
|
72
|
+
// row. After a tenant handover the key keeps the previous tenant's prefix, which ref() and list()
|
|
73
|
+
// reject; every key the helper deletes is the original or one of its derivatives.
|
|
74
|
+
export function deleteFileRefBytes(files, row, errorContext) {
|
|
75
|
+
return deleteStoredFileAndDerivatives(row.storageKey, {
|
|
76
|
+
list: () => files.listFromFileRef(row),
|
|
77
|
+
delete: (key) => files.refFromFileRef({ storageKey: key }).delete(),
|
|
78
|
+
}, errorContext);
|
|
79
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export type { StoredFileStore } from "./delete-stored-file.js";
|
|
2
|
-
export { deleteStoredFileAndDerivatives, listDerivativeKeys } from "./delete-stored-file.js";
|
|
2
|
+
export { deleteFileRefBytes, deleteStoredFileAndDerivatives, listDerivativeKeys, } from "./delete-stored-file.js";
|
|
3
3
|
export type { DerivativesContextDeps } from "./derivatives-context.js";
|
|
4
4
|
export { createDerivativesContext, resolveRenderer } from "./derivatives-context.js";
|
|
5
5
|
export { resolveFieldVariant } from "./field-variants.js";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { deleteStoredFileAndDerivatives, listDerivativeKeys } from "./delete-stored-file.js";
|
|
1
|
+
export { deleteFileRefBytes, deleteStoredFileAndDerivatives, listDerivativeKeys, } from "./delete-stored-file.js";
|
|
2
2
|
export { createDerivativesContext, resolveRenderer } from "./derivatives-context.js";
|
|
3
3
|
export { resolveFieldVariant } from "./field-variants.js";
|
|
4
4
|
export { canonicalJson, derivativeListPrefix, isDerivativeKeyOf, parseDerivativeKey, specHash, VARIANT_NAME_PATTERN, variantSuffix, } from "./variant-key.js";
|
|
@@ -929,6 +929,9 @@ export function validateLockedWhen(feature) {
|
|
|
929
929
|
if (isCiphertextAtRest(field)) {
|
|
930
930
|
throw new Error(`${where} is personal or encrypted — the lock condition is evaluated on the stored value, which is ciphertext. Use a plaintext field.`);
|
|
931
931
|
}
|
|
932
|
+
if (field.access?.read !== undefined) {
|
|
933
|
+
throw new Error(`${where} has a read restriction — roles that cannot read it get records without it, and the client would show every record as locked. Drop field access.read or use another field.`);
|
|
934
|
+
}
|
|
932
935
|
if (!LOCKABLE_FIELD_TYPES.has(field.type)) {
|
|
933
936
|
throw new Error(`${where} has type "${field.type}", which does not store a plain scalar the condition can compare. Use a text, select, boolean, number or reference field.`);
|
|
934
937
|
}
|
|
@@ -11,6 +11,18 @@ export function checkQueryRef(queryHandlers, query, buildPrefix) {
|
|
|
11
11
|
return;
|
|
12
12
|
throw new Error(`${buildPrefix()} query "${query}" ${NOT_REGISTERED_SUFFIX}`);
|
|
13
13
|
}
|
|
14
|
+
// visibleWhen queries are run with an empty payload by the client gate, so a query
|
|
15
|
+
// that requires input would fail on every call and the screen/panel would stay
|
|
16
|
+
// unavailable with no hint.
|
|
17
|
+
function checkVisibleWhenQueryRef(queryHandlers, query, buildPrefix) {
|
|
18
|
+
checkQueryRef(queryHandlers, query, buildPrefix);
|
|
19
|
+
const handler = queryHandlers.get(query);
|
|
20
|
+
// skip: nothing to check for an unknown handler (checkQueryRef already threw) or an input-free query
|
|
21
|
+
if (handler === undefined || handler.schema.safeParse({}).success)
|
|
22
|
+
return;
|
|
23
|
+
throw new Error(`${buildPrefix()} query "${query}" requires input; visibleWhen queries run with an empty payload. ` +
|
|
24
|
+
"Make the query's input fields optional or use a query without required input.");
|
|
25
|
+
}
|
|
14
26
|
// relatedList is currently only reachable on projectionDetail — screens.ts
|
|
15
27
|
// rejects it outright on entityEdit/actionForm/configEdit (fw#2166). Walking
|
|
16
28
|
// the shared EditLayout uniformly here (instead of excluding those three
|
|
@@ -27,7 +39,7 @@ function checkDashboardQueryRefs(queryHandlers, featureName, screenId, screen) {
|
|
|
27
39
|
if (panel.kind === "custom")
|
|
28
40
|
continue;
|
|
29
41
|
if (panel.visibleWhen !== undefined) {
|
|
30
|
-
|
|
42
|
+
checkVisibleWhenQueryRef(queryHandlers, panel.visibleWhen.query, () => `[Feature ${featureName}] Screen "${screenId}" (dashboard) ${panel.kind === "screen" ? "screen-panel" : "panel"} "${panel.id}" visibleWhen`);
|
|
31
43
|
}
|
|
32
44
|
if (panel.kind === "screen")
|
|
33
45
|
continue;
|
|
@@ -60,7 +72,7 @@ function checkFormFieldQueryRefs(queryHandlers, featureName, screenId, screenTyp
|
|
|
60
72
|
}
|
|
61
73
|
function checkScreenQueryRefs(queryHandlers, featureName, screenId, screen) {
|
|
62
74
|
if (screen.visibleWhen !== undefined) {
|
|
63
|
-
|
|
75
|
+
checkVisibleWhenQueryRef(queryHandlers, screen.visibleWhen.query, () => `[Feature ${featureName}] Screen "${screenId}" (${screen.type}) visibleWhen`);
|
|
64
76
|
}
|
|
65
77
|
if (screen.type === "projectionList") {
|
|
66
78
|
checkQueryRef(queryHandlers, screen.query, () => `[Feature ${featureName}] Screen "${screenId}" (projectionList)`);
|
|
@@ -11,13 +11,40 @@ function sameKeys(a, b) {
|
|
|
11
11
|
const bKeys = new Set(Object.keys(b ?? {}));
|
|
12
12
|
return aKeys.length === bKeys.size && aKeys.every((key) => bKeys.has(key));
|
|
13
13
|
}
|
|
14
|
+
const KEYED_SURFACE_PROPERTIES = [
|
|
15
|
+
"entities",
|
|
16
|
+
"writeHandlers",
|
|
17
|
+
"queryHandlers",
|
|
18
|
+
"streamHandlers",
|
|
19
|
+
"screens",
|
|
20
|
+
"navs",
|
|
21
|
+
"jobs",
|
|
22
|
+
"events",
|
|
23
|
+
"translations",
|
|
24
|
+
"configKeys",
|
|
25
|
+
"notifications",
|
|
26
|
+
"metrics",
|
|
27
|
+
];
|
|
28
|
+
// Hooks are keyed per phase, then per target: flatten so a hook added under an
|
|
29
|
+
// existing phase still shows up as a difference.
|
|
30
|
+
function hookKeys(feature) {
|
|
31
|
+
const keys = new Set();
|
|
32
|
+
for (const [phase, byTarget] of Object.entries(feature.hooks ?? {})) {
|
|
33
|
+
for (const target of Object.keys(byTarget ?? {}))
|
|
34
|
+
keys.add(`${phase}/${target}`);
|
|
35
|
+
}
|
|
36
|
+
return keys;
|
|
37
|
+
}
|
|
38
|
+
function sameHookKeys(a, b) {
|
|
39
|
+
const aKeys = hookKeys(a);
|
|
40
|
+
const bKeys = hookKeys(b);
|
|
41
|
+
return aKeys.size === bKeys.size && [...aKeys].every((key) => bKeys.has(key));
|
|
42
|
+
}
|
|
14
43
|
// dedupeOptions is a declaration, not proof: two same-named features that both
|
|
15
44
|
// carry `{}` but register different surface must still clash, not silently drop one.
|
|
16
45
|
function sameRegisteredSurface(a, b) {
|
|
17
|
-
return (sameKeys(a
|
|
18
|
-
|
|
19
|
-
sameKeys(a.queryHandlers, b.queryHandlers) &&
|
|
20
|
-
sameKeys(a.streamHandlers, b.streamHandlers));
|
|
46
|
+
return (KEYED_SURFACE_PROPERTIES.every((property) => sameKeys(a[property], b[property])) &&
|
|
47
|
+
sameHookKeys(a, b));
|
|
21
48
|
}
|
|
22
49
|
function isInterchangeable(existing, next) {
|
|
23
50
|
if (existing === next)
|
|
@@ -4,6 +4,7 @@ import { collectReferenceFields, enrichRowWithReferences, enrichWithReferences,
|
|
|
4
4
|
import { createEventStoreExecutor } from "../db/event-store-executor.js";
|
|
5
5
|
import { buildEntityTable } from "../db/table-builder.js";
|
|
6
6
|
import { acknowledgeConventionCrossTenant, reportConventionCrossTenantUse, } from "../db/tenant-db.js";
|
|
7
|
+
import { InternalError } from "../errors/index.js";
|
|
7
8
|
import { isSystemIdentity } from "../pipeline/system-identity-switch.js";
|
|
8
9
|
import { assertUnreachable } from "../utils/index.js";
|
|
9
10
|
import { PAGED_QUERY_HANDLER_BRAND } from "./define-handler.js";
|
|
@@ -49,8 +50,8 @@ const WRITE_VERBS = ["create", "update", "delete", "restore"];
|
|
|
49
50
|
const QUERY_VERBS = ["list", "detail"];
|
|
50
51
|
const idSchema = z.object({ id: z.uuid() });
|
|
51
52
|
const SYSTEM_ADMIN_ROLE = "SystemAdmin";
|
|
52
|
-
// A
|
|
53
|
-
// role may reach it — otherwise any tenant-level role holder could mutate foreign tenants.
|
|
53
|
+
// A handler with escapeHatch reads/writes rows of every tenant, so only an operator
|
|
54
|
+
// role may reach it — otherwise any tenant-level role holder could read or mutate foreign tenants.
|
|
54
55
|
function isSystemAdminOnly(access) {
|
|
55
56
|
return ("roles" in access &&
|
|
56
57
|
access.roles.length > 0 &&
|
|
@@ -65,8 +66,8 @@ function resolveCrossTenantReason(name, options, kind) {
|
|
|
65
66
|
const placeholderProblem = codemodPlaceholderReasonProblem(options.escapeHatch.reason);
|
|
66
67
|
if (placeholderProblem !== undefined)
|
|
67
68
|
throw new Error(`"${name}": ${placeholderProblem}`);
|
|
68
|
-
if (
|
|
69
|
-
throw new Error(`"${name}": a
|
|
69
|
+
if (!isSystemAdminOnly(options.access)) {
|
|
70
|
+
throw new Error(`"${name}": a ${kind} handler with escapeHatch reaches across every tenant, so its access must be SystemAdmin-only ({ roles: ["${SYSTEM_ADMIN_ROLE}"] }); got ${JSON.stringify(options.access)}.`);
|
|
70
71
|
}
|
|
71
72
|
return options.escapeHatch.reason;
|
|
72
73
|
}
|
|
@@ -235,7 +236,13 @@ export function defineEntityWriteHandler(name, entity, options) {
|
|
|
235
236
|
}
|
|
236
237
|
if (typeof rowTenantId !== "string" || rowTenantId === user.tenantId)
|
|
237
238
|
return undefined;
|
|
238
|
-
|
|
239
|
+
const streamTenantId = parseTenantId(rowTenantId);
|
|
240
|
+
if (streamTenantId === null) {
|
|
241
|
+
throw new InternalError({
|
|
242
|
+
message: `"${name}": row ${String(id)} has tenantId "${rowTenantId}" which is not a TenantId; refusing to append to the wrong stream.`,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
return streamTenantId;
|
|
239
246
|
};
|
|
240
247
|
let schema;
|
|
241
248
|
let handler;
|
|
@@ -569,7 +576,7 @@ export function registerEntityCrud(r, entityName, entity, options) {
|
|
|
569
576
|
...writeOpts,
|
|
570
577
|
access: requireAccess(verb, options?.verbAccess?.[verb] ?? writeOpts?.access),
|
|
571
578
|
description: options?.descriptions?.[verb] ?? writeOpts?.description,
|
|
572
|
-
...(verbAgent !== undefined && { agent: verbAgent }),
|
|
579
|
+
...(verbAgent !== undefined && { agent: { ...writeOpts?.agent, ...verbAgent } }),
|
|
573
580
|
...(excludeFields !== undefined && { excludeFields }),
|
|
574
581
|
};
|
|
575
582
|
};
|
|
@@ -579,7 +586,7 @@ export function registerEntityCrud(r, entityName, entity, options) {
|
|
|
579
586
|
...readOpts,
|
|
580
587
|
access: requireAccess(verb, options?.verbAccess?.[verb] ?? readOpts?.access),
|
|
581
588
|
description: options?.descriptions?.[verb] ?? readOpts?.description,
|
|
582
|
-
...(verbAgent !== undefined && { agent: verbAgent }),
|
|
589
|
+
...(verbAgent !== undefined && { agent: { ...readOpts?.agent, ...verbAgent } }),
|
|
583
590
|
};
|
|
584
591
|
};
|
|
585
592
|
if (verbs.create) {
|
package/dist/engine/index.d.ts
CHANGED
|
@@ -78,7 +78,7 @@ export { unwrapWorkflowStepError, WorkflowStepError } from "./steps/workflow-ste
|
|
|
78
78
|
export { WORKFLOW_SYSTEM_EVENT_DEFS } from "./steps/workflow-system-events.js";
|
|
79
79
|
export { ANONYMOUS_ROLE, ANONYMOUS_USER_ID, createAnonymousUser, createSystemUser, SYSTEM_ROLE, SYSTEM_USER_ID, } from "./system-user.js";
|
|
80
80
|
export { assertNotificationVariables, templateVariable, templateVariablesToZod, } from "./template-variables.js";
|
|
81
|
-
export { type EffectiveFeaturesResolver, findTierResolverUsage, isTierResolverPlugin, TENANT_TIER_RESOLVER_EXT, type TierResolverPlugin, type TrialGate, } from "./tier-resolver-extension.js";
|
|
81
|
+
export { type EffectiveFeaturesResolver, findTierResolverUsage, isTierResolverPlugin, requireTierResolverPlugin, TENANT_TIER_RESOLVER_EXT, type TierResolverPlugin, type TrialGate, } from "./tier-resolver-extension.js";
|
|
82
82
|
export { findOverDeepTransferChain, resolveTransferAdjacency as resolveEntityTransferAdjacency, type TransferAdjacency, type TransferEdge, type TransferEdgeLink, } from "./transfer-adjacency.js";
|
|
83
83
|
export { isSystemTenant, isUuid, parseTenantId, SYSTEM_TENANT_ID } from "./types/identifiers.js";
|
|
84
84
|
export type { AccessRule, ActionFormScreenDefinition, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, BeforeTransactionPhase, BeforeTransactionResult, BelongsToRelation, BigIntFieldDef, BooleanFieldDef, CamelToKebab, ChatSendFailureCode, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, ConcurrencyMode, ConfigAccessor, ConfigAccessorFactory, ConfigBacking, ConfigCascade, ConfigCascadeLevel, ConfigDefinition, ConfigEditScreenDefinition, ConfigKeyAccess, ConfigKeyDefinition, ConfigKeyHandle, ConfigKeyType, ConfigMask, ConfigResolver, ConfigScope, ConfigSecretsReader, ConfigSeedDef, ConfigStoredRow, ConfigStoredRowWithSource, ConfigValidateContext, ConfigValidateFn, ConfigValue, ConfigValueSource, ConfigValueWithSource, ContentCollectionDefinition, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, CustomScreenDefinition, CustomScreenRoute, DashboardChartPanel, DashboardCustomPanel, DashboardDateParam, DashboardFeedPanel, DashboardFilterDefinition, DashboardI18nText, DashboardListPanel, DashboardMoneyParam, DashboardPanelDefinition, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardText, DashboardTextParam, DashboardValueFormat, DateFieldDef, DecimalFieldDef, DeleteContext, DeliveryErrorCode, DeliveryFailureCode, DeliverySkipReason, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EditExtensionSection, EditFieldPrefill, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EmbeddedListEditSpec, EntityDefinition, EntityEditAction, EntityEditActionPlacement, EntityEditScreenDefinition, EntityId, EntityListExpandableRow, EntityListScreenDefinition, EntityRef, EntityRelations, EscapeHatchAuditSink, EscapeHatchDeclaration, EscapeHatchKind, EscapeHatchReporter, EscapeHatchTarget, EscapeHatchUseEvent, EventDef, ExtensionOptionsArgs, ExtensionOptionsFor, FeatureDefinition, FeatureRegistrar, FieldAccess, FieldCondition, FieldDefinition, FieldFormatRegistry, FieldIconKey, FieldRenderer, FileFieldDef, FilesFieldDef, Findability, FormatSpec, FormWidth, HandlerContext, HasManyRelation, HookMap, ImageFieldDef, ImagesFieldDef, JobBackoff, JobBackoffStrategy, JobContext, JobDefinition, JobHandlerFn, JobTrigger, JobTriggerEvent, JsonbFieldDef, KumikoEntityTypeMap, KumikoEventTypeMap, KumikoExtensionOptionsMap, KumikoHandlerPayloadMap, KumikoHandlerResultMap, LifecycleHookType, ListColumnSpec, ListFacetSpec, ListPaginationMode, ListSortDir, ListSortSpec, LongTextFindability, ManyToManyRelation, MemberReader, MetricNavigate, MetricSpec, MspErrorMode, MspErrorPolicy, MultiSelectFieldDef, MultiStreamApplyFn, MultiStreamProjectionDefinition, NameOrRef, NavDefinition, NavIconKey, NotificationDataFn, NotificationDefinition, NotificationLocalizedTemplate, NotificationRecipientFn, NotificationRecordMapping, NotificationTemplateBody, NotificationTemplateFn, NotificationTemplateMeta, NotifyDeferred, NotifyDelivery, NotifyDeliveryStatus, NotifyDispatched, NotifyFactory, NotifyFn, NotifyJobDispatcher, NotifyOptions, NotifyPriority, NotifyResult, NumberFieldDef, OnDeleteStrategy, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, ParentRefDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, PersonalSubject, PlatformComponent, PluralForms, PostDeleteHookFn, PostSaveHookFn, PreDeleteHookFn, PreparedWriteEvent, PreQueryHookFn, PreSaveHookFn, ProjectionDefinition, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, ProjectionTable, QualifiedEventName, QueryEvent, QueryHandlerDef, QueryHandlerFn, RateLimitPer, RecordHeaderSpec, RecordHeaderSubtitlePart, ReferenceDataDef, Registry, RelationDefinition, ResolvedPiiFlags, RetentionDef, RoleAccessPersonalData, RoleAccessRule, RowAction, RowActionNavigate, RowActionNavigateBase, RowActionWriteHandler, RowFieldExtractor, SaveContext, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenNavSugar, ScreenSlots, SecretKeyDefinition, SecretKeyHandle, SecretMintConfirmStep, SecretMintScreenDefinition, SecretNamespaceDefinition, SecretNamespaceHandle, SecretNamespaceOptions, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, SelectFieldDef, SessionUser, SessionUserOrigin, StreamHandlerDef, StreamHandlerFn, Subscribe, TargetRef, TemplateListVariableDef, TemplateScalarVariableDef, TemplateVariableDef, TemplateVariables, TenantId, TenantWriteTarget, TextFieldDef, ToolbarAction, TranslationEntry, TranslationKeys, TranslationsDef, TranslationValue, TreeAction, TreeActionDef, TreeActionsHandle, TreeChildrenSubscribe, TreeNode, TreeNodeState, UnitKey, UnsafeAppendEventFn, ValidationError, ValidationHookFn, WorkspaceDefinition, WriteEvent, WriteHandlerDef, WriteHandlerFn, WriteResult, } from "./types/index.js";
|
package/dist/engine/index.js
CHANGED
|
@@ -71,7 +71,7 @@ export { unwrapWorkflowStepError, WorkflowStepError } from "./steps/workflow-ste
|
|
|
71
71
|
export { WORKFLOW_SYSTEM_EVENT_DEFS } from "./steps/workflow-system-events.js";
|
|
72
72
|
export { ANONYMOUS_ROLE, ANONYMOUS_USER_ID, createAnonymousUser, createSystemUser, SYSTEM_ROLE, SYSTEM_USER_ID, } from "./system-user.js";
|
|
73
73
|
export { assertNotificationVariables, templateVariable, templateVariablesToZod, } from "./template-variables.js";
|
|
74
|
-
export { findTierResolverUsage, isTierResolverPlugin, TENANT_TIER_RESOLVER_EXT, } from "./tier-resolver-extension.js";
|
|
74
|
+
export { findTierResolverUsage, isTierResolverPlugin, requireTierResolverPlugin, TENANT_TIER_RESOLVER_EXT, } from "./tier-resolver-extension.js";
|
|
75
75
|
export { findOverDeepTransferChain, resolveTransferAdjacency as resolveEntityTransferAdjacency, } from "./transfer-adjacency.js";
|
|
76
76
|
export { isSystemTenant, isUuid, parseTenantId, SYSTEM_TENANT_ID } from "./types/identifiers.js";
|
|
77
77
|
export { DEFAULT_CURRENCIES, DEFAULT_LOCALES, DELIVERY_FAILURE_CODES, DELIVERY_SKIP_REASONS, HookPhases, HTTP_ERROR_CODE_PATTERN, isAgentVisibleScreen, isDeliveryErrorCode, isOpenToAllGranted, resolveAgentExposure, } from "./types/index.js";
|
|
@@ -34,6 +34,7 @@ export type UnavailableSelectOption = {
|
|
|
34
34
|
};
|
|
35
35
|
/** Options of a select field that are offered for the given row values, in declaration order. */
|
|
36
36
|
export declare function availableSelectOptions(field: SelectOptionRules, values: Record<string, unknown>): readonly string[];
|
|
37
|
+
export declare function isUnchangedValue(a: unknown, b: unknown): boolean;
|
|
37
38
|
/** Select values that violate a `conditionalOptions` rule. With `previous`, a field is only
|
|
38
39
|
* checked when it or one of its condition fields changed, so legacy rows stay editable. */
|
|
39
40
|
export declare function findUnavailableSelectOptions(fields: Readonly<Record<string, FieldDefinition>>, values: Record<string, unknown>, previous?: Record<string, unknown>): readonly UnavailableSelectOption[];
|
|
@@ -126,7 +126,7 @@ export function availableSelectOptions(field, values) {
|
|
|
126
126
|
return field.options;
|
|
127
127
|
return field.options.filter((option) => rules.every((rule) => !rule.options.includes(option) || evalFieldCondition(rule.when, values)));
|
|
128
128
|
}
|
|
129
|
-
function
|
|
129
|
+
export function isUnchangedValue(a, b) {
|
|
130
130
|
return JSON.stringify(a) === JSON.stringify(b);
|
|
131
131
|
}
|
|
132
132
|
/** Select values that violate a `conditionalOptions` rule. With `previous`, a field is only
|
|
@@ -141,7 +141,7 @@ export function findUnavailableSelectOptions(fields, values, previous) {
|
|
|
141
141
|
continue;
|
|
142
142
|
if (previous) {
|
|
143
143
|
const watched = [name, ...def.conditionalOptions.map((rule) => rule.when.field)];
|
|
144
|
-
if (watched.every((key) =>
|
|
144
|
+
if (watched.every((key) => isUnchangedValue(values[key], previous[key])))
|
|
145
145
|
continue;
|
|
146
146
|
}
|
|
147
147
|
const allowed = availableSelectOptions(def, values);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { unsafeRawForDeclaredStep } from "../../db/tenant-db.js";
|
|
1
|
+
import { requestedTenantIds, unsafeRawForDeclaredStep } from "../../db/tenant-db.js";
|
|
2
2
|
import { InternalError } from "../../errors/classes.js";
|
|
3
3
|
import { SYSTEM_TENANT_ID } from "../types/identifiers.js";
|
|
4
4
|
// unsafeRaw fails closed without the handler's escapeHatch (or systemScope) and reports the use.
|
|
@@ -25,7 +25,10 @@ export function warnOnNarrowedForeignTenantFilter(ctx, stepKind, stepName, where
|
|
|
25
25
|
if (requested === undefined)
|
|
26
26
|
return;
|
|
27
27
|
const allowed = [ctx.db.tenantId, SYSTEM_TENANT_ID];
|
|
28
|
-
const requestedList =
|
|
28
|
+
const requestedList = requestedTenantIds(requested);
|
|
29
|
+
// skip: unrecognised operator form, narrowing cannot be judged.
|
|
30
|
+
if (requestedList === undefined)
|
|
31
|
+
return;
|
|
29
32
|
// skip: every requested tenant is within the caller's scope.
|
|
30
33
|
if (requestedList.every((t) => typeof t === "string" && allowed.includes(t)))
|
|
31
34
|
return;
|
|
@@ -61,4 +61,5 @@ export type TierResolverPlugin = {
|
|
|
61
61
|
* plugin shape — the only thing every implementation must provide is the
|
|
62
62
|
* `build` factory. */
|
|
63
63
|
export declare function isTierResolverPlugin(v: unknown): v is TierResolverPlugin;
|
|
64
|
+
export declare function requireTierResolverPlugin(usage: RegistrarExtensionRegistration): TierResolverPlugin;
|
|
64
65
|
export declare function findTierResolverUsage(features: readonly FeatureDefinition[]): RegistrarExtensionRegistration | undefined;
|
|
@@ -42,6 +42,12 @@ export const TENANT_TIER_RESOLVER_EXT = "tenantTierResolver";
|
|
|
42
42
|
export function isTierResolverPlugin(v) {
|
|
43
43
|
return isPlainObject(v) && typeof v["build"] === "function";
|
|
44
44
|
}
|
|
45
|
+
export function requireTierResolverPlugin(usage) {
|
|
46
|
+
if (!isTierResolverPlugin(usage.options)) {
|
|
47
|
+
throw new Error("tier-resolver registration options must provide a build() factory");
|
|
48
|
+
}
|
|
49
|
+
return usage.options;
|
|
50
|
+
}
|
|
45
51
|
export function findTierResolverUsage(features) {
|
|
46
52
|
for (const feature of features) {
|
|
47
53
|
for (const usage of feature.extensionUsages) {
|
|
@@ -3,7 +3,7 @@ import type { AuthRoutesConfig } from "../api/auth-routes.js";
|
|
|
3
3
|
import type { JwtHelper, JwtKeyring } from "../api/jwt.js";
|
|
4
4
|
import type { ServerOptions } from "../api/server.js";
|
|
5
5
|
import type { SseBroker } from "../api/sse-broker.js";
|
|
6
|
-
import type { PgClient } from "../db/connection.js";
|
|
6
|
+
import type { PgClient, PgListenClient } from "../db/connection.js";
|
|
7
7
|
import type { AppContext, Registry } from "../engine/types/index.js";
|
|
8
8
|
import type { JobRunner, JobRunnerOptions } from "../jobs/job-runner.js";
|
|
9
9
|
import type { Lifecycle } from "../lifecycle/index.js";
|
|
@@ -82,4 +82,4 @@ export type AllInOneEntrypoint = Omit<ApiEntrypoint, "mode"> & Omit<WorkerEntryp
|
|
|
82
82
|
export declare function createApiEntrypoint(options: ApiEntrypointOptions): ApiEntrypoint;
|
|
83
83
|
export declare function createWorkerEntrypoint(options: WorkerEntrypointOptions): WorkerEntrypoint;
|
|
84
84
|
export declare function createAllInOneEntrypoint(options: AllInOneEntrypointOptions): AllInOneEntrypoint;
|
|
85
|
-
export type { PgClient };
|
|
85
|
+
export type { PgClient, PgListenClient };
|
package/dist/errors/docs-url.js
CHANGED
|
@@ -20,17 +20,17 @@ function isCoveredByConfig(reason, errorDocs) {
|
|
|
20
20
|
function errorsUrl(baseUrl, slug) {
|
|
21
21
|
return `${baseUrl}/errors/${encodeURIComponent(slug)}`;
|
|
22
22
|
}
|
|
23
|
-
// A reason the framework documents (or the
|
|
24
|
-
//
|
|
25
|
-
//
|
|
23
|
+
// A reason the framework documents (or no reason, or the code itself) links to the framework docs, overridable via
|
|
24
|
+
// KUMIKO_DOCS_URL. An app's own reason links to the app docs where `errorDocs` covers it; every
|
|
25
|
+
// other reason falls back to the framework page of the error code, which exists for each code.
|
|
26
26
|
export function resolveErrorDocsUrl(err, errorDocs) {
|
|
27
27
|
const reason = reasonOf(err);
|
|
28
|
+
const frameworkBaseUrl = process.env["KUMIKO_DOCS_URL"] ?? DEFAULT_DOCS_BASE_URL;
|
|
28
29
|
if (reason === undefined || reason === err.code || frameworkReasons.has(reason)) {
|
|
29
|
-
|
|
30
|
-
return errorsUrl(baseUrl, reason ?? err.code);
|
|
30
|
+
return errorsUrl(frameworkBaseUrl, reason ?? err.code);
|
|
31
31
|
}
|
|
32
32
|
if (errorDocs && isCoveredByConfig(reason, errorDocs)) {
|
|
33
33
|
return errorsUrl(errorDocs.baseUrl, reason);
|
|
34
34
|
}
|
|
35
|
-
return
|
|
35
|
+
return errorsUrl(frameworkBaseUrl, err.code);
|
|
36
36
|
}
|
package/dist/errors/index.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ export type { AgentReason, FrameworkReason } from "./reasons.js";
|
|
|
9
9
|
export { AgentReasons, FrameworkReasons } from "./reasons.js";
|
|
10
10
|
export type { ErrorLogEntry, ErrorResponseBody } from "./serialize.js";
|
|
11
11
|
export { buildErrorLog, serializeError } from "./serialize.js";
|
|
12
|
-
export { subjectErasedConflict } from "./subject-erased.js";
|
|
12
|
+
export { isSubjectErasedConflict, subjectErasedConflict } from "./subject-erased.js";
|
|
13
13
|
export { toKumikoError } from "./to-kumiko-error.js";
|
|
14
14
|
export type { InvalidTransitionDetails } from "./transition-details.js";
|
|
15
15
|
export { buildInvalidTransitionDetails } from "./transition-details.js";
|
package/dist/errors/index.js
CHANGED
|
@@ -4,7 +4,7 @@ export { isKumikoError, KumikoError } from "./kumiko-error.js";
|
|
|
4
4
|
export { memberResolutionReadOnlyDenied } from "./member-resolution.js";
|
|
5
5
|
export { AgentReasons, FrameworkReasons } from "./reasons.js";
|
|
6
6
|
export { buildErrorLog, serializeError } from "./serialize.js";
|
|
7
|
-
export { subjectErasedConflict } from "./subject-erased.js";
|
|
7
|
+
export { isSubjectErasedConflict, subjectErasedConflict } from "./subject-erased.js";
|
|
8
8
|
export { toKumikoError } from "./to-kumiko-error.js";
|
|
9
9
|
export { buildInvalidTransitionDetails } from "./transition-details.js";
|
|
10
10
|
export { failNotFound, failTransition, failUnprocessable, reraiseAsKumikoError, toWriteErrorInfo, writeFailure, } from "./write-error-info.js";
|