@cosmicdrift/kumiko-framework 0.354.0 → 0.354.1
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/db/event-store-executor-read.js +17 -9
- package/dist/db/queries/event-store.d.ts +1 -0
- package/dist/db/queries/event-store.js +11 -0
- package/dist/engine/schema-builder.js +35 -2
- package/dist/event-store/event-store.d.ts +1 -0
- package/dist/event-store/event-store.js +4 -1
- package/dist/event-store/index.d.ts +1 -1
- package/dist/event-store/index.js +1 -1
- package/package.json +4 -4
- package/src/changes.json +11 -0
|
@@ -6,7 +6,7 @@ import { coerceRow, extractTableInfo } from "../db/query.js";
|
|
|
6
6
|
import { buildOwnershipClause, combineClauses, normalizeAccessEntry, shiftParams, } from "../engine/ownership.js";
|
|
7
7
|
import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
|
|
8
8
|
import { UnprocessableError } from "../errors/index.js";
|
|
9
|
-
import { getStreamVersion } from "../event-store/index.js";
|
|
9
|
+
import { getStreamVersion, getStreamVersions } from "../event-store/index.js";
|
|
10
10
|
import { LIST_ROW_META_REFERENCES } from "../ui-types/list-row-meta.js";
|
|
11
11
|
import { rehydrateCompoundTypes } from "./compound-types.js";
|
|
12
12
|
import { decodeKeysetCursor, encodeCursor, encodeKeysetCursor } from "./cursor.js";
|
|
@@ -64,6 +64,9 @@ function toCursorSortText(value) {
|
|
|
64
64
|
}
|
|
65
65
|
return undefined;
|
|
66
66
|
}
|
|
67
|
+
function withStreamVersion(row, streamVersion) {
|
|
68
|
+
return streamVersion > 0 ? { ...row, version: streamVersion } : row;
|
|
69
|
+
}
|
|
67
70
|
// Keyset boundary for `ORDER BY <sort> <dir>, id ASC` under Postgres' DEFAULT
|
|
68
71
|
// null ordering (ASC → NULLS LAST, DESC → NULLS FIRST). An explicit NULLS clause
|
|
69
72
|
// would change the visible order and cost the sort its index.
|
|
@@ -661,11 +664,17 @@ export function createReadVerbs(ctx) {
|
|
|
661
664
|
// field (ciphertext leaked to the caller).
|
|
662
665
|
const tableInfo = extractTableInfo(table);
|
|
663
666
|
const encryptedRows = rawRows.map((r) => rehydrateCompoundTypes(coerceRow(r, tableInfo), entity));
|
|
664
|
-
const
|
|
665
|
-
//
|
|
666
|
-
//
|
|
667
|
-
//
|
|
668
|
-
|
|
667
|
+
const decryptedRows = await Promise.all(encryptedRows.map((r) => decryptForRead(r)));
|
|
668
|
+
// Stream version is authoritative (see detail()): ctx.appendEvent bumps the
|
|
669
|
+
// stream without touching row.version, so the raw row version would hand
|
|
670
|
+
// clients a stale optimistic-lock base. Rows without events keep theirs.
|
|
671
|
+
const streamVersions = decryptedRows.length > 0
|
|
672
|
+
? await getStreamVersions(runner, decryptedRows.map((r) => String(r["id"])), streamTenantFor(user))
|
|
673
|
+
: new Map();
|
|
674
|
+
const rows = decryptedRows.map((r) => withStreamVersion(r, streamVersions.get(String(r["id"])) ?? 0));
|
|
675
|
+
// Cache the still-encrypted raw rows (read-row version): same at-rest guarantee as
|
|
676
|
+
// detail()'s encryptForStorage round-trip, without a re-encrypt; detail() rejects
|
|
677
|
+
// a cached version that differs from the stream.
|
|
669
678
|
if (entityCache && entityName && rows.length > 0) {
|
|
670
679
|
await entityCache.mset(user.tenantId, entityName, encryptedRows.map((r) => ({ id: r["id"], data: r })));
|
|
671
680
|
}
|
|
@@ -718,7 +727,6 @@ export function createReadVerbs(ctx) {
|
|
|
718
727
|
// guaranteed version_conflict.
|
|
719
728
|
// A cache hit only counts when its version equals the live stream version:
|
|
720
729
|
// a late entityCache.set() can resurrect an old row after the writer's del().
|
|
721
|
-
const withVersion = (row, streamVersion) => (streamVersion > 0 ? { ...row, version: streamVersion } : row);
|
|
722
730
|
if (entityCache && entityName) {
|
|
723
731
|
const cached = await entityCache.get(user.tenantId, entityName, payload.id);
|
|
724
732
|
if (cached) {
|
|
@@ -736,7 +744,7 @@ export function createReadVerbs(ctx) {
|
|
|
736
744
|
// Cached rows are stored re-encrypted (see the `set` below) so an
|
|
737
745
|
// `encrypted` field's plaintext never sits in a second at-rest
|
|
738
746
|
// store (Redis) the field-encryption feature doesn't cover.
|
|
739
|
-
return
|
|
747
|
+
return withStreamVersion(await decryptForRead(cached), streamVersion);
|
|
740
748
|
}
|
|
741
749
|
}
|
|
742
750
|
}
|
|
@@ -748,7 +756,7 @@ export function createReadVerbs(ctx) {
|
|
|
748
756
|
// is snake_case (raw SQL); coerceRow maps it to camelCase.
|
|
749
757
|
const rowInfo = extractTableInfo(table);
|
|
750
758
|
const coerced = await decryptForRead(rehydrateCompoundTypes(coerceRow(raw, rowInfo), entity));
|
|
751
|
-
const result =
|
|
759
|
+
const result = withStreamVersion(coerced, snapshot.streamVersion);
|
|
752
760
|
if (entityCache && entityName) {
|
|
753
761
|
await entityCache.set(user.tenantId, entityName, payload.id, await encryptForStorage(result, user));
|
|
754
762
|
}
|
|
@@ -27,6 +27,7 @@ export type SubsequentEventInsertRow = {
|
|
|
27
27
|
/** INSERT … SELECT … WHERE EXISTS predecessor — stays raw (typed builder can't express this). */
|
|
28
28
|
export declare function insertSubsequentEventRow(db: AnyDb, params: SubsequentEventInsertParams): Promise<SubsequentEventInsertRow | undefined>;
|
|
29
29
|
export declare function selectStreamMaxVersion(db: AnyDb, aggregateId: string, tenantId: string): Promise<number>;
|
|
30
|
+
export declare function selectStreamMaxVersions(db: AnyDb, aggregateIds: readonly string[], tenantId: string): Promise<Map<string, number>>;
|
|
30
31
|
/** MAX(version) for one aggregate stream — no tenant filter (seed idempotency). */
|
|
31
32
|
export declare function selectAggregateMaxVersion(db: AnyDb, aggregateId: string): Promise<number>;
|
|
32
33
|
export declare function selectEventsHighWaterMark(db: AnyDb): Promise<bigint>;
|
|
@@ -171,6 +171,17 @@ export async function selectStreamMaxVersion(db, aggregateId, tenantId) {
|
|
|
171
171
|
const rows = await unsafeReadRetrying(db, `SELECT MAX("version") AS v FROM "kumiko_events" WHERE "aggregate_id" = $1 AND "tenant_id" = $2`, [aggregateId, tenantId]);
|
|
172
172
|
return rows[0]?.v ?? 0;
|
|
173
173
|
}
|
|
174
|
+
export async function selectStreamMaxVersions(db, aggregateIds, tenantId) {
|
|
175
|
+
const versions = new Map();
|
|
176
|
+
if (aggregateIds.length === 0)
|
|
177
|
+
return versions;
|
|
178
|
+
const rows = await unsafeReadRetrying(db, `SELECT "aggregate_id" AS id, MAX("version") AS v FROM "kumiko_events" WHERE "tenant_id" = $1 AND "aggregate_id" = ANY($2::uuid[]) GROUP BY "aggregate_id"`, [tenantId, [...aggregateIds]]);
|
|
179
|
+
for (const row of rows) {
|
|
180
|
+
if (row.v !== null)
|
|
181
|
+
versions.set(row.id, row.v);
|
|
182
|
+
}
|
|
183
|
+
return versions;
|
|
184
|
+
}
|
|
174
185
|
/** MAX(version) for one aggregate stream — no tenant filter (seed idempotency). */
|
|
175
186
|
export async function selectAggregateMaxVersion(db, aggregateId) {
|
|
176
187
|
const rows = await unsafeReadRetrying(db, `SELECT MAX("version") AS v FROM "kumiko_events" WHERE "aggregate_id" = $1`, [aggregateId]);
|
|
@@ -406,6 +406,38 @@ export function buildInsertSchema(entity, currencies = [...DEFAULT_CURRENCIES],
|
|
|
406
406
|
}
|
|
407
407
|
return applyTotalsMatchRefinements(entity, z.object(shape));
|
|
408
408
|
}
|
|
409
|
+
// Field types whose column is nullable and whose projection write accepts a
|
|
410
|
+
// plain `null`. Excluded: select/writeOnly text (fieldToZod is already
|
|
411
|
+
// nullable there), and types where null would not clear cleanly — NOT NULL
|
|
412
|
+
// jsonb columns (multiSelect, embedded, jsonb, multi reference), files/images
|
|
413
|
+
// (no column; stripped from updates), locatedTimestamp (split into _utc/_tz
|
|
414
|
+
// columns, a bare null targets a column that does not exist) and money (the
|
|
415
|
+
// currency column would keep its stale value).
|
|
416
|
+
const NULL_CLEARABLE_FIELD_TYPES = new Set([
|
|
417
|
+
"text",
|
|
418
|
+
"longText",
|
|
419
|
+
"boolean",
|
|
420
|
+
"number",
|
|
421
|
+
"bigInt",
|
|
422
|
+
"decimal",
|
|
423
|
+
"reference",
|
|
424
|
+
"date",
|
|
425
|
+
"timestamp",
|
|
426
|
+
"tz",
|
|
427
|
+
"file",
|
|
428
|
+
"image",
|
|
429
|
+
]);
|
|
430
|
+
function isClearableByNull(field) {
|
|
431
|
+
if (!NULL_CLEARABLE_FIELD_TYPES.has(field.type))
|
|
432
|
+
return false;
|
|
433
|
+
if (field.type === "reference" && field.multiple === true)
|
|
434
|
+
return false;
|
|
435
|
+
if (field.type === "text" && field.writeOnly === true)
|
|
436
|
+
return false;
|
|
437
|
+
const isRequired = "required" in field && field.required === true;
|
|
438
|
+
const hasDefault = "default" in field && field.default !== undefined;
|
|
439
|
+
return !isRequired && !hasDefault;
|
|
440
|
+
}
|
|
409
441
|
export function buildUpdateSchema(entity, currencies = [...DEFAULT_CURRENCIES], excludedFields = []) {
|
|
410
442
|
const shape = {};
|
|
411
443
|
for (const [name, field] of Object.entries(entity.fields)) {
|
|
@@ -421,10 +453,11 @@ export function buildUpdateSchema(entity, currencies = [...DEFAULT_CURRENCIES],
|
|
|
421
453
|
// fieldToZod still knows the default for its "" → default mapping
|
|
422
454
|
// (e.g. select) — applyDefaults: false only suppresses the schema-level
|
|
423
455
|
// `.default(...)` fallback for a genuinely omitted key.
|
|
424
|
-
|
|
456
|
+
const zodField = fieldToZod(field, currencies, {
|
|
425
457
|
applyDefaults: false,
|
|
426
458
|
isUpdate: true,
|
|
427
|
-
})
|
|
459
|
+
});
|
|
460
|
+
shape[name] = (isClearableByNull(field) ? zodField.nullable() : zodField).optional();
|
|
428
461
|
}
|
|
429
462
|
return applyTotalsMatchRefinements(entity, z.object(shape));
|
|
430
463
|
}
|
|
@@ -24,6 +24,7 @@ export declare function loadAggregateAsOf(db: DbRunner, aggregateId: string, ten
|
|
|
24
24
|
readonly includeArchived?: boolean;
|
|
25
25
|
}): Promise<readonly StoredEvent[]>;
|
|
26
26
|
export declare function getStreamVersion(db: DbRunner, aggregateId: string, tenantId: TenantId): Promise<number>;
|
|
27
|
+
export declare function getStreamVersions(db: DbRunner, aggregateIds: readonly string[], tenantId: TenantId): Promise<Map<string, number>>;
|
|
27
28
|
/** MAX(version) for one aggregate — no tenant filter. SECURITY: existence-oracle,
|
|
28
29
|
* a caller can probe whether an aggregateId has any events regardless of tenant
|
|
29
30
|
* membership. Only call from seed/system-internal paths (idempotency checks
|
|
@@ -4,7 +4,7 @@ import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
|
4
4
|
import { requestContext } from "../api/request-context.js";
|
|
5
5
|
import { encryptEventPayloadPii } from "../crypto/event-pii.js";
|
|
6
6
|
import { constraintOf, isUniqueViolation } from "../db/pg-error.js";
|
|
7
|
-
import { claimXactId, insertSubsequentEventRow, notifyPgChannel, runAtomicEventWrite, selectAggregateMaxVersion, selectEventsHighWaterMark, selectStreamMaxVersion, } from "../db/queries/event-store.js";
|
|
7
|
+
import { claimXactId, insertSubsequentEventRow, notifyPgChannel, runAtomicEventWrite, selectAggregateMaxVersion, selectEventsHighWaterMark, selectStreamMaxVersion, selectStreamMaxVersions, } from "../db/queries/event-store.js";
|
|
8
8
|
import { insertOne, selectMany } from "../db/query.js";
|
|
9
9
|
import { isStreamArchived } from "./archive.js";
|
|
10
10
|
import { IdempotentAppendConflictError, VersionConflictError } from "./errors.js";
|
|
@@ -223,6 +223,9 @@ export async function loadAggregateAsOf(db, aggregateId, tenantId, asOf, options
|
|
|
223
223
|
export async function getStreamVersion(db, aggregateId, tenantId) {
|
|
224
224
|
return selectStreamMaxVersion(db, aggregateId, tenantId);
|
|
225
225
|
}
|
|
226
|
+
export async function getStreamVersions(db, aggregateIds, tenantId) {
|
|
227
|
+
return selectStreamMaxVersions(db, aggregateIds, tenantId);
|
|
228
|
+
}
|
|
226
229
|
/** MAX(version) for one aggregate — no tenant filter. SECURITY: existence-oracle,
|
|
227
230
|
* a caller can probe whether an aggregateId has any events regardless of tenant
|
|
228
231
|
* membership. Only call from seed/system-internal paths (idempotency checks
|
|
@@ -2,7 +2,7 @@ export { APP_INSTANCE_STREAM_TYPE, APP_STARTED_EVENT_TYPE } from "../crypto/syst
|
|
|
2
2
|
export { backfillEventPiiEncryption, backfillEventPiiEncryptionBatch, type PiiBackfillBatchOptions, type PiiBackfillBatchResult, type PiiBackfillFailure, type PiiBackfillOptions, type PiiBackfillResult, type PiiBackfillScanCache, } from "../db/queries/backfill-pii.js";
|
|
3
3
|
export { type ArchiveStreamArgs, archivedStreamsTable, archiveStream, createArchivedStreamsTable, isStreamArchived, restoreStream, } from "./archive.js";
|
|
4
4
|
export { ArchivedStreamError, IdempotentAppendConflictError, VersionConflictError, } from "./errors.js";
|
|
5
|
-
export { append, EVENTS_PUBSUB_CHANNEL, type EventMetadata, type EventToAppend, getEventsHighWaterMark, getStreamVersion, getUnscopedAggregateStreamMaxVersion, getUnscopedStreamMaxVersionForSeed, LOAD_ALL_EVENTS_ROW_LIMIT, loadAggregate, loadAggregateAsOf, loadAllEventsByType, loadEventsAfterVersion, type StoredEvent, streamAllEventsByType, } from "./event-store.js";
|
|
5
|
+
export { append, EVENTS_PUBSUB_CHANNEL, type EventMetadata, type EventToAppend, getEventsHighWaterMark, getStreamVersion, getStreamVersions, getUnscopedAggregateStreamMaxVersion, getUnscopedStreamMaxVersionForSeed, LOAD_ALL_EVENTS_ROW_LIMIT, loadAggregate, loadAggregateAsOf, loadAllEventsByType, loadEventsAfterVersion, type StoredEvent, streamAllEventsByType, } from "./event-store.js";
|
|
6
6
|
export { createEventsTable, eventsTable } from "./events-schema.js";
|
|
7
7
|
export { appendProvenanceEvent, type ProvenanceEventInput } from "./provenance-append.js";
|
|
8
8
|
export { createRebuildDeadLetterTable, listRebuildDeadLetters, type RebuildDeadLetterRow, rebuildDeadLetterTable, recordRebuildDeadLetters, type SkippedApply, } from "./rebuild-dead-letter.js";
|
|
@@ -2,7 +2,7 @@ export { APP_INSTANCE_STREAM_TYPE, APP_STARTED_EVENT_TYPE } from "../crypto/syst
|
|
|
2
2
|
export { backfillEventPiiEncryption, backfillEventPiiEncryptionBatch, } from "../db/queries/backfill-pii.js";
|
|
3
3
|
export { archivedStreamsTable, archiveStream, createArchivedStreamsTable, isStreamArchived, restoreStream, } from "./archive.js";
|
|
4
4
|
export { ArchivedStreamError, IdempotentAppendConflictError, VersionConflictError, } from "./errors.js";
|
|
5
|
-
export { append, EVENTS_PUBSUB_CHANNEL, getEventsHighWaterMark, getStreamVersion, getUnscopedAggregateStreamMaxVersion, getUnscopedStreamMaxVersionForSeed, LOAD_ALL_EVENTS_ROW_LIMIT, loadAggregate, loadAggregateAsOf, loadAllEventsByType, loadEventsAfterVersion, streamAllEventsByType, } from "./event-store.js";
|
|
5
|
+
export { append, EVENTS_PUBSUB_CHANNEL, getEventsHighWaterMark, getStreamVersion, getStreamVersions, getUnscopedAggregateStreamMaxVersion, getUnscopedStreamMaxVersionForSeed, LOAD_ALL_EVENTS_ROW_LIMIT, loadAggregate, loadAggregateAsOf, loadAllEventsByType, loadEventsAfterVersion, streamAllEventsByType, } from "./event-store.js";
|
|
6
6
|
export { createEventsTable, eventsTable } from "./events-schema.js";
|
|
7
7
|
export { appendProvenanceEvent } from "./provenance-append.js";
|
|
8
8
|
export { createRebuildDeadLetterTable, listRebuildDeadLetters, rebuildDeadLetterTable, recordRebuildDeadLetters, } from "./rebuild-dead-letter.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.354.
|
|
3
|
+
"version": "0.354.1",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -210,8 +210,8 @@
|
|
|
210
210
|
"./package.json": "./package.json"
|
|
211
211
|
},
|
|
212
212
|
"dependencies": {
|
|
213
|
-
"@cosmicdrift/kumiko-http": "0.354.
|
|
214
|
-
"@cosmicdrift/kumiko-types": "0.354.
|
|
213
|
+
"@cosmicdrift/kumiko-http": "0.354.1",
|
|
214
|
+
"@cosmicdrift/kumiko-types": "0.354.1",
|
|
215
215
|
"bullmq": "^5.76.7",
|
|
216
216
|
"bun-types": "^1.3.13",
|
|
217
217
|
"hono": "^4.13.1",
|
|
@@ -227,7 +227,7 @@
|
|
|
227
227
|
"zod": "^4.4.3"
|
|
228
228
|
},
|
|
229
229
|
"devDependencies": {
|
|
230
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.354.
|
|
230
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.354.1",
|
|
231
231
|
"bun-types": "^1.3.13",
|
|
232
232
|
"pino-pretty": "^13.1.3"
|
|
233
233
|
},
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.354.1",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Executor list returns the stream version, so updates built on list rows no longer fail with version_conflict after ctx.appendEvent"
|
|
6
|
+
},
|
|
7
|
+
{
|
|
8
|
+
"version": "0.354.1",
|
|
9
|
+
"type": "fix",
|
|
10
|
+
"title": "Update schema accepts null on optional fields, so a preSave hook or client can clear a stored optional value",
|
|
11
|
+
"detail": "Update schema accepts null on optional fields, so a preSave hook or client can clear a stored optional value without presave_hook_invalid_output or a validation error"
|
|
12
|
+
},
|
|
2
13
|
{
|
|
3
14
|
"version": "0.354.0",
|
|
4
15
|
"type": "fix",
|