@cosmicdrift/kumiko-framework 0.361.0 → 0.363.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-middleware.js +7 -5
- package/dist/api/redis-sse-broker.js +10 -8
- package/dist/api/routes.js +2 -2
- package/dist/api/server.js +3 -0
- package/dist/bun-db/query.d.ts +6 -4
- package/dist/bun-db/query.js +66 -92
- package/dist/crypto/kek-source.js +5 -3
- package/dist/crypto/pii-field-encryption.d.ts +4 -1
- package/dist/crypto/pii-field-encryption.js +2 -2
- package/dist/db/apply-entity-event.js +2 -2
- package/dist/db/cursor.js +2 -1
- package/dist/db/dialect.d.ts +1 -0
- package/dist/db/dialect.js +28 -13
- package/dist/db/entity-table-from-registry.js +1 -1
- package/dist/db/entity-table-meta.js +5 -13
- package/dist/db/event-store-executor-context.js +6 -6
- package/dist/db/event-store-executor-read.js +9 -9
- package/dist/db/migrate-generator.js +5 -3
- package/dist/db/migrate-runner.js +31 -35
- package/dist/db/parent-ref-clause.js +2 -2
- package/dist/db/queries/backfill-pii.js +11 -9
- package/dist/db/queries/es-ops.js +1 -1
- package/dist/db/queries/event-consumer.d.ts +6 -6
- package/dist/db/queries/event-consumer.js +17 -17
- package/dist/db/queries/event-store-admin.js +6 -6
- package/dist/db/queries/event-store-transfer.js +2 -2
- package/dist/db/queries/event-store.d.ts +1 -1
- package/dist/db/queries/event-store.js +2 -2
- package/dist/db/queries/pii-backfill-state.js +2 -2
- package/dist/db/queries/projection-rebuild.d.ts +1 -1
- package/dist/db/queries/raw-sql.js +1 -1
- package/dist/db/queries/test-stack.js +1 -1
- package/dist/db/reference-data.js +3 -5
- package/dist/db/table-builder.js +6 -4
- package/dist/db/table-introspection.d.ts +16 -0
- package/dist/db/table-introspection.js +76 -0
- package/dist/db/tenant-db.d.ts +3 -2
- package/dist/db/tenant-db.js +4 -10
- package/dist/derivatives/variant-key.js +2 -1
- package/dist/engine/boot-validator/access-declarations.js +29 -18
- package/dist/engine/boot-validator/card-meta-overflow.js +5 -2
- package/dist/engine/boot-validator/entity-list-screens.js +1 -1
- package/dist/engine/boot-validator/pii-retention.js +1 -1
- package/dist/engine/boot-validator/screens.js +2 -1
- package/dist/engine/build-app-schema.js +1 -0
- package/dist/engine/changeset-changes.js +3 -3
- package/dist/engine/define-handler.js +3 -6
- package/dist/engine/embedded-derived.js +10 -6
- package/dist/engine/extensions/escape-hatch-usage.js +2 -1
- package/dist/engine/factories.js +5 -6
- package/dist/engine/feature-ast/extractors/events.js +43 -33
- package/dist/engine/feature-ast/extractors/round3.d.ts +2 -2
- package/dist/engine/feature-ast/extractors/round3.js +4 -4
- package/dist/engine/feature-ast/patcher.d.ts +3 -3
- package/dist/engine/feature-ast/patcher.js +3 -3
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +8 -8
- package/dist/engine/feature-ast/pattern-change-schema.js +3 -3
- package/dist/engine/feature-ast/patterns.d.ts +3 -3
- package/dist/engine/feature-ast/render.js +6 -3
- package/dist/engine/feature-changelog.d.ts +2 -1
- package/dist/engine/feature-changelog.js +8 -6
- package/dist/engine/feature-config-events-jobs.d.ts +17 -7
- package/dist/engine/feature-config-events-jobs.js +20 -5
- package/dist/engine/feature-ui-extensions.js +2 -3
- package/dist/engine/index.d.ts +2 -1
- package/dist/engine/index.js +1 -0
- package/dist/engine/ownership.js +7 -20
- package/dist/engine/pattern-library/mixed-schemas.js +0 -1
- package/dist/engine/registry-ingest.d.ts +2 -1
- package/dist/engine/registry-ingest.js +85 -2
- package/dist/engine/registry-validate.d.ts +1 -0
- package/dist/engine/registry-validate.js +38 -8
- package/dist/engine/registry.js +2 -1
- package/dist/engine/schema-builder.js +1 -3
- package/dist/engine/soft-delete-cleanup.js +2 -1
- package/dist/engine/steps/call-feature.js +1 -3
- package/dist/engine/steps/unsafe-projection-upsert.js +2 -13
- package/dist/engine/template-variables.d.ts +29 -0
- package/dist/engine/template-variables.js +77 -0
- package/dist/engine/tier-resolver-extension.js +2 -3
- package/dist/engine/types/index.d.ts +2 -2
- package/dist/engine/validate-projection-allowlist.js +12 -13
- package/dist/engine/where-rule-lint.js +8 -7
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +2 -0
- package/dist/files/bind-referenced-files.write.d.ts +16 -0
- package/dist/files/bind-referenced-files.write.js +109 -0
- package/dist/files/feature.d.ts +1 -0
- package/dist/files/feature.js +2 -0
- package/dist/files/file-routes.d.ts +2 -1
- package/dist/files/file-routes.js +10 -3
- package/dist/files/local-provider.js +2 -1
- package/dist/files/record-write-check.d.ts +1 -0
- package/dist/files/record-write-check.js +9 -9
- package/dist/i18n/index.d.ts +1 -1
- package/dist/i18n/index.js +1 -1
- package/dist/i18n/mail-registry.d.ts +2 -0
- package/dist/i18n/mail-registry.js +7 -0
- package/dist/jobs/job-runner.js +5 -5
- package/dist/pipeline/dispatch-query.js +2 -3
- package/dist/pipeline/dispatch-shared.js +45 -14
- package/dist/pipeline/event-dispatcher-delivery.js +2 -3
- package/dist/pipeline/index.d.ts +1 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/pipeline/member-read-only-transaction.js +1 -1
- package/dist/pipeline/notify-scope.d.ts +13 -0
- package/dist/pipeline/notify-scope.js +32 -0
- package/dist/pipeline/system-hooks.js +2 -1
- package/dist/pipeline/system-identity-switch.js +2 -2
- package/dist/pipeline/write-origin.js +2 -2
- package/dist/search/meilisearch-adapter.js +3 -4
- package/dist/secrets/stored-envelope.js +7 -7
- package/dist/stack/push-entity-projection-tables.js +2 -3
- package/dist/stack/table-helpers.js +10 -26
- package/dist/stack/test-stack.js +2 -5
- package/dist/testing/multipart-helper.d.ts +0 -6
- package/dist/testing/multipart-helper.js +5 -5
- package/dist/testing/without-ambient-temporal.js +4 -4
- package/dist/ui-types/card-column-roles.d.ts +5 -0
- package/dist/ui-types/card-column-roles.js +22 -4
- package/dist/ui-types/index.d.ts +1 -1
- package/dist/ui-types/index.js +1 -1
- package/dist/utils/is-plain-object.d.ts +1 -2
- package/dist/utils/is-plain-object.js +1 -4
- package/package.json +4 -4
- package/src/changes.json +88 -0
|
@@ -5,14 +5,14 @@ import { asRawClient } from "../query.js";
|
|
|
5
5
|
// unassigned one. Both travel as strings — bigint/xid8 doesn't round-trip
|
|
6
6
|
// through the driver as a JS number.
|
|
7
7
|
export async function selectSnapshotXmin(db) {
|
|
8
|
-
const rows =
|
|
8
|
+
const rows = await asRawClient(db).unsafe(`SELECT pg_snapshot_xmin(pg_current_snapshot())::text AS xmin`);
|
|
9
9
|
const xmin = rows[0]?.xmin;
|
|
10
10
|
if (xmin === undefined)
|
|
11
11
|
throw new Error("selectSnapshotXmin: no row returned");
|
|
12
12
|
return xmin;
|
|
13
13
|
}
|
|
14
14
|
export async function selectSnapshotXmax(db) {
|
|
15
|
-
const rows =
|
|
15
|
+
const rows = await asRawClient(db).unsafe(`SELECT pg_snapshot_xmax(pg_current_snapshot())::text AS xmax`);
|
|
16
16
|
const xmax = rows[0]?.xmax;
|
|
17
17
|
if (xmax === undefined)
|
|
18
18
|
throw new Error("selectSnapshotXmax: no row returned");
|
|
@@ -36,7 +36,7 @@ export async function lockEventConsumersShareMode(db) {
|
|
|
36
36
|
// is still in flight, which pg_snapshot_xip being empty rules out, so the window
|
|
37
37
|
// scan is skipped then.
|
|
38
38
|
export async function selectEventIdHorizonWithMissingRanges(db) {
|
|
39
|
-
const rows =
|
|
39
|
+
const rows = await asRawClient(db).unsafe(`WITH a AS (
|
|
40
40
|
SELECT "last_processed_event_id" AS cursor, "pending_gaps" AS gaps
|
|
41
41
|
FROM "kumiko_event_consumers"
|
|
42
42
|
ORDER BY "last_processed_event_id" DESC
|
|
@@ -64,7 +64,7 @@ export async function selectEventIdHorizonWithMissingRanges(db) {
|
|
|
64
64
|
r.t::text AS gap_to,
|
|
65
65
|
r.x AS gap_xmax
|
|
66
66
|
FROM b LEFT JOIN r ON true
|
|
67
|
-
ORDER BY r.f`)
|
|
67
|
+
ORDER BY r.f`);
|
|
68
68
|
const first = rows[0];
|
|
69
69
|
if (first === undefined)
|
|
70
70
|
throw new Error("selectEventIdHorizonWithMissingRanges: no row returned");
|
|
@@ -89,7 +89,7 @@ export async function selectEventIdHorizonWithMissingRanges(db) {
|
|
|
89
89
|
// subquery only ever affects the row this statement inserts.
|
|
90
90
|
export async function insertConsumerIfAbsent(db, name, instanceId, startFrom = "beginning") {
|
|
91
91
|
if (startFrom === "now") {
|
|
92
|
-
const existing =
|
|
92
|
+
const existing = await asRawClient(db).unsafe(`SELECT 1 FROM "kumiko_event_consumers" WHERE "name" = $1 AND "instance_id" = $2`, [name, instanceId]);
|
|
93
93
|
// skip: already registered — an existing cursor is never re-seeded.
|
|
94
94
|
if (existing.length > 0)
|
|
95
95
|
return;
|
|
@@ -116,18 +116,18 @@ export async function insertConsumerIfAbsent(db, name, instanceId, startFrom = "
|
|
|
116
116
|
// cycle. The
|
|
117
117
|
// type-only import keeps the parameter pinned to the "dead" literal.
|
|
118
118
|
export async function selectProvablyIdleConsumerPairs(db, names, instanceIds, deadStatus) {
|
|
119
|
-
const rows =
|
|
119
|
+
const rows = await asRawClient(db).unsafe(`SELECT c."name" AS name, c."instance_id" AS instance_id
|
|
120
120
|
FROM "kumiko_event_consumers" c
|
|
121
121
|
WHERE ("name", "instance_id") = ANY (SELECT * FROM unnest($1::text[], $2::text[]))
|
|
122
122
|
AND "status" != $3
|
|
123
123
|
AND "pending_gaps" = '[]'::jsonb
|
|
124
124
|
AND NOT EXISTS (
|
|
125
125
|
SELECT 1 FROM "kumiko_events" e WHERE e."id" > c."last_processed_event_id"
|
|
126
|
-
)`, [names, instanceIds, deadStatus])
|
|
126
|
+
)`, [names, instanceIds, deadStatus]);
|
|
127
127
|
return rows.map((r) => ({ name: r.name, instanceId: r.instance_id }));
|
|
128
128
|
}
|
|
129
129
|
export async function selectConsumerForUpdateSkipLocked(db, name, instanceId) {
|
|
130
|
-
const rows =
|
|
130
|
+
const rows = await asRawClient(db).unsafe(`SELECT * FROM "kumiko_event_consumers" WHERE "name" = $1 AND "instance_id" = $2 FOR UPDATE SKIP LOCKED`, [name, instanceId]);
|
|
131
131
|
return rows[0];
|
|
132
132
|
}
|
|
133
133
|
export async function markConsumerProcessing(db, name, instanceId) {
|
|
@@ -194,15 +194,15 @@ export async function updateConsumerDeliveryOutcome(db, name, instanceId, outcom
|
|
|
194
194
|
]);
|
|
195
195
|
}
|
|
196
196
|
export async function updateConsumerStatusReturning(db, name, instanceId, status) {
|
|
197
|
-
const rows =
|
|
197
|
+
const rows = await asRawClient(db).unsafe(`UPDATE "kumiko_event_consumers" SET "status" = $1, "attempts" = 0, "last_error" = NULL, "rearm_count" = 0, "updated_at" = now()
|
|
198
198
|
WHERE "name" = $2 AND "instance_id" = $3
|
|
199
|
-
RETURNING *`, [status, name, instanceId])
|
|
199
|
+
RETURNING *`, [status, name, instanceId]);
|
|
200
200
|
return rows[0];
|
|
201
201
|
}
|
|
202
202
|
export async function advanceConsumerPastEventReturning(db, name, instanceId, eventId,
|
|
203
203
|
// Full list, including ids skipped over between the old cursor and eventId that were not visible yet.
|
|
204
204
|
pendingGaps) {
|
|
205
|
-
const rows =
|
|
205
|
+
const rows = await asRawClient(db).unsafe(`UPDATE "kumiko_event_consumers" SET
|
|
206
206
|
"last_processed_event_id" = $1,
|
|
207
207
|
"status" = 'idle',
|
|
208
208
|
"attempts" = 0,
|
|
@@ -213,7 +213,7 @@ pendingGaps) {
|
|
|
213
213
|
"pending_gaps" = $4::text::jsonb,
|
|
214
214
|
"updated_at" = now()
|
|
215
215
|
WHERE "name" = $2 AND "instance_id" = $3
|
|
216
|
-
RETURNING *`, [eventId, name, instanceId, JSON.stringify(pendingGaps)])
|
|
216
|
+
RETURNING *`, [eventId, name, instanceId, JSON.stringify(pendingGaps)]);
|
|
217
217
|
return rows[0];
|
|
218
218
|
}
|
|
219
219
|
export async function resetConsumerForMspRebuild(db, name, instanceId) {
|
|
@@ -229,7 +229,7 @@ export async function resetConsumerForMspRebuild(db, name, instanceId) {
|
|
|
229
229
|
"updated_at" = now()`, [name, instanceId]);
|
|
230
230
|
}
|
|
231
231
|
export async function selectConsumerForUpdate(db, name, instanceId) {
|
|
232
|
-
const rows =
|
|
232
|
+
const rows = await asRawClient(db).unsafe(`SELECT * FROM "kumiko_event_consumers" WHERE "name" = $1 AND "instance_id" = $2 FOR UPDATE`, [name, instanceId]);
|
|
233
233
|
return rows[0];
|
|
234
234
|
}
|
|
235
235
|
export async function updateConsumerRebuildCursor(db, name, instanceId, lastProcessedEventId, pendingGaps) {
|
|
@@ -254,14 +254,14 @@ export async function markConsumerRebuildFailed(db, name, instanceId, errorMessa
|
|
|
254
254
|
// so the caller can enforce a lifetime cap on automatic revivals; manual
|
|
255
255
|
// restartConsumer()/enableConsumer() reset it back to 0.
|
|
256
256
|
export async function rearmDeadConsumer(db, name, instanceId) {
|
|
257
|
-
const rows =
|
|
257
|
+
const rows = await asRawClient(db).unsafe(`UPDATE "kumiko_event_consumers" SET
|
|
258
258
|
"status" = 'idle',
|
|
259
259
|
"attempts" = 0,
|
|
260
260
|
"last_error" = NULL,
|
|
261
261
|
"rearm_count" = "rearm_count" + 1,
|
|
262
262
|
"updated_at" = now()
|
|
263
263
|
WHERE "name" = $1 AND "instance_id" = $2
|
|
264
|
-
RETURNING *`, [name, instanceId])
|
|
264
|
+
RETURNING *`, [name, instanceId]);
|
|
265
265
|
return rows[0];
|
|
266
266
|
}
|
|
267
267
|
// skipPoisonEvent's pending-gap branch: the poison is a pending id below the
|
|
@@ -269,7 +269,7 @@ export async function rearmDeadConsumer(db, name, instanceId) {
|
|
|
269
269
|
// last_processed_event_id (advancing it here would be a regression — the
|
|
270
270
|
// cursor already sits above this id).
|
|
271
271
|
export async function removePendingGapReturning(db, name, instanceId, newPendingGaps) {
|
|
272
|
-
const rows =
|
|
272
|
+
const rows = await asRawClient(db).unsafe(`UPDATE "kumiko_event_consumers" SET
|
|
273
273
|
"pending_gaps" = $1::text::jsonb,
|
|
274
274
|
"status" = 'idle',
|
|
275
275
|
"attempts" = 0,
|
|
@@ -278,6 +278,6 @@ export async function removePendingGapReturning(db, name, instanceId, newPending
|
|
|
278
278
|
"last_failed_event_id" = NULL,
|
|
279
279
|
"updated_at" = now()
|
|
280
280
|
WHERE "name" = $2 AND "instance_id" = $3
|
|
281
|
-
RETURNING *`, [JSON.stringify(newPendingGaps), name, instanceId])
|
|
281
|
+
RETURNING *`, [JSON.stringify(newPendingGaps), name, instanceId]);
|
|
282
282
|
return rows[0];
|
|
283
283
|
}
|
|
@@ -18,7 +18,7 @@ export async function insertRawFirstEvent(db, params) {
|
|
|
18
18
|
]);
|
|
19
19
|
}
|
|
20
20
|
export async function insertRawSubsequentEvent(db, params) {
|
|
21
|
-
const rows =
|
|
21
|
+
const rows = await asRawClient(db).unsafe(`INSERT INTO "kumiko_events" (
|
|
22
22
|
aggregate_id, aggregate_type, tenant_id, version,
|
|
23
23
|
type, event_version, payload, metadata, created_at, created_by
|
|
24
24
|
)
|
|
@@ -41,7 +41,7 @@ export async function insertRawSubsequentEvent(db, params) {
|
|
|
41
41
|
params.createdAt,
|
|
42
42
|
params.createdBy,
|
|
43
43
|
params.expectedVersion,
|
|
44
|
-
])
|
|
44
|
+
]);
|
|
45
45
|
return rows.length > 0;
|
|
46
46
|
}
|
|
47
47
|
export async function insertRawEventBatch(db, sqlValues, params) {
|
|
@@ -52,18 +52,18 @@ export async function insertRawEventBatch(db, sqlValues, params) {
|
|
|
52
52
|
VALUES ${sqlValues}`, params);
|
|
53
53
|
}
|
|
54
54
|
export async function eventPredecessorExists(db, aggregateId, tenantId, version) {
|
|
55
|
-
const rows =
|
|
55
|
+
const rows = await asRawClient(db).unsafe(`SELECT EXISTS(
|
|
56
56
|
SELECT 1 FROM "kumiko_events"
|
|
57
57
|
WHERE aggregate_id = $1::uuid
|
|
58
58
|
AND tenant_id = $2::uuid
|
|
59
59
|
AND version = $3
|
|
60
|
-
) AS present`, [aggregateId, tenantId, version])
|
|
60
|
+
) AS present`, [aggregateId, tenantId, version]);
|
|
61
61
|
return rows[0]?.present === true;
|
|
62
62
|
}
|
|
63
63
|
export async function findExistingEventVersion(db, sqlInClause, params) {
|
|
64
|
-
const rows =
|
|
64
|
+
const rows = await asRawClient(db).unsafe(`SELECT aggregate_id, version FROM "kumiko_events"
|
|
65
65
|
WHERE (tenant_id, aggregate_id, version) IN (${sqlInClause})
|
|
66
|
-
LIMIT 1`, params)
|
|
66
|
+
LIMIT 1`, params);
|
|
67
67
|
const row = rows[0];
|
|
68
68
|
if (!row)
|
|
69
69
|
return undefined;
|
|
@@ -7,9 +7,9 @@ import { asRawClient } from "../query.js";
|
|
|
7
7
|
// Postgres' RETURNING clause has no DISTINCT keyword — dedupe the
|
|
8
8
|
// per-event-row hits in-process instead of relying on invalid SQL.
|
|
9
9
|
export async function transferEventRows(db, params) {
|
|
10
|
-
const rows =
|
|
10
|
+
const rows = await asRawClient(db).unsafe(`UPDATE "kumiko_events" SET "tenant_id" = $1 ` +
|
|
11
11
|
`WHERE "tenant_id" = $2 AND "aggregate_type" = $3 AND "aggregate_id" = ANY($4) ` +
|
|
12
|
-
`RETURNING "aggregate_id" AS id`, [params.destinationTenantId, params.sourceTenantId, params.aggregateType, params.aggregateIds])
|
|
12
|
+
`RETURNING "aggregate_id" AS id`, [params.destinationTenantId, params.sourceTenantId, params.aggregateType, params.aggregateIds]);
|
|
13
13
|
return [...new Set(rows.map((row) => row.id))];
|
|
14
14
|
}
|
|
15
15
|
// Snapshots are a pure read-performance cache (event-store/snapshot.ts) —
|
|
@@ -56,5 +56,5 @@ export type PendingIdRange = {
|
|
|
56
56
|
readonly from: bigint;
|
|
57
57
|
readonly to: bigint;
|
|
58
58
|
};
|
|
59
|
-
export declare function selectPendingAndNewEventRows(db: AnyDb, cursor: bigint, pendingRanges: readonly PendingIdRange[], batchSize: number): Promise<ReadonlyArray<
|
|
59
|
+
export declare function selectPendingAndNewEventRows<TRow = Record<string, unknown>>(db: AnyDb, cursor: bigint, pendingRanges: readonly PendingIdRange[], batchSize: number): Promise<ReadonlyArray<TRow>>;
|
|
60
60
|
export declare function selectSmallestVisibleIdInRanges(db: AnyDb, pendingRanges: readonly PendingIdRange[]): Promise<bigint | null>;
|
|
@@ -139,7 +139,7 @@ function duplicateIdempotencyKeyErrorOr(e) {
|
|
|
139
139
|
}
|
|
140
140
|
/** INSERT … SELECT … WHERE EXISTS predecessor — stays raw (typed builder can't express this). */
|
|
141
141
|
export async function insertSubsequentEventRow(db, params) {
|
|
142
|
-
const rows =
|
|
142
|
+
const rows = await asRawClient(db).unsafe(`INSERT INTO "kumiko_events" (
|
|
143
143
|
aggregate_id, aggregate_type, tenant_id, version,
|
|
144
144
|
type, event_version, payload, metadata, created_by, created_at
|
|
145
145
|
)
|
|
@@ -164,7 +164,7 @@ export async function insertSubsequentEventRow(db, params) {
|
|
|
164
164
|
params.createdBy,
|
|
165
165
|
params.expectedVersion,
|
|
166
166
|
params.createdAt ?? null,
|
|
167
|
-
])
|
|
167
|
+
]);
|
|
168
168
|
return rows[0];
|
|
169
169
|
}
|
|
170
170
|
export async function selectStreamMaxVersion(db, aggregateId, tenantId) {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { asRawClient } from "../query.js";
|
|
2
2
|
export async function readPiiBackfillState(db, fingerprint) {
|
|
3
|
-
const rows =
|
|
3
|
+
const rows = await asRawClient(db).unsafe(`SELECT "cursor_event_id"::text AS "cursor", "completed_at" IS NOT NULL AS "completed",
|
|
4
4
|
"failed_event_ids"::text[] AS "failedEventIds"
|
|
5
|
-
FROM "kumiko_pii_backfill_state" WHERE "fingerprint" = $1`, [fingerprint])
|
|
5
|
+
FROM "kumiko_pii_backfill_state" WHERE "fingerprint" = $1`, [fingerprint]);
|
|
6
6
|
return rows[0] ?? null;
|
|
7
7
|
}
|
|
8
8
|
export async function persistPiiBackfillState(db, fingerprint, update) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { AnyDb } from "../query.js";
|
|
2
2
|
export declare function markProjectionRebuilding(db: AnyDb, projectionName: string): Promise<void>;
|
|
3
|
-
export declare function selectEventsForProjectionRebuildBatch(db: AnyDb, aggregateTypes: readonly string[], eventTypes: readonly string[], afterId: bigint, limit: number): Promise<ReadonlyArray<
|
|
3
|
+
export declare function selectEventsForProjectionRebuildBatch<TRow = Record<string, unknown>>(db: AnyDb, aggregateTypes: readonly string[], eventTypes: readonly string[], afterId: bigint, limit: number): Promise<ReadonlyArray<TRow>>;
|
|
4
4
|
export declare function countSubscribedEvents(db: AnyDb, aggregateTypes: readonly string[], eventTypes: readonly string[]): Promise<bigint>;
|
|
5
5
|
export declare function finalizeProjectionRebuild(db: AnyDb, projectionName: string, lastProcessedEventId: bigint): Promise<void>;
|
|
6
6
|
export declare function markProjectionRebuildFailed(db: AnyDb, projectionName: string, errorMessage: string): Promise<void>;
|
|
@@ -3,7 +3,7 @@ import { asRawClient, unsafeReadRetrying } from "../query.js";
|
|
|
3
3
|
* retrying an ambiguous write risks double-apply (#1358). Prefer {@link executeRawQueryRead}
|
|
4
4
|
* for SELECT-only paths. */
|
|
5
5
|
export async function executeRawQuery(db, sqlText, params = []) {
|
|
6
|
-
return
|
|
6
|
+
return await asRawClient(db).unsafe(sqlText, params);
|
|
7
7
|
}
|
|
8
8
|
/** SELECT-only escape hatch with the #1163 closed-connection retry. Do not pass
|
|
9
9
|
* INSERT/UPDATE/DELETE — retry re-executes the statement. */
|
|
@@ -9,7 +9,7 @@ export async function truncateTablesRestartIdentity(db, tableNames) {
|
|
|
9
9
|
await asRawClient(db).unsafe(`TRUNCATE ${quoted} RESTART IDENTITY CASCADE`);
|
|
10
10
|
}
|
|
11
11
|
export async function databaseExists(db, dbName) {
|
|
12
|
-
const rows =
|
|
12
|
+
const rows = await asRawClient(db).unsafe(`SELECT EXISTS (SELECT 1 FROM pg_database WHERE datname = $1) AS exists`, [dbName]);
|
|
13
13
|
return rows[0]?.exists === true;
|
|
14
14
|
}
|
|
15
15
|
export async function createDatabase(db, dbName) {
|
|
@@ -1,11 +1,9 @@
|
|
|
1
|
-
import { KUMIKO_COLUMNS_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
2
1
|
import { fetchOne, insertOne, updateMany } from "../db/query.js";
|
|
3
2
|
import { SYSTEM_TENANT_ID } from "../engine/types/index.js";
|
|
3
|
+
import { tableColumnsOf } from "./table-introspection.js";
|
|
4
4
|
function hasColumn(table, field) {
|
|
5
|
-
const cols = table
|
|
6
|
-
|
|
7
|
-
return false;
|
|
8
|
-
return field in cols;
|
|
5
|
+
const cols = tableColumnsOf(table);
|
|
6
|
+
return cols !== undefined && field in cols;
|
|
9
7
|
}
|
|
10
8
|
/**
|
|
11
9
|
* Seed reference data at boot time.
|
package/dist/db/table-builder.js
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { KUMIKO_META_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
2
2
|
import { toSnakeCase } from "../utils/case.js";
|
|
3
3
|
import { assertUnreachable } from "../utils/index.js";
|
|
4
|
+
import { columnHandleName } from "./table-introspection.js";
|
|
4
5
|
export { toSnakeCase } from "../utils/case.js";
|
|
5
|
-
import { bigint, boolean, decimalColumn, doublePrecision, index, instant, integer, jsonb, moneyAmount, table as pgTable, plainDate, serial, sql, text, uniqueIndex, uuid, } from "./dialect.js";
|
|
6
|
+
import { bigint, boolean, decimalColumn, doublePrecision, index, instant, integer, isSqlExpression, jsonb, moneyAmount, table as pgTable, plainDate, serial, sql, text, uniqueIndex, uuid, } from "./dialect.js";
|
|
6
7
|
// Partial-index predicate for soft-delete entities; table-builder and
|
|
7
8
|
// entity-table-meta must render identical index definitions.
|
|
8
9
|
export const SOFT_DELETE_LIVE_ROW_PREDICATE = `"is_deleted" = false`;
|
|
9
10
|
// Shared by the CRUD executor and the parent-ref read-gate — a field's
|
|
10
11
|
// declared column name if it has one, else its snake_case default.
|
|
11
12
|
export function physicalColumnName(table, field) {
|
|
12
|
-
return table
|
|
13
|
+
return columnHandleName(table, field) ?? toSnakeCase(field);
|
|
13
14
|
}
|
|
14
15
|
// Returns column(s) for a field. Most fields return a single entry,
|
|
15
16
|
// money returns two (amount + currency).
|
|
@@ -281,7 +282,7 @@ export function declareGlobalTenancy(table) {
|
|
|
281
282
|
...meta,
|
|
282
283
|
tenancy: "global",
|
|
283
284
|
};
|
|
284
|
-
// @cast-boundary
|
|
285
|
+
// @cast-boundary phantom-brand — TenancyBrand<"global"> is a phantom marker with no runtime representation.
|
|
285
286
|
return table;
|
|
286
287
|
}
|
|
287
288
|
export function buildEntityTable(entityName, entity, options) {
|
|
@@ -377,7 +378,8 @@ export function buildEntityTable(entityName, entity, options) {
|
|
|
377
378
|
// entity.indexes[].where is now a SqlExpression (was drizzle SQL).
|
|
378
379
|
// Pass through to the IndexBuilderWithCols.where()-API.
|
|
379
380
|
if (def.where !== undefined) {
|
|
380
|
-
|
|
381
|
+
if (isSqlExpression(def.where))
|
|
382
|
+
chain = chain.where(def.where);
|
|
381
383
|
}
|
|
382
384
|
else if (def.unique === true && entity.softDelete === true) {
|
|
383
385
|
// Soft-deleted rows keep their value, so a full unique index would
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { EntityTableMeta } from "@cosmicdrift/kumiko-types/entity-table-meta-types";
|
|
2
|
+
export declare function isEntityTableMeta(value: unknown): value is EntityTableMeta;
|
|
3
|
+
export declare function asEntityTableMeta(table: unknown): EntityTableMeta | undefined;
|
|
4
|
+
export declare function requireEntityTableMeta(table: unknown, tableName: string): EntityTableMeta;
|
|
5
|
+
/** Physical table name from the unshadowable name symbol; undefined when absent. */
|
|
6
|
+
export declare function schemaTableName(table: unknown): string | undefined;
|
|
7
|
+
/** Name symbol first, then the canonical meta; "<unknown>" when neither resolves. */
|
|
8
|
+
export declare function tableNameOf(table: unknown): string;
|
|
9
|
+
/** Column-handle map stamped by table(); undefined for tables without one. */
|
|
10
|
+
export declare function tableColumnsOf(table: unknown): Record<string, unknown> | undefined;
|
|
11
|
+
/** SQL name carried by the enumerable column handle `table[field]`; undefined when the field has none. */
|
|
12
|
+
export declare function columnHandleName(table: unknown, field: string): string | undefined;
|
|
13
|
+
/** True when `table[field]` is defined, i.e. the table exposes a column handle (or plain value) for the field. */
|
|
14
|
+
export declare function hasFieldProperty(table: unknown, field: string): boolean;
|
|
15
|
+
/** SQL column name of a JS field via the column-handle map; undefined when not mapped. */
|
|
16
|
+
export declare function columnSqlNameOf(table: unknown, field: string): string | undefined;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { KUMIKO_COLUMNS_SYMBOL, KUMIKO_META_SYMBOL, KUMIKO_NAME_SYMBOL, } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
2
|
+
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
3
|
+
// table() spreads column handles onto the table object as enumerable props, so
|
|
4
|
+
// an entity field named `source`/`columns`/`tableName`/… shadows the matching
|
|
5
|
+
// meta key. The symbol-keyed slots are the only collision-safe source; plain
|
|
6
|
+
// metas (deriveEntityTableMeta / defineUnmanagedTable) carry no handle spread
|
|
7
|
+
// and are unshadowable by their structural shape.
|
|
8
|
+
export function isEntityTableMeta(value) {
|
|
9
|
+
return (typeof value === "object" &&
|
|
10
|
+
value !== null &&
|
|
11
|
+
"tableName" in value &&
|
|
12
|
+
typeof value.tableName === "string" &&
|
|
13
|
+
"columns" in value &&
|
|
14
|
+
Array.isArray(value.columns) &&
|
|
15
|
+
"indexes" in value &&
|
|
16
|
+
Array.isArray(value.indexes) &&
|
|
17
|
+
"source" in value &&
|
|
18
|
+
(value.source === "managed" || value.source === "unmanaged"));
|
|
19
|
+
}
|
|
20
|
+
export function asEntityTableMeta(table) {
|
|
21
|
+
if (typeof table !== "object" || table === null)
|
|
22
|
+
return undefined;
|
|
23
|
+
if (KUMIKO_META_SYMBOL in table && isEntityTableMeta(table[KUMIKO_META_SYMBOL])) {
|
|
24
|
+
return table[KUMIKO_META_SYMBOL];
|
|
25
|
+
}
|
|
26
|
+
return isEntityTableMeta(table) ? table : undefined;
|
|
27
|
+
}
|
|
28
|
+
export function requireEntityTableMeta(table, tableName) {
|
|
29
|
+
const meta = asEntityTableMeta(table);
|
|
30
|
+
if (!meta) {
|
|
31
|
+
throw new Error(`${tableName}: table carries no EntityTableMeta — built via table()?`);
|
|
32
|
+
}
|
|
33
|
+
return meta;
|
|
34
|
+
}
|
|
35
|
+
/** Physical table name from the unshadowable name symbol; undefined when absent. */
|
|
36
|
+
export function schemaTableName(table) {
|
|
37
|
+
if (typeof table !== "object" || table === null || !(KUMIKO_NAME_SYMBOL in table)) {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
const name = table[KUMIKO_NAME_SYMBOL];
|
|
41
|
+
return typeof name === "string" ? name : undefined;
|
|
42
|
+
}
|
|
43
|
+
/** Name symbol first, then the canonical meta; "<unknown>" when neither resolves. */
|
|
44
|
+
export function tableNameOf(table) {
|
|
45
|
+
return schemaTableName(table) ?? asEntityTableMeta(table)?.tableName ?? "<unknown>";
|
|
46
|
+
}
|
|
47
|
+
/** Column-handle map stamped by table(); undefined for tables without one. */
|
|
48
|
+
export function tableColumnsOf(table) {
|
|
49
|
+
if (typeof table !== "object" || table === null || !(KUMIKO_COLUMNS_SYMBOL in table)) {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
const columns = table[KUMIKO_COLUMNS_SYMBOL];
|
|
53
|
+
return isPlainObject(columns) ? columns : undefined;
|
|
54
|
+
}
|
|
55
|
+
/** SQL name carried by the enumerable column handle `table[field]`; undefined when the field has none. */
|
|
56
|
+
export function columnHandleName(table, field) {
|
|
57
|
+
if (!isPlainObject(table))
|
|
58
|
+
return undefined;
|
|
59
|
+
const handle = table[field];
|
|
60
|
+
if (!isPlainObject(handle))
|
|
61
|
+
return undefined;
|
|
62
|
+
const name = handle["name"];
|
|
63
|
+
return typeof name === "string" ? name : undefined;
|
|
64
|
+
}
|
|
65
|
+
/** True when `table[field]` is defined, i.e. the table exposes a column handle (or plain value) for the field. */
|
|
66
|
+
export function hasFieldProperty(table, field) {
|
|
67
|
+
return isPlainObject(table) && table[field] !== undefined;
|
|
68
|
+
}
|
|
69
|
+
/** SQL column name of a JS field via the column-handle map; undefined when not mapped. */
|
|
70
|
+
export function columnSqlNameOf(table, field) {
|
|
71
|
+
const handle = tableColumnsOf(table)?.[field];
|
|
72
|
+
if (!isPlainObject(handle))
|
|
73
|
+
return undefined;
|
|
74
|
+
const name = handle["name"];
|
|
75
|
+
return typeof name === "string" ? name : undefined;
|
|
76
|
+
}
|
package/dist/db/tenant-db.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { EntityTableMeta } from "@cosmicdrift/kumiko-types/entity-table-meta-types";
|
|
2
2
|
import type { EscapeHatchDeclaration, EscapeHatchReporter, EscapeHatchTarget } from "@cosmicdrift/kumiko-types/handlers";
|
|
3
|
-
import {
|
|
3
|
+
import type { SchemaTable } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
4
4
|
import { type TenantDb, type TenantDbMode, type UncheckedSystemDb } from "@cosmicdrift/kumiko-types/tenant-db-types";
|
|
5
5
|
import { asRawClient } from "../db/query.js";
|
|
6
6
|
import type { EntityDefinition } from "../engine/types/fields.js";
|
|
@@ -8,6 +8,7 @@ import { type TenantId } from "../engine/types/identifiers.js";
|
|
|
8
8
|
import type { Registry } from "../engine/types/index.js";
|
|
9
9
|
import { type Meter, type Tracer } from "../observability/index.js";
|
|
10
10
|
import type { DbRunner } from "./connection.js";
|
|
11
|
+
import { tableNameOf } from "./table-introspection.js";
|
|
11
12
|
type Table = SchemaTable;
|
|
12
13
|
export { SYSTEM_SCOPE_CHECK_BRAND, type TenantDb, type TenantDbMode, type UncheckedSystemDb, } from "@cosmicdrift/kumiko-types/tenant-db-types";
|
|
13
14
|
export declare function assertPersonalDataWrite(db: TenantDb, tableName: string, keys: readonly string[], entity: EntityDefinition): void;
|
|
@@ -16,7 +17,7 @@ export declare function withSystemDbUnsafeRawGrant(systemDb: UncheckedSystemDb,
|
|
|
16
17
|
export declare function createUncheckedSystemDb(db: TenantDb, dbOutsideTransaction?: TenantDb, report?: EscapeHatchReporter): UncheckedSystemDb;
|
|
17
18
|
export declare function createSystemDbView(db: TenantDb, dbOutsideTransaction?: TenantDb, report?: EscapeHatchReporter): UncheckedSystemDb;
|
|
18
19
|
export declare function castTenantRows<T>(rows: readonly Record<string, unknown>[]): readonly T[];
|
|
19
|
-
export
|
|
20
|
+
export { tableNameOf };
|
|
20
21
|
export declare function hasTenantColumn(table: Table | EntityTableMeta): boolean;
|
|
21
22
|
export type TenantDbGrants = {
|
|
22
23
|
readonly globalWrites?: EscapeHatchDeclaration;
|
package/dist/db/tenant-db.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { KUMIKO_NAME_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
2
1
|
import { SYSTEM_SCOPE_CHECK_BRAND, } from "@cosmicdrift/kumiko-types/tenant-db-types";
|
|
3
2
|
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";
|
|
4
3
|
import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
|
|
5
4
|
import { AccessDeniedError, InternalError, memberResolutionReadOnlyDenied, } from "../errors/index.js";
|
|
6
5
|
import { emitDbQuery, fallbackEscapeHatchReporter, registerStandardMetrics, } from "../observability/index.js";
|
|
7
6
|
import { bindProjectionRegistry, projectionRegistryOf } from "./projection-registry-binding.js";
|
|
7
|
+
import { hasFieldProperty, tableNameOf } from "./table-introspection.js";
|
|
8
8
|
import { bindTenantDbRunner, tenantDbRunner } from "./tenant-db-runner.js";
|
|
9
9
|
export { SYSTEM_SCOPE_CHECK_BRAND, } from "@cosmicdrift/kumiko-types/tenant-db-types";
|
|
10
10
|
const declaredUnsafeRawRunners = new WeakMap();
|
|
@@ -53,7 +53,6 @@ function boundRunner(runner, gate, projectionRegistry) {
|
|
|
53
53
|
}
|
|
54
54
|
return value.bind(target);
|
|
55
55
|
},
|
|
56
|
-
// @cast-boundary proxy-erasure — Proxy<object> re-tags as the wrapped DbRunner shape.
|
|
57
56
|
});
|
|
58
57
|
if (gate)
|
|
59
58
|
runnerPersonalDataGates.set(proxy, gate);
|
|
@@ -225,12 +224,7 @@ export function createSystemDbView(db, dbOutsideTransaction, report = fallbackEs
|
|
|
225
224
|
export function castTenantRows(rows) {
|
|
226
225
|
return rows;
|
|
227
226
|
}
|
|
228
|
-
export
|
|
229
|
-
const sym = table[KUMIKO_NAME_SYMBOL];
|
|
230
|
-
if (typeof sym === "string")
|
|
231
|
-
return sym;
|
|
232
|
-
return asEntityTableMeta(table)?.tableName ?? "<unknown>";
|
|
233
|
-
}
|
|
227
|
+
export { tableNameOf };
|
|
234
228
|
// A grant is only real when its reason is non-empty — `{ reason: "" }` must not silently unlock it.
|
|
235
229
|
function hasGrant(decl) {
|
|
236
230
|
return decl !== undefined && decl.reason.trim().length > 0;
|
|
@@ -247,7 +241,7 @@ export function hasTenantColumn(table) {
|
|
|
247
241
|
const meta = asEntityTableMeta(table);
|
|
248
242
|
if (meta)
|
|
249
243
|
return meta.columns.some((c) => c.name === "tenant_id");
|
|
250
|
-
return table
|
|
244
|
+
return hasFieldProperty(table, "tenantId");
|
|
251
245
|
}
|
|
252
246
|
const unsafeRawRebinders = new WeakMap();
|
|
253
247
|
// Rebinds tenantDb's unsafeRaw grant (e.g. a hook's own escapeHatch); inputs not built by
|
|
@@ -475,7 +469,7 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
|
|
|
475
469
|
report("global-write", globalWriteReason());
|
|
476
470
|
return withDbSpan("delete", table, async () => bunDeleteMany(db, table, where));
|
|
477
471
|
},
|
|
478
|
-
// @cast-boundary
|
|
472
|
+
// @cast-boundary phantom-brand — GlobalWrites is only present in the type when TTable is not ExecutorOnly; the object above always carries the methods.
|
|
479
473
|
};
|
|
480
474
|
}
|
|
481
475
|
function grantedUnsafeRawRunner(declaredStepReason) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
2
3
|
// Deterministic JSON: object keys sorted (recursively), `undefined` values
|
|
3
4
|
// dropped, arrays keep their order. Two specs that are semantically equal
|
|
4
5
|
// (same keys, different insertion order; explicit `undefined` vs. omitted)
|
|
@@ -7,7 +8,7 @@ export function canonicalJson(value) {
|
|
|
7
8
|
if (Array.isArray(value)) {
|
|
8
9
|
return `[${value.map((entry) => canonicalJson(entry)).join(",")}]`;
|
|
9
10
|
}
|
|
10
|
-
if (value
|
|
11
|
+
if (isPlainObject(value)) {
|
|
11
12
|
const entries = Object.entries(value)
|
|
12
13
|
.filter(([, v]) => v !== undefined)
|
|
13
14
|
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
@@ -67,16 +67,25 @@ function validateEscapeHatchReason(feature, kind, handlerName, escapeHatch) {
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
|
-
function
|
|
71
|
-
// skip:
|
|
72
|
-
if (
|
|
70
|
+
function validatePersonalDataSupportedForKind(feature, kind, handlerName, access) {
|
|
71
|
+
// skip: nothing declared
|
|
72
|
+
if (declaredPersonalData(access) === undefined)
|
|
73
73
|
return;
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
74
|
+
// skip: write handlers accept every personalData declaration
|
|
75
|
+
if (kind === "write")
|
|
76
|
+
return;
|
|
77
|
+
const isOpenToAll = "openToAll" in access;
|
|
78
|
+
// skip: query handlers may declare access.personalData, its value is validated separately
|
|
79
|
+
if (kind === "query" && !isOpenToAll)
|
|
80
|
+
return;
|
|
81
|
+
const property = isOpenToAll ? "openToAll.personalData" : "access.personalData";
|
|
82
|
+
const onlyAllowed = kind === "query"
|
|
83
|
+
? 'only access.personalData: "public-intake" applies to query handlers, which write ' +
|
|
84
|
+
"personal data through nested writes of an anonymous request."
|
|
85
|
+
: "it only applies to write handlers, whose input is checked for personal-data fields.";
|
|
86
|
+
throw new Error(`[Feature ${feature.name}] ${kind} handler "${handlerName}" declares ${property} — ${onlyAllowed}`);
|
|
78
87
|
}
|
|
79
|
-
function validatePersonalDataValue(feature, handlerName, access) {
|
|
88
|
+
function validatePersonalDataValue(feature, kind, handlerName, access) {
|
|
80
89
|
const declared = declaredPersonalData(access);
|
|
81
90
|
// skip: nothing declared
|
|
82
91
|
if (declared === undefined)
|
|
@@ -85,17 +94,17 @@ function validatePersonalDataValue(feature, handlerName, access) {
|
|
|
85
94
|
// skip: the one supported value on openToAll
|
|
86
95
|
if (declared === "tenant-members")
|
|
87
96
|
return;
|
|
88
|
-
throw new Error(`[Feature ${feature.name}]
|
|
97
|
+
throw new Error(`[Feature ${feature.name}] ${kind} handler "${handlerName}" declares an unknown ` +
|
|
89
98
|
`openToAll.personalData ${JSON.stringify(declared)} — the only supported value is "tenant-members".`);
|
|
90
99
|
}
|
|
91
100
|
if (declared !== "public-intake") {
|
|
92
|
-
throw new Error(`[Feature ${feature.name}]
|
|
101
|
+
throw new Error(`[Feature ${feature.name}] ${kind} handler "${handlerName}" declares an unknown ` +
|
|
93
102
|
`access.personalData ${JSON.stringify(declared)} — the only supported value is "public-intake".`);
|
|
94
103
|
}
|
|
95
104
|
// skip: roles include "anonymous", which personalData: "public-intake" requires
|
|
96
105
|
if (accessAllowsAnonymous(access))
|
|
97
106
|
return;
|
|
98
|
-
throw new Error(`[Feature ${feature.name}]
|
|
107
|
+
throw new Error(`[Feature ${feature.name}] ${kind} handler "${handlerName}" declares ` +
|
|
99
108
|
'access.personalData: "public-intake" but its roles do not include ' +
|
|
100
109
|
`"${ANONYMOUS_ROLE}" — personalData: "public-intake" only applies to handlers ` +
|
|
101
110
|
"that allow anonymous callers.");
|
|
@@ -150,14 +159,14 @@ function validateAnonymousPersonalData(feature, handlerName, handler) {
|
|
|
150
159
|
}
|
|
151
160
|
// The rateLimit is the only protection of a public-intake handler, so the
|
|
152
161
|
// generic anonymous opt-out (rateLimit: { disabled: true }) must not apply.
|
|
153
|
-
function validatePublicIntakeRateLimit(feature, handlerName, handler) {
|
|
154
|
-
// skip: not a public-intake handler
|
|
162
|
+
function validatePublicIntakeRateLimit(feature, kind, handlerName, handler) {
|
|
163
|
+
// skip: not a public-intake handler
|
|
155
164
|
if (declaredPersonalData(handler.access) !== "public-intake")
|
|
156
165
|
return;
|
|
157
166
|
// skip: rateLimit is active
|
|
158
167
|
if (!isRateLimitDisabled(handler.rateLimit))
|
|
159
168
|
return;
|
|
160
|
-
throw new Error(`[Feature ${feature.name}]
|
|
169
|
+
throw new Error(`[Feature ${feature.name}] ${kind} handler "${handlerName}" declares ` +
|
|
161
170
|
'access.personalData: "public-intake" with rateLimit: { disabled: true } — the rateLimit ' +
|
|
162
171
|
'is the only protection for anonymous personal-data writes. Declare rateLimit: { per: "ip" ' +
|
|
163
172
|
'or "ip+handler", ... } instead.');
|
|
@@ -166,19 +175,21 @@ export function validateAccessDeclarations(feature) {
|
|
|
166
175
|
for (const [handlerName, handler] of Object.entries(feature.writeHandlers)) {
|
|
167
176
|
validateOpenToAllReason(feature, "write", handlerName, handler.access);
|
|
168
177
|
validateEscapeHatchReason(feature, "write", handlerName, handler.escapeHatch);
|
|
169
|
-
validatePersonalDataValue(feature, handlerName, handler.access);
|
|
178
|
+
validatePersonalDataValue(feature, "write", handlerName, handler.access);
|
|
170
179
|
validateOpenToAllPersonalData(feature, handlerName, handler);
|
|
171
180
|
validateAnonymousPersonalData(feature, handlerName, handler);
|
|
172
|
-
validatePublicIntakeRateLimit(feature, handlerName, handler);
|
|
181
|
+
validatePublicIntakeRateLimit(feature, "write", handlerName, handler);
|
|
173
182
|
}
|
|
174
183
|
for (const [handlerName, handler] of Object.entries(feature.queryHandlers)) {
|
|
175
184
|
validateOpenToAllReason(feature, "query", handlerName, handler.access);
|
|
176
185
|
validateEscapeHatchReason(feature, "query", handlerName, handler.escapeHatch);
|
|
177
|
-
|
|
186
|
+
validatePersonalDataSupportedForKind(feature, "query", handlerName, handler.access);
|
|
187
|
+
validatePersonalDataValue(feature, "query", handlerName, handler.access);
|
|
188
|
+
validatePublicIntakeRateLimit(feature, "query", handlerName, handler);
|
|
178
189
|
}
|
|
179
190
|
for (const [handlerName, handler] of Object.entries(feature.streamHandlers)) {
|
|
180
191
|
validateOpenToAllReason(feature, "stream", handlerName, handler.access);
|
|
181
192
|
validateEscapeHatchReason(feature, "stream", handlerName, handler.escapeHatch);
|
|
182
|
-
|
|
193
|
+
validatePersonalDataSupportedForKind(feature, "stream", handlerName, handler.access);
|
|
183
194
|
}
|
|
184
195
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CARD_META_MAX, cardColumnRoles } from "../../ui-types/card-column-roles.js";
|
|
1
|
+
import { CARD_META_MAX, cardColumnRoles, cardFileDisplayKind, } from "../../ui-types/card-column-roles.js";
|
|
2
2
|
import { LIST_ROW_META_COLUMNS } from "../../ui-types/list-row-meta.js";
|
|
3
3
|
import { parseRefTarget } from "../parse-ref-target.js";
|
|
4
4
|
import { normalizeListColumn } from "../screen-helpers.js";
|
|
@@ -19,11 +19,14 @@ function resolveColumnType(spec, storedType, entity) {
|
|
|
19
19
|
function countOverflow(feature, screen, location, columns, storedTypeOf, entity) {
|
|
20
20
|
const cardColumns = columns.map((spec) => {
|
|
21
21
|
const col = normalizeListColumn(spec);
|
|
22
|
+
const type = resolveColumnType(spec, storedTypeOf(col.field), entity);
|
|
23
|
+
const fileKind = cardFileDisplayKind(type, col.renderer);
|
|
22
24
|
return {
|
|
23
25
|
field: col.field,
|
|
24
|
-
type
|
|
26
|
+
type,
|
|
25
27
|
renderer: col.renderer,
|
|
26
28
|
hideOnNarrow: col.hideOnNarrow,
|
|
29
|
+
...(fileKind !== undefined && { fileDisplay: { kind: fileKind } }),
|
|
27
30
|
};
|
|
28
31
|
});
|
|
29
32
|
const { meta } = cardColumnRoles(cardColumns);
|
|
@@ -15,7 +15,7 @@ function hasFilterableFields(feature, entityName) {
|
|
|
15
15
|
const entity = entities[entityName];
|
|
16
16
|
if (!entity)
|
|
17
17
|
return false;
|
|
18
|
-
return Object.values(entity.fields).some((raw) => raw.filterable === true);
|
|
18
|
+
return Object.values(entity.fields).some((raw) => "filterable" in raw && raw.filterable === true);
|
|
19
19
|
}
|
|
20
20
|
function hasEntityEditDetail(feature, entityName) {
|
|
21
21
|
return Object.values(feature.screens).some((s) => s.type === "entityEdit" && s.entity === entityName);
|