@cosmicdrift/kumiko-framework 0.362.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/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/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/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/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/file-routes.js +1 -1
- package/dist/files/local-provider.js +2 -1
- 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/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/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 +44 -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));
|
|
@@ -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);
|
|
@@ -48,7 +48,7 @@ function validateWriteOnlyField(featureName, entityName, fieldName, field) {
|
|
|
48
48
|
const where = `[Feature ${featureName}] Field "${fieldName}" on entity "${entityName}"`;
|
|
49
49
|
if (field.type === "embedded") {
|
|
50
50
|
for (const [subName, subField] of Object.entries(field.schema)) {
|
|
51
|
-
if (subField.writeOnly === true) {
|
|
51
|
+
if ("writeOnly" in subField && subField.writeOnly === true) {
|
|
52
52
|
// @cast-boundary schema-walk
|
|
53
53
|
throw new Error(`${where} declares writeOnly on embedded sub-field "${subName}" — writeOnly is only supported on top-level text fields.`);
|
|
54
54
|
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { resolveActionIcon } from "@cosmicdrift/kumiko-types/action-icon";
|
|
7
7
|
import { NO_WIDGET_FIELD_TYPES, optionsQueryFieldRefs, SELECT_OPTION_TONES, } from "@cosmicdrift/kumiko-types/fields";
|
|
8
8
|
import { NAV_ICON_KEYS } from "@cosmicdrift/kumiko-types/nav-icon";
|
|
9
|
+
import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
9
10
|
import { rowMetaFieldNames } from "../../db/table-builder.js";
|
|
10
11
|
import { REFERENCE_LOOKUP_SOURCES } from "../../ui-types/list-row-meta.js";
|
|
11
12
|
import { isEncryptedAtRest } from "../config-helpers.js";
|
|
@@ -275,7 +276,7 @@ function validateWizardLayout(featureName, screenId, screenType, layout, feature
|
|
|
275
276
|
// for an unregistered key instead of crashing — this check makes the same
|
|
276
277
|
// gap fail loudly at boot instead of silently at render.
|
|
277
278
|
function isRegisteredIcon(icon) {
|
|
278
|
-
return NAV_ICON_KEYS
|
|
279
|
+
return isOneOf(NAV_ICON_KEYS, icon);
|
|
279
280
|
}
|
|
280
281
|
export function validateActionHasIcon(featureName, screenId, screenType, context, action) {
|
|
281
282
|
const resolved = resolveActionIcon(action.id, action.icon);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
2
|
+
import { CHANGELOG_TYPES, validateChangelog } from "./feature-changelog.js";
|
|
2
3
|
const BLOCK_RE = /<!--\s*kumiko-changes\s*\n([\s\S]*?)\n\s*-->/g;
|
|
3
|
-
const TYPE_VALUES = new Set(["breaking", "improvement", "fix"]);
|
|
4
4
|
const KEY_RE = /^(feature|type|title|detail|migration|codemod|manualAfterCodemod):(?:\s(.*))?$/;
|
|
5
5
|
function requiredField(fields, field, source) {
|
|
6
6
|
const value = fields.get(field)?.trim();
|
|
@@ -95,7 +95,7 @@ function parseManualAfterCodemod(fields, source) {
|
|
|
95
95
|
function buildChange(fields, prose, source) {
|
|
96
96
|
const feature = requiredField(fields, "feature", source);
|
|
97
97
|
const rawType = requiredField(fields, "type", source);
|
|
98
|
-
if (!
|
|
98
|
+
if (!isOneOf(CHANGELOG_TYPES, rawType)) {
|
|
99
99
|
throw new Error(`${source}: kumiko-changes type must be breaking, improvement, or fix`);
|
|
100
100
|
}
|
|
101
101
|
const { title, detail } = resolveTitleAndDetail(fields, prose, source);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ENTITY_CONVENTION_QUERY_BRAND } from "@cosmicdrift/kumiko-types/handlers";
|
|
2
|
+
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
2
3
|
import { runPipeline } from "./run-pipeline.js";
|
|
3
4
|
export function defineWriteHandler(def,
|
|
4
5
|
// R6: a phantom rest-param. When the inferred response `TData` carries a
|
|
@@ -88,14 +89,10 @@ export function defineQueryHandler(def,
|
|
|
88
89
|
// brand through into the stored registry entry.
|
|
89
90
|
export const PAGED_QUERY_HANDLER_BRAND = "__kumikoPagedQueryHandler";
|
|
90
91
|
export function isPagedQueryHandler(def) {
|
|
91
|
-
|
|
92
|
-
// otherwise-typed handler definition; the property may legitimately be
|
|
93
|
-
// absent, which is exactly the case this function distinguishes.
|
|
94
|
-
return def[PAGED_QUERY_HANDLER_BRAND] === true;
|
|
92
|
+
return propertyOf(def, PAGED_QUERY_HANDLER_BRAND) === true;
|
|
95
93
|
}
|
|
96
94
|
export function isEntityConventionQueryHandler(def) {
|
|
97
|
-
|
|
98
|
-
return def[ENTITY_CONVENTION_QUERY_BRAND] === true;
|
|
95
|
+
return propertyOf(def, ENTITY_CONVENTION_QUERY_BRAND) === true;
|
|
99
96
|
}
|
|
100
97
|
// A projectionList screen's query must resolve to { rows, nextCursor, total? }
|
|
101
98
|
// (CursorResult<T>) — the renderer used to read rowsQuery.data?.rows and
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
1
2
|
/** Computes a derived cell from its source values. Missing/non-numeric
|
|
2
3
|
* sources: "multiply" with any missing source → undefined; "sum"/"subtract"
|
|
3
4
|
* treat missing as 0 only when at least one source is present — if every
|
|
@@ -6,9 +7,13 @@
|
|
|
6
7
|
* to the target sub-field's declared precision afterward. */
|
|
7
8
|
export function computeDerivedCellValue(op, values) {
|
|
8
9
|
if (op === "multiply") {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
let product = 1;
|
|
11
|
+
for (const value of values) {
|
|
12
|
+
if (value === undefined)
|
|
13
|
+
return undefined;
|
|
14
|
+
product *= value;
|
|
15
|
+
}
|
|
16
|
+
return product;
|
|
12
17
|
}
|
|
13
18
|
if (values.every((value) => value === undefined))
|
|
14
19
|
return undefined;
|
|
@@ -58,10 +63,9 @@ function roundHalfAwayFromZero(value, decimals) {
|
|
|
58
63
|
* it's written back, so a fractional product lands on a value the target
|
|
59
64
|
* type can actually represent. */
|
|
60
65
|
export function withDerivedCells(row, derived, schema) {
|
|
61
|
-
if (
|
|
66
|
+
if (!isPlainObject(row))
|
|
62
67
|
return row;
|
|
63
|
-
const
|
|
64
|
-
const copy = { ...source };
|
|
68
|
+
const copy = { ...row };
|
|
65
69
|
for (const [cellName, def] of Object.entries(derived)) {
|
|
66
70
|
const sourceValues = def.from.map((sourceField) => {
|
|
67
71
|
const value = copy[sourceField];
|
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
// validation (feature-ui-extensions.ts's useExtension) already rejects an
|
|
4
4
|
// empty reason, but options stays `Record<string, unknown>`, so orchestrators
|
|
5
5
|
// re-narrow at read time instead of trusting the shape.
|
|
6
|
+
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
6
7
|
export function extensionUsageEscapeHatchReason(usage) {
|
|
7
8
|
const escapeHatch = usage.options?.["escapeHatch"];
|
|
8
|
-
if (
|
|
9
|
+
if (!isPlainObject(escapeHatch))
|
|
9
10
|
return undefined;
|
|
10
11
|
const { reason, grants } = escapeHatch;
|
|
11
12
|
if (Array.isArray(grants) && !grants.includes("unsafeRaw"))
|