@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.
@@ -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 rows = await Promise.all(encryptedRows.map((r) => decryptForRead(r)));
665
- // list rows carry the READ-ROW version (display-only), never an optimistic-lock
666
- // base — edit flows reload via detail(), which reconciles the stream version.
667
- // Cache the still-encrypted form: same at-rest guarantee as detail()'s
668
- // encryptForStorage round-trip, without paying a re-encrypt.
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 withVersion(await decryptForRead(cached), streamVersion);
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 = withVersion(coerced, snapshot.streamVersion);
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
- shape[name] = fieldToZod(field, currencies, {
456
+ const zodField = fieldToZod(field, currencies, {
425
457
  applyDefaults: false,
426
458
  isUpdate: true,
427
- }).optional();
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.0",
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.0",
214
- "@cosmicdrift/kumiko-types": "0.354.0",
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.0",
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",