@jini-ai/cms 0.4.1 → 0.5.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/content-types/lifecycle.d.ts +1 -1
  3. package/dist/content-types/lifecycle.js +6 -2
  4. package/dist/content-types/types.d.ts +11 -0
  5. package/dist/content-types/write-service.d.ts +6 -0
  6. package/dist/content-types/write-service.js +7 -0
  7. package/dist/entries/errors.d.ts +7 -0
  8. package/dist/entries/errors.js +6 -0
  9. package/dist/entries/field-validation.d.ts +3 -0
  10. package/dist/entries/index.d.ts +2 -2
  11. package/dist/entries/index.js +1 -1
  12. package/dist/entries/repo.memory.d.ts +2 -2
  13. package/dist/entries/repo.memory.js +10 -1
  14. package/dist/entries/types.d.ts +2 -0
  15. package/dist/entries/write-service.d.ts +19 -8
  16. package/dist/entries/write-service.js +88 -54
  17. package/dist/media/blob-store.fs.d.ts +4 -0
  18. package/dist/media/blob-store.fs.js +27 -0
  19. package/dist/media/blob-store.memory.d.ts +3 -0
  20. package/dist/media/blob-store.memory.js +3 -0
  21. package/dist/media/created-by.d.ts +15 -0
  22. package/dist/media/created-by.js +20 -0
  23. package/dist/media/index.d.ts +1 -0
  24. package/dist/media/index.js +1 -0
  25. package/dist/media/media-service.d.ts +1 -1
  26. package/dist/media/media-service.js +1 -0
  27. package/dist/media/ports.d.ts +12 -2
  28. package/dist/media/repo.memory.js +2 -1
  29. package/dist/media/tool-registrations.js +1 -0
  30. package/dist/media/types.d.ts +4 -0
  31. package/dist/navigation/index.d.ts +2 -2
  32. package/dist/navigation/index.js +2 -2
  33. package/dist/navigation/menu-service.d.ts +28 -16
  34. package/dist/navigation/menu-service.js +90 -65
  35. package/dist/navigation/repo.memory.d.ts +46 -2
  36. package/dist/navigation/repo.memory.js +28 -1
  37. package/dist/navigation/types.d.ts +5 -0
  38. package/dist/taxonomy/write-service.d.ts +4 -2
  39. package/dist/taxonomy/write-service.js +8 -3
  40. package/dist/trash/write-service.js +19 -12
  41. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0 — 2026-10-05
4
+
5
+ ### BREAKING
6
+
7
+ - Entries: an entry's envelope owner comes from its content type, not the caller. `createEntry`/`updateEntry`/`importEntry` drop `input.owner` (a stray runtime value is ignored) and validate under `contentType.owner`; `ContentTypeRecord`/`OwningContentType` gain an optional `owner` (absent = `"site"`, so existing types are byte-identical) and `registerContentType` accepts one. Callers that passed `owner` register the type with its owner instead.
8
+ - Entries, BREAKING for `EntryRepoPort` adapters: `save` takes `{ expectedVersion }` and must compare it atomically with the write inside the transaction (`VersionConflictError` thrown there becomes the Result's `{ ok: false }`). `expectedVersion: null` means "the id must be free" (live, trashed or another workspace); an import-as-create passes it, so two concurrent creates of one id no longer both land. New exports `EntrySaveOptions`, `entryVersionConflictError`; `InMemoryEntryRepo` implements both.
9
+ - Navigation, BREAKING for `MenuRepoPort` adapters: `save` takes `{ expectedVersion }` and compares it atomically with the write; `updateMenuTree` and both `assignLocation` saves pass it. New exports `MenuSaveOptions`, `menuVersionConflictError`; `InMemoryMenuRepo` implements it (a trashed or missing row is "found none").
10
+
11
+ ### Changed
12
+
13
+ - Content types: tombstoning tears down a type's indexes after the status save commits, not before, so a failed save no longer leaves a still-deprecated type with its indexes gone.
14
+ - Entries: `updateEntry`, `publishEntry`/`unpublishEntry` and an import-as-update again refuse an `expectedVersion` that differs from the version they read (`VersionConflictError`), in addition to the save's compare-and-set. The compare-and-set alone let a record built from an older read overwrite a writer that landed the claimed version after that read.
15
+ - Navigation: `updateMenuTree` restores the same read check. New `MenuVersionConflictError` (a `MenuConflictError` subclass) is what `menuVersionConflictError` and that check throw, so a host can tell a lost version check from a slug conflict.
16
+ - Navigation, BREAKING for `MenuRepoPort` adapters: the port gains `transaction({ fn })`, and `assignLocation` runs both menu saves, the binding upsert and its outbox events inside it, so a conflict on the second save no longer leaves the displaced menu changed. `InMemoryMenuRepo.transaction` just runs `fn` (no rollback).
17
+ - Navigation: `MenuSaveOptions.expectedVersion` accepts `null` — insert only when no row (live, trashed, any workspace) holds the id — for a host's id-preserving import-as-create; the loser gets `menu '<id>' already exists (expected no menu, found version <n>)`.
18
+
19
+ ## 0.4.2 — 2026-10-04
20
+
21
+ - Media: optional `BlobStorePort.sizeOf` reports original blob bytes (filesystem store memoizes `stat`); `MediaRecord.createdBy` is stamped at creation and write-once.
22
+ - Navigation: entry targets carry optional `lastKnownHref` and `entryType` hints.
23
+ - Taxonomy: `unassignTerms` returns the removed ids. Trash: a no-op trash/restore does not notify again.
24
+ - Internal `@jini-ai/*` dependencies are caret ranges (`workspace:^`) instead of exact pins, so a host on a newer patch resolves a single copy without an override.
25
+
3
26
  ## Unreleased — Trash host adoption
4
27
 
5
28
  - Preserve `adapter-unavailable` for absent domains before checking entity policy; selected purge
@@ -95,7 +95,7 @@ export interface TombstoneContentTypeRequired {
95
95
  * — the one-way terminal transition into `tombstone`. Must be entered from
96
96
  * `deprecated` (any other status, including an already-tombstoned row, is rejected with the same
97
97
  * "must deprecate first" guard — collapse to one check). Tears down every queryable-
98
- * field index the type ever provisioned BEFORE persisting the status flip, and enqueues
98
+ * field index the type ever provisioned AFTER persisting the status flip, and enqueues
99
99
  * `content_type.tombstoned` in the same call.
100
100
  *
101
101
  * @complexity O(1) plus one repo read, one index-teardown call, one same-tx write pair, and one
@@ -99,7 +99,7 @@ export async function reactivateContentType(required) {
99
99
  * — the one-way terminal transition into `tombstone`. Must be entered from
100
100
  * `deprecated` (any other status, including an already-tombstoned row, is rejected with the same
101
101
  * "must deprecate first" guard — collapse to one check). Tears down every queryable-
102
- * field index the type ever provisioned BEFORE persisting the status flip, and enqueues
102
+ * field index the type ever provisioned AFTER persisting the status flip, and enqueues
103
103
  * `content_type.tombstoned` in the same call.
104
104
  *
105
105
  * @complexity O(1) plus one repo read, one index-teardown call, one same-tx write pair, and one
@@ -129,7 +129,6 @@ export async function tombstoneContentType(required) {
129
129
  }
130
130
  const now = kernelNowIso({ clock: deps.clock });
131
131
  const updated = { ...current, status: "tombstone", tombstonedAt: now, version: current.version + 1 };
132
- await deps.indexProvisioner.tearDownAllIndexesForContentType({ workspaceId: input.workspaceId, contentTypeKey: input.key });
133
132
  await deps.repo.transaction({ fn: async () => {
134
133
  await deps.repo.save(updated);
135
134
  await deps.repo.appendRevision({
@@ -144,6 +143,11 @@ export async function tombstoneContentType(required) {
144
143
  recordedAt: now,
145
144
  });
146
145
  } });
146
+ // AFTER the save commits, never before (wm S5): teardown is not part of the repo transaction, so
147
+ // tearing down first meant a save that threw left a still-'deprecated' type — whose entries stay
148
+ // fully readable/writable — with every queryable index already gone. In this order a failed save
149
+ // touches nothing, and a failed teardown leaves only unused indexes on an already-terminal type.
150
+ await deps.indexProvisioner.tearDownAllIndexesForContentType({ workspaceId: input.workspaceId, contentTypeKey: input.key });
147
151
  await deps.outbox.enqueue({ name: "content_type.tombstoned", payload: { workspaceId: input.workspaceId, key: input.key } });
148
152
  return { ok: true, value: { contentType: updated } };
149
153
  }
@@ -116,6 +116,17 @@ export interface ContentTypeRecord {
116
116
  status: ContentTypeStatus;
117
117
  version: number;
118
118
  tombstonedAt?: string | null;
119
+ /**
120
+ * The `fieldsJson.ext.<owner>` namespace every entry of this type is stored under. Absent means
121
+ * `"site"`, so every type registered before this property existed keeps its envelope unchanged.
122
+ * It belongs to the TYPE, not to whoever writes an entry: `entries/write-service.ts` reads it off
123
+ * the owning type, so no caller can validate a payload under the wrong namespace and mint a row
124
+ * the type's real reader can never parse (found 2026-09-19: the generic entries route passed no
125
+ * owner and wrote `ext.site` rows into a widget-owned type). Set only by code that registers its
126
+ * own type (`registerContentType`'s optional `owner`); the agent tool and HTTP surfaces never
127
+ * pass it.
128
+ */
129
+ owner?: string;
119
130
  }
120
131
  /**
121
132
  * Actor classes a chokepoint write can be attributed to. Structurally identical to
@@ -117,6 +117,12 @@ export interface RegisterContentTypeRequired {
117
117
  key: string;
118
118
  label: string;
119
119
  fields: ContentTypeFieldDef[];
120
+ /**
121
+ * The `fieldsJson.ext.<owner>` namespace this type's entries are stored under; omit for the
122
+ * default `"site"`. For code that registers its OWN type (e.g. a host's widgets feature); the
123
+ * agent tool and HTTP surfaces deliberately never pass it. See `ContentTypeRecord.owner`.
124
+ */
125
+ owner?: string | undefined;
120
126
  };
121
127
  }
122
128
  /**
@@ -57,6 +57,11 @@ export async function registerContentType(required) {
57
57
  if (!validateIdentifierGrammar({ value: input.key })) {
58
58
  return { ok: false, error: new InvalidKeyGrammarError({ message: `content-type key '${input.key}' fails the identifier grammar gate ^[a-z][a-z0-9_]{0,63}$` }) };
59
59
  }
60
+ // Guard 1b: owner grammar, only when one is given. It becomes an object key inside every entry's
61
+ // `fieldsJson.ext`, so it gets the same identifier gate as the type key.
62
+ if (input.owner !== undefined && !validateIdentifierGrammar({ value: input.owner })) {
63
+ return { ok: false, error: new InvalidKeyGrammarError({ message: `content-type owner '${input.owner}' fails the identifier grammar gate ^[a-z][a-z0-9_]{0,63}$` }) };
64
+ }
60
65
  // Guard 2: reserved key.
61
66
  if (RESERVED_CONTENT_TYPE_KEYS.has(input.key)) {
62
67
  return { ok: false, error: new ReservedContentTypeKeyError({ message: `key '${input.key}' is permanently reserved for the legacy 'posts' table` }) };
@@ -102,6 +107,8 @@ export async function registerContentType(required) {
102
107
  status: "active",
103
108
  version: 1,
104
109
  tombstonedAt: null,
110
+ // Only set when given, so a type registered without one stays byte-identical to before `owner` existed.
111
+ ...(input.owner !== undefined ? { owner: input.owner } : {}),
105
112
  };
106
113
  try {
107
114
  await deps.repo.transaction({ fn: async () => {
@@ -41,6 +41,13 @@ export declare class VersionConflictError extends Error {
41
41
  message: string;
42
42
  }, optionalArgs?: Record<string, never>);
43
43
  }
44
+ /** The compare-and-set loss every `EntryRepoPort.save` adapter throws (one wording for all of them).
45
+ * `expectedVersion: null` is a create that found the id taken. */
46
+ export declare function entryVersionConflictError(required: {
47
+ id: string;
48
+ expectedVersion: number | null;
49
+ found: number | null;
50
+ }): VersionConflictError;
44
51
  /** — `fieldsJson` failed `validateFieldsAgainstSchema` against the owning type's current schema. See docs/decisions/DR-002-content-lifecycle-and-cleanup.md. */
45
52
  export declare class EntryFieldValidationError extends Error {
46
53
  readonly fieldErrors: Array<{
@@ -52,6 +52,12 @@ export class VersionConflictError extends Error {
52
52
  this.name = "VersionConflictError";
53
53
  }
54
54
  }
55
+ /** The compare-and-set loss every `EntryRepoPort.save` adapter throws (one wording for all of them).
56
+ * `expectedVersion: null` is a create that found the id taken. */
57
+ export function entryVersionConflictError(required) {
58
+ const expected = required.expectedVersion === null ? "no entry" : `version ${required.expectedVersion} for entry`;
59
+ return new VersionConflictError({ message: `expected ${expected} '${required.id}', found ${required.found ?? "none"}` });
60
+ }
55
61
  /** — `fieldsJson` failed `validateFieldsAgainstSchema` against the owning type's current schema. See docs/decisions/DR-002-content-lifecycle-and-cleanup.md. */
56
62
  export class EntryFieldValidationError extends Error {
57
63
  fieldErrors;
@@ -21,6 +21,9 @@ import type { ContentTypeFieldDef } from "../content-types/types.js";
21
21
  * documented `fields.ext.{owner}.*` promise (first surfaced by a widgets feature, which needs
22
22
  * `ext.widget`/`ext.widgets`, not `ext.site`, for data that structurally belongs to a different
23
23
  * feature).
24
+ * Since 2026-10-04 `write-service.ts` passes the OWNING TYPE's `owner` here, never a caller's: the
25
+ * caller-chosen version let the generic entries route (which passed none) validate a widget
26
+ * payload under `ext.site` and write a row the widgets reader can never parse.
24
27
  * `selectVisibleEntryFields` is intentionally left untouched — it has no production caller anywhere
25
28
  * in this codebase today, so widening it now would be speculative; extend it the same way once a
26
29
  * real caller needs a non-`site` read projection.
@@ -25,10 +25,10 @@ export { validateFieldsAgainstSchema, selectVisibleEntryFields } from "./field-v
25
25
  * separation is inherited from the source this was ported from; a consumer catching across the
26
26
  * boundary must catch the one it actually called into.
27
27
  */
28
- export { ForbiddenError, EntryNotFoundError, ContentTypeNotFoundError, ContentTypeNotActiveError, EntrySlugConflictError, VersionConflictError, EntryFieldValidationError, } from "./errors.js";
28
+ export { ForbiddenError, EntryNotFoundError, ContentTypeNotFoundError, ContentTypeNotActiveError, EntrySlugConflictError, VersionConflictError, EntryFieldValidationError, entryVersionConflictError, } from "./errors.js";
29
29
  export type { EntryLifecycleOp, EntryLifecycleHandler } from "./lifecycle-dispatch.js";
30
30
  export { ENTRY_LIFECYCLE_OP_NAMES, ENTRY_LIFECYCLE_OPS, parseEntryLifecycleOp, } from "./lifecycle-dispatch.js";
31
- export type { AuthorizeFn, EntryRevisionInput, EntryRepoPort, ContentTypeLookupPort, OutboxPort, WatermarkPort, CreateEntryRequired, UpdateEntryRequired, PublishUnpublishEntryRequired, ImportEntryRequired, } from "./write-service.js";
31
+ export type { AuthorizeFn, EntryRevisionInput, EntryRepoPort, EntrySaveOptions, ContentTypeLookupPort, OutboxPort, WatermarkPort, CreateEntryRequired, UpdateEntryRequired, PublishUnpublishEntryRequired, ImportEntryRequired, } from "./write-service.js";
32
32
  export { createEntry, updateEntry, publishEntry, unpublishEntry, importEntry } from "./write-service.js";
33
33
  /**
34
34
  * The in-memory repository. Exported for the same reason `navigation` exports its own: a host's
@@ -10,7 +10,7 @@ export { validateFieldsAgainstSchema, selectVisibleEntryFields } from "./field-v
10
10
  * separation is inherited from the source this was ported from; a consumer catching across the
11
11
  * boundary must catch the one it actually called into.
12
12
  */
13
- export { ForbiddenError, EntryNotFoundError, ContentTypeNotFoundError, ContentTypeNotActiveError, EntrySlugConflictError, VersionConflictError, EntryFieldValidationError, } from "./errors.js";
13
+ export { ForbiddenError, EntryNotFoundError, ContentTypeNotFoundError, ContentTypeNotActiveError, EntrySlugConflictError, VersionConflictError, EntryFieldValidationError, entryVersionConflictError, } from "./errors.js";
14
14
  export { ENTRY_LIFECYCLE_OP_NAMES, ENTRY_LIFECYCLE_OPS, parseEntryLifecycleOp, } from "./lifecycle-dispatch.js";
15
15
  export { createEntry, updateEntry, publishEntry, unpublishEntry, importEntry } from "./write-service.js";
16
16
  /**
@@ -1,5 +1,5 @@
1
1
  import type { Clock } from "@jini-ai/core/primitives";
2
- import type { EntryRepoPort, EntryRevisionInput, OutboxPort } from "./write-service.js";
2
+ import type { EntryRepoPort, EntryRevisionInput, EntrySaveOptions, OutboxPort } from "./write-service.js";
3
3
  import type { EntryListPort } from "./list.js";
4
4
  import type { EntryRecord, EntryStatus } from "./types.js";
5
5
  /**
@@ -20,7 +20,7 @@ export declare class InMemoryEntryRepo implements EntryRepoPort, EntryListPort {
20
20
  workspaceId: string;
21
21
  id: string;
22
22
  }): Promise<EntryRecord | null>;
23
- save(row: EntryRecord): Promise<void>;
23
+ save(row: EntryRecord, options?: EntrySaveOptions): Promise<void>;
24
24
  appendRevision(revision: EntryRevisionInput): Promise<void>;
25
25
  transaction<T>({ fn }: {
26
26
  fn: () => Promise<T>;
@@ -1,4 +1,5 @@
1
1
  import { nowIso as kernelNowIso } from "@jini-ai/core/primitives";
2
+ import { entryVersionConflictError } from "./errors.js";
2
3
  /**
3
4
  * @file In-memory `EntryRepoPort` + `EntryListPort` double (same disclosed rule-of-two-until-a-
4
5
  * real-adapter-exists precedent as `features/content-types/repo.memory.ts` — see that file's
@@ -22,7 +23,15 @@ export class InMemoryEntryRepo {
22
23
  const row = this.byId.get(params.id);
23
24
  return row && row.workspaceId === params.workspaceId ? { ...row } : null;
24
25
  }
25
- async save(row) {
26
+ async save(row, options = {}) {
27
+ if (options.expectedVersion !== undefined) {
28
+ const stored = this.byId.get(row.id);
29
+ // A create (`null`) needs the id free in every workspace: the id is the key here, as the
30
+ // primary key is in a SQL adapter, so a foreign row with it would be overwritten.
31
+ const found = stored && (options.expectedVersion === null || stored.workspaceId === row.workspaceId) ? stored.version : null;
32
+ if (found !== options.expectedVersion)
33
+ throw entryVersionConflictError({ id: row.id, expectedVersion: options.expectedVersion, found });
34
+ }
26
35
  this.byId.set(row.id, { ...row });
27
36
  }
28
37
  async appendRevision(revision) {
@@ -38,6 +38,8 @@ export interface OwningContentType {
38
38
  key: string;
39
39
  status: ContentTypeStatus;
40
40
  fields: ContentTypeFieldDef[];
41
+ /** The `fieldsJson.ext.<owner>` namespace this type's entries live under; absent = `"site"`. See `content-types/types.ts`'s `ContentTypeRecord.owner`. */
42
+ owner?: string | undefined;
41
43
  }
42
44
  export interface ActorIdentityInput {
43
45
  actorId: string;
@@ -51,6 +51,12 @@ export interface EntryRevisionInput {
51
51
  delegatedById: string | null;
52
52
  recordedAt: string;
53
53
  }
54
+ /** Options for {@link EntryRepoPort.save}. */
55
+ export interface EntrySaveOptions {
56
+ /** The version the caller read; the save throws `VersionConflictError` unless the stored live row still holds it.
57
+ * `null` is a create: the save throws unless no row (live, trashed, or in another workspace) holds the id. */
58
+ expectedVersion?: number | null | undefined;
59
+ }
54
60
  export interface EntryRepoPort {
55
61
  findBySlug(params: {
56
62
  workspaceId: string;
@@ -61,7 +67,16 @@ export interface EntryRepoPort {
61
67
  workspaceId: string;
62
68
  id: string;
63
69
  }): Promise<EntryRecord | null>;
64
- save(row: EntryRecord): Promise<void>;
70
+ /**
71
+ * Writes `row` by id. With `expectedVersion`, the write is a compare-and-set: it lands only when the
72
+ * stored live (not trashed) row still holds that version, checked atomically with the write (one
73
+ * conditional UPDATE in a SQL adapter), so two writers that read the same version cannot both land.
74
+ * With `expectedVersion: null` it is insert-if-absent (an import-as-create; `INSERT ... ON CONFLICT DO
75
+ * NOTHING` in a SQL adapter), so two creates of one id cannot both land either.
76
+ * Without it, the write is unconditional (`createEntry`'s freshly minted id, trash seams).
77
+ * @throws VersionConflictError ``expected version <n> for entry '<id>', found <stored|none>`` ({@link entryVersionConflictError}).
78
+ */
79
+ save(row: EntryRecord, options?: EntrySaveOptions): Promise<void>;
65
80
  appendRevision(revision: EntryRevisionInput): Promise<void>;
66
81
  transaction<T>(required: {
67
82
  fn: () => Promise<T>;
@@ -111,8 +126,6 @@ export interface CreateEntryRequired {
111
126
  title: string;
112
127
  fieldsJson: unknown;
113
128
  bodyJson?: unknown;
114
- /** The `ext` sub-key `fieldsJson` is namespaced under. Defaults to `"site"` — see `field-validation.ts`'s `validateFieldsAgainstSchema`. */
115
- owner?: string | undefined;
116
129
  };
117
130
  }
118
131
  /**
@@ -164,8 +177,6 @@ export interface UpdateEntryRequired {
164
177
  */
165
178
  bodyJson?: unknown;
166
179
  expectedVersion: number;
167
- /** The `ext` sub-key `fieldsJson` is namespaced under. Defaults to `"site"` — see `field-validation.ts`'s `validateFieldsAgainstSchema`. */
168
- owner?: string | undefined;
169
180
  };
170
181
  }
171
182
  /**
@@ -211,11 +222,11 @@ export interface ImportEntryRequired {
211
222
  * The publish factory's three-way CAS contract: `undefined` means "no row with this id may
212
223
  * already exist" (an import-as-create); a number means "a row with this id must exist and be
213
224
  * at exactly this version" (an import-as-update). Both cases end in `VersionConflictError` on
214
- * mismatch, never a silent create-over-existing or a silent no-op.
225
+ * mismatch, never a silent create-over-existing or a silent no-op. Both are also enforced by the
226
+ * save itself (`undefined` becomes the save's `expectedVersion: null`), because the `findById`
227
+ * check below let two concurrent creates of one id both pass and the second overwrite the first.
215
228
  */
216
229
  expectedVersion: number | undefined;
217
- /** The `ext` sub-key `fieldsJson` is namespaced under. Defaults to `"site"` — see `field-validation.ts`'s `validateFieldsAgainstSchema`. */
218
- owner?: string | undefined;
219
230
  };
220
231
  }
221
232
  /**
@@ -1,5 +1,5 @@
1
1
  import { nowIso as kernelNowIso } from "@jini-ai/core/primitives";
2
- import { ContentTypeNotActiveError, ContentTypeNotFoundError, EntryFieldValidationError, EntryNotFoundError, EntrySlugConflictError, ForbiddenError, VersionConflictError, } from "./errors.js";
2
+ import { ContentTypeNotActiveError, ContentTypeNotFoundError, EntryFieldValidationError, EntryNotFoundError, EntrySlugConflictError, entryVersionConflictError, ForbiddenError, VersionConflictError, } from "./errors.js";
3
3
  import { validateFieldsAgainstSchema } from "./field-validation.js";
4
4
  function delegationFields(input) {
5
5
  return {
@@ -33,7 +33,11 @@ export async function createEntry(required) {
33
33
  // See docs/decisions/DR-002-content-lifecycle-and-cleanup.md for the invariant behind this refusal; keep external identifiers out of caller-facing messages.
34
34
  return { ok: false, error: new ContentTypeNotActiveError({ message: `content type '${input.type}' is not active; new entries cannot be created` }) };
35
35
  }
36
- const validation = validateFieldsAgainstSchema({ schema: contentType.fields, fieldsJson: input.fieldsJson }, { owner: input.owner });
36
+ // The envelope namespace comes from the owning TYPE, never the caller (2026-10-04). It used to be
37
+ // `input.owner` defaulting to "site": the generic entries route passed none, so a widget-owned
38
+ // type's payload validated under `ext.site` and wrote a row the widgets reader could never parse.
39
+ // The same rule holds in `updateEntry` and `importEntry` below.
40
+ const validation = validateFieldsAgainstSchema({ schema: contentType.fields, fieldsJson: input.fieldsJson }, { owner: contentType.owner });
37
41
  if (!validation.valid) {
38
42
  return { ok: false, error: new EntryFieldValidationError({ fieldErrors: validation.fieldErrors }) };
39
43
  }
@@ -78,8 +82,12 @@ export async function createEntry(required) {
78
82
  /**
79
83
  * shared resolve step for `updateEntry`/`publishEntry`/`unpublishEntry`: authorize ->
80
84
  * find the entry -> find its owning type -> reject ONLY if that type is `tombstone`
81
- * (`deprecated` blocks nothing here, unlike `createEntry`'s rule) -> `expectedVersion`
82
- * check.
85
+ * (`deprecated` blocks nothing here, unlike `createEntry`'s rule) -> `expectedVersion` must equal the
86
+ * version read. That check alone is not enough: the save's compare-and-set inside the write's
87
+ * transaction ({@link runVersionedWrite}) also checks it, because a check on this read let two writers
88
+ * that read the same version both land (wm S3). Nor is the compare-and-set alone: the caller's record
89
+ * is built from THIS read, so a basis that differs from it could match a version another writer lands
90
+ * after the read and overwrite that writer with a record built from the older row.
83
91
  *
84
92
  * @complexity O(1) plus one entry read and one content-type read.
85
93
  * @overallScore 100
@@ -100,10 +108,27 @@ async function resolveExistingEntryForTransition(deps, input) {
100
108
  return { ok: false, error: new ContentTypeNotActiveError({ message: `content type '${entry.type}' is tombstoned; existing entries cannot be updated/published/unpublished` }) };
101
109
  }
102
110
  if (input.expectedVersion !== entry.version) {
103
- return { ok: false, error: new VersionConflictError({ message: `expected version ${input.expectedVersion} for entry '${input.id}', found ${entry.version}` }) };
111
+ return { ok: false, error: entryVersionConflictError({ id: input.id, expectedVersion: input.expectedVersion, found: entry.version }) };
104
112
  }
105
113
  return { ok: true, value: { entry, contentType } };
106
114
  }
115
+ /**
116
+ * Runs a versioned write's transaction; a compare-and-set loss thrown by `save` inside it becomes
117
+ * the returned error (the callers return it as `{ ok: false }`, so HTTP routes keep their Result
118
+ * path). The transaction rolls the whole write back, so a loss leaves no revision or watermark.
119
+ * @throws whatever else the transaction throws.
120
+ */
121
+ async function runVersionedWrite(entryRepo, fn) {
122
+ try {
123
+ await entryRepo.transaction({ fn });
124
+ return null;
125
+ }
126
+ catch (error) {
127
+ if (error instanceof VersionConflictError)
128
+ return error;
129
+ throw error;
130
+ }
131
+ }
107
132
  /**
108
133
  * — updates an existing entry's `title`/`fieldsJson`/`bodyJson` (only the fields supplied
109
134
  * are changed). Rejected `ContentTypeNotActiveError` only if the owning type is `tombstone`
@@ -122,7 +147,7 @@ export async function updateEntry(required) {
122
147
  const { entry: current, contentType } = resolved.value;
123
148
  let fieldsJson = current.fieldsJson;
124
149
  if (input.fieldsJson !== undefined) {
125
- const validation = validateFieldsAgainstSchema({ schema: contentType?.fields ?? [], fieldsJson: input.fieldsJson }, { owner: input.owner });
150
+ const validation = validateFieldsAgainstSchema({ schema: contentType?.fields ?? [], fieldsJson: input.fieldsJson }, { owner: contentType?.owner });
126
151
  if (!validation.valid) {
127
152
  return { ok: false, error: new EntryFieldValidationError({ fieldErrors: validation.fieldErrors }) };
128
153
  }
@@ -137,22 +162,24 @@ export async function updateEntry(required) {
137
162
  updatedAt: now,
138
163
  version: current.version + 1,
139
164
  };
140
- await deps.entryRepo.transaction({ fn: async () => {
141
- await deps.entryRepo.save(updated);
142
- await deps.entryRepo.appendRevision({
143
- entryId: current.id,
144
- workspaceId: input.workspaceId,
145
- op: "update",
146
- stateJson: updated,
147
- actorId: input.actorId,
148
- ...delegationFields(input),
149
- recordedAt: now,
150
- });
151
- if (deps.watermark)
152
- await deps.watermark.stampWatermark({ workspaceId: input.workspaceId });
153
- if (deps.onWritten)
154
- await deps.onWritten(updated);
155
- } });
165
+ const conflict = await runVersionedWrite(deps.entryRepo, async () => {
166
+ await deps.entryRepo.save(updated, { expectedVersion: input.expectedVersion });
167
+ await deps.entryRepo.appendRevision({
168
+ entryId: current.id,
169
+ workspaceId: input.workspaceId,
170
+ op: "update",
171
+ stateJson: updated,
172
+ actorId: input.actorId,
173
+ ...delegationFields(input),
174
+ recordedAt: now,
175
+ });
176
+ if (deps.watermark)
177
+ await deps.watermark.stampWatermark({ workspaceId: input.workspaceId });
178
+ if (deps.onWritten)
179
+ await deps.onWritten(updated);
180
+ });
181
+ if (conflict)
182
+ return { ok: false, error: conflict };
156
183
  await deps.outbox.enqueue({ name: "entry.updated", payload: { workspaceId: input.workspaceId, entryId: current.id } });
157
184
  return { ok: true, value: { entry: updated } };
158
185
  }
@@ -187,12 +214,13 @@ export async function importEntry(required) {
187
214
  if (contentType.status === "tombstone") {
188
215
  return { ok: false, error: new ContentTypeNotActiveError({ message: `content type '${input.type}' is tombstoned; entries cannot be imported into it` }) };
189
216
  }
190
- const validation = validateFieldsAgainstSchema({ schema: contentType.fields, fieldsJson: input.fieldsJson }, { owner: input.owner });
217
+ const validation = validateFieldsAgainstSchema({ schema: contentType.fields, fieldsJson: input.fieldsJson }, { owner: contentType.owner });
191
218
  if (!validation.valid) {
192
219
  return { ok: false, error: new EntryFieldValidationError({ fieldErrors: validation.fieldErrors }) };
193
220
  }
194
221
  const existing = await deps.entryRepo.findById({ workspaceId: input.workspaceId, id: input.id });
195
222
  if (input.expectedVersion === undefined) {
223
+ // A create that lands between this read and the save is the save's insert-if-absent below.
196
224
  if (existing) {
197
225
  return { ok: false, error: new VersionConflictError({ message: `entry '${input.id}' already exists in workspace '${input.workspaceId}', but no expectedVersion was supplied for import` }) };
198
226
  }
@@ -201,8 +229,10 @@ export async function importEntry(required) {
201
229
  if (!existing) {
202
230
  return { ok: false, error: new VersionConflictError({ message: `expected version ${input.expectedVersion} for entry '${input.id}', but no such entry exists` }) };
203
231
  }
232
+ // Checked on this read AND by the save's compare-and-set below (wm S3): the record is built from
233
+ // this read, so a basis that differs from it must not reach a save that could match it.
204
234
  if (existing.version !== input.expectedVersion) {
205
- return { ok: false, error: new VersionConflictError({ message: `expected version ${input.expectedVersion} for entry '${input.id}', found ${existing.version}` }) };
235
+ return { ok: false, error: entryVersionConflictError({ id: input.id, expectedVersion: input.expectedVersion, found: existing.version }) };
206
236
  }
207
237
  // Same id, different type means this is not the row the caller's plan was made against; a
208
238
  // silent type move would also dodge the tombstone check on the entry's REAL type. See docs/decisions/DR-002-content-lifecycle-and-cleanup.md.
@@ -225,22 +255,24 @@ export async function importEntry(required) {
225
255
  updatedAt: now,
226
256
  version: existing ? existing.version + 1 : 1,
227
257
  };
228
- await deps.entryRepo.transaction({ fn: async () => {
229
- await deps.entryRepo.save(entry);
230
- await deps.entryRepo.appendRevision({
231
- entryId: entry.id,
232
- workspaceId: input.workspaceId,
233
- op: existing ? "update" : "create",
234
- stateJson: entry,
235
- actorId: input.actorId,
236
- ...delegationFields(input),
237
- recordedAt: now,
238
- });
239
- if (deps.watermark)
240
- await deps.watermark.stampWatermark({ workspaceId: input.workspaceId });
241
- if (deps.onWritten)
242
- await deps.onWritten(entry);
243
- } });
258
+ const conflict = await runVersionedWrite(deps.entryRepo, async () => {
259
+ await deps.entryRepo.save(entry, { expectedVersion: input.expectedVersion ?? null });
260
+ await deps.entryRepo.appendRevision({
261
+ entryId: entry.id,
262
+ workspaceId: input.workspaceId,
263
+ op: existing ? "update" : "create",
264
+ stateJson: entry,
265
+ actorId: input.actorId,
266
+ ...delegationFields(input),
267
+ recordedAt: now,
268
+ });
269
+ if (deps.watermark)
270
+ await deps.watermark.stampWatermark({ workspaceId: input.workspaceId });
271
+ if (deps.onWritten)
272
+ await deps.onWritten(entry);
273
+ });
274
+ if (conflict)
275
+ return { ok: false, error: conflict };
244
276
  await deps.outbox.enqueue({ name: "entry.imported", payload: { workspaceId: input.workspaceId, entryId: entry.id, type: input.type, slug: input.slug } });
245
277
  return { ok: true, value: { entry } };
246
278
  }
@@ -258,20 +290,22 @@ async function transitionEntryStatus(required, target) {
258
290
  updatedAt: now,
259
291
  version: current.version + 1,
260
292
  };
261
- await deps.entryRepo.transaction({ fn: async () => {
262
- await deps.entryRepo.save(updated);
263
- await deps.entryRepo.appendRevision({
264
- entryId: current.id,
265
- workspaceId: input.workspaceId,
266
- op: target.op,
267
- stateJson: updated,
268
- actorId: input.actorId,
269
- ...delegationFields(input),
270
- recordedAt: now,
271
- });
272
- if (deps.watermark)
273
- await deps.watermark.stampWatermark({ workspaceId: input.workspaceId });
274
- } });
293
+ const conflict = await runVersionedWrite(deps.entryRepo, async () => {
294
+ await deps.entryRepo.save(updated, { expectedVersion: input.expectedVersion });
295
+ await deps.entryRepo.appendRevision({
296
+ entryId: current.id,
297
+ workspaceId: input.workspaceId,
298
+ op: target.op,
299
+ stateJson: updated,
300
+ actorId: input.actorId,
301
+ ...delegationFields(input),
302
+ recordedAt: now,
303
+ });
304
+ if (deps.watermark)
305
+ await deps.watermark.stampWatermark({ workspaceId: input.workspaceId });
306
+ });
307
+ if (conflict)
308
+ return { ok: false, error: conflict };
275
309
  await deps.outbox.enqueue({ name: target.eventName, payload: { workspaceId: input.workspaceId, entryId: current.id } });
276
310
  return { ok: true, value: { entry: updated } };
277
311
  }
@@ -5,6 +5,7 @@ export interface LocalFsBlobStoreDeps {
5
5
  }
6
6
  export declare class LocalFsBlobStore implements BlobStorePort {
7
7
  private readonly rootDir;
8
+ private readonly sizesByKey;
8
9
  constructor(deps: LocalFsBlobStoreDeps);
9
10
  private resolvePath;
10
11
  put(input: PutBlobInput): Promise<{
@@ -28,6 +29,9 @@ export declare class LocalFsBlobStore implements BlobStorePort {
28
29
  get(input: {
29
30
  storageKey: string;
30
31
  }): Promise<Uint8Array>;
32
+ sizeOf({ storageKey }: {
33
+ storageKey: string;
34
+ }, _optional?: Record<string, never>): Promise<number | null>;
31
35
  /**
32
36
  * `ENOENT` (the path genuinely does not exist) resolves to `false`. Any other `stat()` failure
33
37
  * (`EACCES` permission denied, `ENOTDIR`, `EIO`, ...) is rethrown raw — same "catch the one
@@ -16,6 +16,9 @@ import { dirname, join } from "node:path";
16
16
  import { computeBlobStorageKey } from "./blob-key.js";
17
17
  export class LocalFsBlobStore {
18
18
  rootDir;
19
+ // Storage keys include workspace + content hash: immutable bytes make successful sizes
20
+ // reusable for this process. Promises coalesce concurrent reads of the same hash as well.
21
+ sizesByKey = new Map();
19
22
  constructor(deps) {
20
23
  this.rootDir = deps.rootDir;
21
24
  }
@@ -27,6 +30,7 @@ export class LocalFsBlobStore {
27
30
  const path = this.resolvePath(storageKey);
28
31
  await mkdir(dirname(path), { recursive: true });
29
32
  await writeFile(path, input.bytes);
33
+ this.sizesByKey.delete(storageKey);
30
34
  return { storageKey };
31
35
  }
32
36
  /**
@@ -46,6 +50,7 @@ export class LocalFsBlobStore {
46
50
  await mkdir(dirname(path), { recursive: true });
47
51
  try {
48
52
  await writeFile(path, input.bytes, { flag: "wx" });
53
+ this.sizesByKey.delete(storageKey);
49
54
  return { storageKey, written: true };
50
55
  }
51
56
  catch (err) {
@@ -58,6 +63,27 @@ export class LocalFsBlobStore {
58
63
  async get(input) {
59
64
  return readFile(this.resolvePath(input.storageKey));
60
65
  }
66
+ sizeOf({ storageKey }, _optional = {}) {
67
+ const cached = this.sizesByKey.get(storageKey);
68
+ if (cached)
69
+ return cached;
70
+ const pending = stat(this.resolvePath(storageKey)).then((info) => info.size).catch((err) => {
71
+ if (err.code === "ENOENT")
72
+ return null;
73
+ throw err;
74
+ });
75
+ this.sizesByKey.set(storageKey, pending);
76
+ // Missing/restored blobs and transient failures must be retried. Identity guards prevent
77
+ // an older stat from clearing a newer entry after a write/remove invalidated the cache.
78
+ void pending.then((size) => {
79
+ if (size === null && this.sizesByKey.get(storageKey) === pending)
80
+ this.sizesByKey.delete(storageKey);
81
+ }, () => {
82
+ if (this.sizesByKey.get(storageKey) === pending)
83
+ this.sizesByKey.delete(storageKey);
84
+ });
85
+ return pending;
86
+ }
61
87
  /**
62
88
  * `ENOENT` (the path genuinely does not exist) resolves to `false`. Any other `stat()` failure
63
89
  * (`EACCES` permission denied, `ENOTDIR`, `EIO`, ...) is rethrown raw — same "catch the one
@@ -79,6 +105,7 @@ export class LocalFsBlobStore {
79
105
  }
80
106
  async remove(input) {
81
107
  await rm(this.resolvePath(input.storageKey), { force: true });
108
+ this.sizesByKey.delete(input.storageKey);
82
109
  }
83
110
  }
84
111
  //# sourceMappingURL=blob-store.fs.js.map
@@ -24,6 +24,9 @@ export declare class InMemoryBlobStore implements BlobStorePort {
24
24
  exists(input: {
25
25
  storageKey: string;
26
26
  }): Promise<boolean>;
27
+ sizeOf({ storageKey }: {
28
+ storageKey: string;
29
+ }, _optional?: Record<string, never>): Promise<number | null>;
27
30
  remove(input: {
28
31
  storageKey: string;
29
32
  }): Promise<void>;
@@ -29,6 +29,9 @@ export class InMemoryBlobStore {
29
29
  async exists(input) {
30
30
  return this.bytesByKey.has(input.storageKey);
31
31
  }
32
+ async sizeOf({ storageKey }, _optional = {}) {
33
+ return this.bytesByKey.get(storageKey)?.byteLength ?? null;
34
+ }
32
35
  async remove(input) {
33
36
  this.bytesByKey.delete(input.storageKey);
34
37
  }