@jini-ai/cms 0.4.2 → 0.5.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/CHANGELOG.md +20 -0
- package/dist/content-types/lifecycle.d.ts +1 -1
- package/dist/content-types/lifecycle.js +6 -2
- package/dist/content-types/types.d.ts +11 -0
- package/dist/content-types/write-service.d.ts +6 -0
- package/dist/content-types/write-service.js +7 -0
- package/dist/entries/errors.d.ts +7 -0
- package/dist/entries/errors.js +6 -0
- package/dist/entries/field-validation.d.ts +3 -0
- package/dist/entries/index.d.ts +2 -2
- package/dist/entries/index.js +1 -1
- package/dist/entries/repo.memory.d.ts +2 -2
- package/dist/entries/repo.memory.js +10 -1
- package/dist/entries/types.d.ts +2 -0
- package/dist/entries/write-service.d.ts +19 -8
- package/dist/entries/write-service.js +88 -54
- package/dist/navigation/agent-tools.js +10 -2
- package/dist/navigation/index.d.ts +2 -2
- package/dist/navigation/index.js +2 -2
- package/dist/navigation/menu-service.d.ts +40 -16
- package/dist/navigation/menu-service.js +126 -66
- package/dist/navigation/repo.memory.d.ts +46 -2
- package/dist/navigation/repo.memory.js +28 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.1 — 2026-10-06
|
|
4
|
+
|
|
5
|
+
- Navigation: the `menus_update_menu_tree` schema declares the `entryType`/`lastKnownHref` hints on entry targets, and `updateMenuTree` carries a stored hint forward when the same item still targets the same entry and the submitted target omits it, so echoing a read no longer marks the menu changed.
|
|
6
|
+
|
|
7
|
+
## 0.5.0 — 2026-10-05
|
|
8
|
+
|
|
9
|
+
### BREAKING
|
|
10
|
+
|
|
11
|
+
- 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.
|
|
12
|
+
- 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.
|
|
13
|
+
- 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").
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- 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.
|
|
18
|
+
- 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.
|
|
19
|
+
- 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.
|
|
20
|
+
- 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).
|
|
21
|
+
- 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>)`.
|
|
22
|
+
|
|
3
23
|
## 0.4.2 — 2026-10-04
|
|
4
24
|
|
|
5
25
|
- Media: optional `BlobStorePort.sizeOf` reports original blob bytes (filesystem store memoizes `stat`); `MediaRecord.createdBy` is stamped at creation and write-once.
|
|
@@ -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
|
|
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
|
|
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 () => {
|
package/dist/entries/errors.d.ts
CHANGED
|
@@ -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<{
|
package/dist/entries/errors.js
CHANGED
|
@@ -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.
|
package/dist/entries/index.d.ts
CHANGED
|
@@ -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
|
package/dist/entries/index.js
CHANGED
|
@@ -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) {
|
package/dist/entries/types.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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:
|
|
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:
|
|
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
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
}
|
|
@@ -84,7 +84,15 @@ const NAV_TARGET_SCHEMA = {
|
|
|
84
84
|
type: "object",
|
|
85
85
|
additionalProperties: false,
|
|
86
86
|
required: ["kind", "entryId"],
|
|
87
|
-
properties: {
|
|
87
|
+
properties: {
|
|
88
|
+
kind: { const: "entryRef" },
|
|
89
|
+
entryId: { type: "string", description: "Target entry id (e.g. a page or post)." },
|
|
90
|
+
// Declared so a read can be echoed back unchanged: these were stored on page links but absent
|
|
91
|
+
// here, so every agent save dropped them (dry run 2026-10-05). The save also keeps the stored
|
|
92
|
+
// value when omitted for the same item id + entryId (`carryForwardEntryHints`).
|
|
93
|
+
entryType: { type: "string", description: "Content-type hint, e.g. 'page' or 'post'. Copy it unchanged from the read; omit for a new link." },
|
|
94
|
+
lastKnownHref: { type: "string", description: "The entry's last known public URL, e.g. '/about'. Copy it unchanged from the read; omit for a new link." },
|
|
95
|
+
},
|
|
88
96
|
},
|
|
89
97
|
{
|
|
90
98
|
type: "object",
|
|
@@ -207,7 +215,7 @@ export const menusAgentToolCatalog = [
|
|
|
207
215
|
name: "menus_update_menu_tree",
|
|
208
216
|
description: "Replaces an existing menu's whole item tree (and optionally its title/slug) in one version-guarded write. " +
|
|
209
217
|
"Always a whole-tree replace — there is no per-item patch operation, so the submitted tree must include every " +
|
|
210
|
-
"item the menu keeps. To delete a menu, use trash_item with entityType 'menu' (it moves to the Trash and can be restored).",
|
|
218
|
+
"item the menu keeps, each with its fields copied unchanged from the read. To delete a menu, use trash_item with entityType 'menu' (it moves to the Trash and can be restored).",
|
|
211
219
|
sideEffects: "mutates-durable-state",
|
|
212
220
|
authorization: { permission: "admin.menus.update" },
|
|
213
221
|
inputSchema: {
|
|
@@ -15,7 +15,7 @@ export { createNavMenuReadModel, type NavMenuReadModelDeps, type NavMenuReadMode
|
|
|
15
15
|
* `repo.memory.ts`'s file header for why) but is a real public contract: a host's own SQLite
|
|
16
16
|
* adapter implements it directly, the same way it implements `NavLocationBindingRepoPort`.
|
|
17
17
|
*/
|
|
18
|
-
export { InMemoryMenuRepo, InMemoryNavLocationBindingRepo, type MenuRepoPort, } from "./repo.memory.js";
|
|
18
|
+
export { InMemoryMenuRepo, InMemoryNavLocationBindingRepo, menuVersionConflictError, type MenuRepoPort, type MenuSaveOptions, } from "./repo.memory.js";
|
|
19
19
|
/**
|
|
20
20
|
* `isAllowedHref`/`ALLOWED_HREF_SHAPES_DESCRIPTION` (2026-09-03): the canonical author-link
|
|
21
21
|
* allowlist, promoted here specifically so a HOST can import it rather than hand-copy it — the
|
|
@@ -25,7 +25,7 @@ export { InMemoryMenuRepo, InMemoryNavLocationBindingRepo, type MenuRepoPort, }
|
|
|
25
25
|
* `menu-service.ts`'s own doc comment on `isAllowedHref` for the retirement plan and why nothing in
|
|
26
26
|
* `check:boundaries` blocks either host-side call site from doing so.
|
|
27
27
|
*/
|
|
28
|
-
export { createMenu, updateMenuTree, assignLocation, deleteMenu, validateAndCloneTree, isAllowedHref, ALLOWED_HREF_SHAPES_DESCRIPTION, MenuNotFoundError, MenuValidationError, MenuConflictError, MenuLocationBoundError, DEFAULT_MAX_TREE_DEPTH, DEFAULT_MAX_ITEM_COUNT, type TreeValidationLimits, type CreateMenuDeps, type CreateMenuServiceInput, type CreateMenuRequired, type CreateMenuOptional, type UpdateMenuTreeDeps, type UpdateMenuTreeServiceInput, type UpdateMenuTreeRequired, type UpdateMenuTreeOptional, type AssignLocationDeps, type AssignLocationServiceInput, type AssignLocationRequired, type AssignLocationOptional, type DeleteMenuDeps, type DeleteMenuServiceInput, type DeleteMenuRequired, type DeleteMenuOptional, } from "./menu-service.js";
|
|
28
|
+
export { createMenu, updateMenuTree, assignLocation, deleteMenu, validateAndCloneTree, isAllowedHref, ALLOWED_HREF_SHAPES_DESCRIPTION, MenuNotFoundError, MenuValidationError, MenuConflictError, MenuVersionConflictError, MenuLocationBoundError, DEFAULT_MAX_TREE_DEPTH, DEFAULT_MAX_ITEM_COUNT, type TreeValidationLimits, type CreateMenuDeps, type CreateMenuServiceInput, type CreateMenuRequired, type CreateMenuOptional, type UpdateMenuTreeDeps, type UpdateMenuTreeServiceInput, type UpdateMenuTreeRequired, type UpdateMenuTreeOptional, type AssignLocationDeps, type AssignLocationServiceInput, type AssignLocationRequired, type AssignLocationOptional, type DeleteMenuDeps, type DeleteMenuServiceInput, type DeleteMenuRequired, type DeleteMenuOptional, } from "./menu-service.js";
|
|
29
29
|
export { resolveForLocation, resolveMenuDoc, type ResolveTargetHrefFn, type ResolvedTargetHref, type ResolveForLocationDeps, type ResolveForLocationServiceInput, type ResolveForLocationRequired, type ResolveForLocationOptional, } from "./resolver.js";
|
|
30
30
|
export { rebuildNavLocationBindings, type RebuildNavLocationBindingsDeps, type RebuildNavLocationBindingsResult, } from "./reconcile.js";
|
|
31
31
|
/** The agent-tool surface for this domain (see `agent-tools.ts` for what is deliberately omitted). */
|
package/dist/navigation/index.js
CHANGED
|
@@ -6,7 +6,7 @@ export { createNavMenuReadModel } from "./read-model.js";
|
|
|
6
6
|
* `repo.memory.ts`'s file header for why) but is a real public contract: a host's own SQLite
|
|
7
7
|
* adapter implements it directly, the same way it implements `NavLocationBindingRepoPort`.
|
|
8
8
|
*/
|
|
9
|
-
export { InMemoryMenuRepo, InMemoryNavLocationBindingRepo, } from "./repo.memory.js";
|
|
9
|
+
export { InMemoryMenuRepo, InMemoryNavLocationBindingRepo, menuVersionConflictError, } from "./repo.memory.js";
|
|
10
10
|
/**
|
|
11
11
|
* `isAllowedHref`/`ALLOWED_HREF_SHAPES_DESCRIPTION` (2026-09-03): the canonical author-link
|
|
12
12
|
* allowlist, promoted here specifically so a HOST can import it rather than hand-copy it — the
|
|
@@ -16,7 +16,7 @@ export { InMemoryMenuRepo, InMemoryNavLocationBindingRepo, } from "./repo.memory
|
|
|
16
16
|
* `menu-service.ts`'s own doc comment on `isAllowedHref` for the retirement plan and why nothing in
|
|
17
17
|
* `check:boundaries` blocks either host-side call site from doing so.
|
|
18
18
|
*/
|
|
19
|
-
export { createMenu, updateMenuTree, assignLocation, deleteMenu, validateAndCloneTree, isAllowedHref, ALLOWED_HREF_SHAPES_DESCRIPTION, MenuNotFoundError, MenuValidationError, MenuConflictError, MenuLocationBoundError, DEFAULT_MAX_TREE_DEPTH, DEFAULT_MAX_ITEM_COUNT, } from "./menu-service.js";
|
|
19
|
+
export { createMenu, updateMenuTree, assignLocation, deleteMenu, validateAndCloneTree, isAllowedHref, ALLOWED_HREF_SHAPES_DESCRIPTION, MenuNotFoundError, MenuValidationError, MenuConflictError, MenuVersionConflictError, MenuLocationBoundError, DEFAULT_MAX_TREE_DEPTH, DEFAULT_MAX_ITEM_COUNT, } from "./menu-service.js";
|
|
20
20
|
export { resolveForLocation, resolveMenuDoc, } from "./resolver.js";
|
|
21
21
|
export { rebuildNavLocationBindings, } from "./reconcile.js";
|
|
22
22
|
/** The agent-tool surface for this domain (see `agent-tools.ts` for what is deliberately omitted). */
|
|
@@ -18,6 +18,22 @@ export declare class MenuConflictError extends Error {
|
|
|
18
18
|
message: string;
|
|
19
19
|
}, optionalArgs?: ErrorOptions);
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* A menu write lost its version check: the basis the caller read is no longer the stored live row's
|
|
23
|
+
* version (or a create found the id taken). A subclass, so every `instanceof MenuConflictError` check
|
|
24
|
+
* keeps matching, while a host can tell "someone else changed this, reload" apart from the other
|
|
25
|
+
* conflicts (slug already taken, slug held by a trashed menu), which want the message itself.
|
|
26
|
+
* Every `MenuRepoPort` adapter throws it, built by {@link menuVersionConflictError}.
|
|
27
|
+
*/
|
|
28
|
+
export declare class MenuVersionConflictError extends MenuConflictError {
|
|
29
|
+
}
|
|
30
|
+
/** The version-check loss every `MenuRepoPort` adapter (and `updateMenuTree`'s read check) throws, one
|
|
31
|
+
* wording for all of them. `expectedVersion: null` is a create (`MenuSaveOptions`) that found the id taken. */
|
|
32
|
+
export declare function menuVersionConflictError(required: {
|
|
33
|
+
id: UUID;
|
|
34
|
+
expectedVersion: number | null;
|
|
35
|
+
found: number | null;
|
|
36
|
+
}): MenuVersionConflictError;
|
|
21
37
|
/**
|
|
22
38
|
* The 409-style purge rejection: hard delete is blocked while the menu is
|
|
23
39
|
* still bound to at least one theme location (deletion ladder).
|
|
@@ -182,9 +198,23 @@ export interface UpdateMenuTreeRequired {
|
|
|
182
198
|
export interface UpdateMenuTreeOptional {
|
|
183
199
|
limits?: TreeValidationLimits | undefined;
|
|
184
200
|
}
|
|
201
|
+
/**
|
|
202
|
+
* Whole-tree saves arrive from callers that may not know an item's stored hint fields (an agent
|
|
203
|
+
* echoing a read through a schema, an older editor build). Dropping them on every save made an
|
|
204
|
+
* untouched menu look changed (dry run 2026-10-05: ~30 header page links lost `entryType: "page"`
|
|
205
|
+
* on one assistant save and the menu showed pending-to-live). So a hint is kept when the same item
|
|
206
|
+
* id still points at the same entry and the submitted target leaves that hint out; a re-pointed
|
|
207
|
+
* link or an explicitly submitted hint is taken as sent.
|
|
208
|
+
*/
|
|
209
|
+
export declare function carryForwardEntryHints({ previous, next }: {
|
|
210
|
+
previous: readonly NavItemNode[];
|
|
211
|
+
next: readonly NavItemNode[];
|
|
212
|
+
}, _optional?: {}): NavItemNode[];
|
|
185
213
|
/**
|
|
186
214
|
* Replaces a menu's whole item tree, guarded by optimistic concurrency on the
|
|
187
|
-
* entry `version` (matches the host's `updatePost` pattern)
|
|
215
|
+
* entry `version` (matches the host's `updatePost` pattern): `expectedVersion` must equal the version
|
|
216
|
+
* read, and the repo's `save` compares it again atomically with the write; either miss throws
|
|
217
|
+
* `MenuVersionConflictError`.
|
|
188
218
|
*
|
|
189
219
|
* Id-stability note ("an update may not renumber surviving items"):
|
|
190
220
|
* this build enforces only that ids are present and unique within the
|
|
@@ -236,24 +266,18 @@ export interface AssignLocationOptional {
|
|
|
236
266
|
* default) and its `locations` field is updated to drop the key — recorded as
|
|
237
267
|
* its own revision-worthy write.
|
|
238
268
|
*
|
|
239
|
-
* Atomicity
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
* has real transactions available.
|
|
269
|
+
* Atomicity: these writes are ONE transaction (`repo.transaction`) — the displaced
|
|
270
|
+
* menu's save, this menu's save, the binding upsert and the outbox events commit
|
|
271
|
+
* together or not at all. Both menu saves are compare-and-set, so the second can
|
|
272
|
+
* lose to another editor AFTER the first landed: without the transaction, that
|
|
273
|
+
* left the displaced menu's `locations` without the key while the binding still
|
|
274
|
+
* pointed at it, and a binding rebuild then dropped the assignment. A
|
|
275
|
+
* `MenuVersionConflictError` (or any other throw) rolls the whole unit back in a
|
|
276
|
+
* store that can roll back; `InMemoryMenuRepo` cannot (see its `transaction`).
|
|
248
277
|
*
|
|
249
278
|
* @complexity O repo calls (bounded: at most two menu reads/writes plus one
|
|
250
279
|
* binding upsert), independent of workspace size.
|
|
251
|
-
* @overallScore
|
|
252
|
-
* @findings Medium: no rollback across the two writes (menu save, then
|
|
253
|
-
* binding upsert) if the second fails, leaving the derived index stale until
|
|
254
|
-
* a rebuild. Acceptable because the index is declared rebuildable-by-definition;
|
|
255
|
-
* flagged as tech debt for when transactions
|
|
256
|
-
* exist.
|
|
280
|
+
* @overallScore 100
|
|
257
281
|
*/
|
|
258
282
|
export declare function assignLocation(required: AssignLocationRequired, _optional?: AssignLocationOptional): Promise<{
|
|
259
283
|
menu: NavMenuEntry;
|
|
@@ -67,6 +67,23 @@ export class MenuConflictError extends Error {
|
|
|
67
67
|
super(message, optionalArgs);
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* A menu write lost its version check: the basis the caller read is no longer the stored live row's
|
|
72
|
+
* version (or a create found the id taken). A subclass, so every `instanceof MenuConflictError` check
|
|
73
|
+
* keeps matching, while a host can tell "someone else changed this, reload" apart from the other
|
|
74
|
+
* conflicts (slug already taken, slug held by a trashed menu), which want the message itself.
|
|
75
|
+
* Every `MenuRepoPort` adapter throws it, built by {@link menuVersionConflictError}.
|
|
76
|
+
*/
|
|
77
|
+
export class MenuVersionConflictError extends MenuConflictError {
|
|
78
|
+
}
|
|
79
|
+
/** The version-check loss every `MenuRepoPort` adapter (and `updateMenuTree`'s read check) throws, one
|
|
80
|
+
* wording for all of them. `expectedVersion: null` is a create (`MenuSaveOptions`) that found the id taken. */
|
|
81
|
+
export function menuVersionConflictError(required) {
|
|
82
|
+
if (required.expectedVersion === null) {
|
|
83
|
+
return new MenuVersionConflictError({ message: `menu '${required.id}' already exists (expected no menu, found version ${required.found ?? "none"})` });
|
|
84
|
+
}
|
|
85
|
+
return new MenuVersionConflictError({ message: `menu '${required.id}' was modified concurrently (expected version ${required.expectedVersion}, found ${required.found ?? "none"})` });
|
|
86
|
+
}
|
|
70
87
|
/**
|
|
71
88
|
* The 409-style purge rejection: hard delete is blocked while the menu is
|
|
72
89
|
* still bound to at least one theme location (deletion ladder).
|
|
@@ -310,9 +327,49 @@ export async function createMenu(required, optional = {}) {
|
|
|
310
327
|
}));
|
|
311
328
|
return { menu };
|
|
312
329
|
}
|
|
330
|
+
/** The stored `entryRef` hint fields a whole-tree save carries forward when the submitted target omits them. */
|
|
331
|
+
const ENTRY_HINT_KEYS = ["entryType", "lastKnownHref"];
|
|
332
|
+
function entryTargetsById(nodes, into = new Map()) {
|
|
333
|
+
for (const node of nodes) {
|
|
334
|
+
if (node.target?.kind === "entryRef")
|
|
335
|
+
into.set(node.id, node.target);
|
|
336
|
+
if (node.children)
|
|
337
|
+
entryTargetsById(node.children, into);
|
|
338
|
+
}
|
|
339
|
+
return into;
|
|
340
|
+
}
|
|
341
|
+
function withStoredHints(target, stored) {
|
|
342
|
+
if (target.kind !== "entryRef" || !stored || stored.entryId !== target.entryId)
|
|
343
|
+
return target;
|
|
344
|
+
const merged = { ...target };
|
|
345
|
+
for (const key of ENTRY_HINT_KEYS) {
|
|
346
|
+
if (merged[key] === undefined && stored[key] !== undefined)
|
|
347
|
+
merged[key] = stored[key];
|
|
348
|
+
}
|
|
349
|
+
return merged;
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Whole-tree saves arrive from callers that may not know an item's stored hint fields (an agent
|
|
353
|
+
* echoing a read through a schema, an older editor build). Dropping them on every save made an
|
|
354
|
+
* untouched menu look changed (dry run 2026-10-05: ~30 header page links lost `entryType: "page"`
|
|
355
|
+
* on one assistant save and the menu showed pending-to-live). So a hint is kept when the same item
|
|
356
|
+
* id still points at the same entry and the submitted target leaves that hint out; a re-pointed
|
|
357
|
+
* link or an explicitly submitted hint is taken as sent.
|
|
358
|
+
*/
|
|
359
|
+
export function carryForwardEntryHints({ previous, next }, _optional = {}) {
|
|
360
|
+
const stored = entryTargetsById(previous);
|
|
361
|
+
const walk = (nodes) => nodes.map((node) => ({
|
|
362
|
+
...node,
|
|
363
|
+
target: withStoredHints(node.target, stored.get(node.id)),
|
|
364
|
+
children: node.children ? walk(node.children) : undefined,
|
|
365
|
+
}));
|
|
366
|
+
return walk(next);
|
|
367
|
+
}
|
|
313
368
|
/**
|
|
314
369
|
* Replaces a menu's whole item tree, guarded by optimistic concurrency on the
|
|
315
|
-
* entry `version` (matches the host's `updatePost` pattern)
|
|
370
|
+
* entry `version` (matches the host's `updatePost` pattern): `expectedVersion` must equal the version
|
|
371
|
+
* read, and the repo's `save` compares it again atomically with the write; either miss throws
|
|
372
|
+
* `MenuVersionConflictError`.
|
|
316
373
|
*
|
|
317
374
|
* Id-stability note ("an update may not renumber surviving items"):
|
|
318
375
|
* this build enforces only that ids are present and unique within the
|
|
@@ -336,8 +393,10 @@ export async function updateMenuTree(required, optional = {}) {
|
|
|
336
393
|
if (!existing)
|
|
337
394
|
throw new MenuNotFoundError({ message: `menu '${input.id}' was not found` });
|
|
338
395
|
assertEntityLive({ entityType: "menu", entityId: input.id, state: existing.status === "trash" ? "trashed" : "live" });
|
|
396
|
+
// The new record is built from this read, so a basis that differs from it must not reach the save:
|
|
397
|
+
// it could match a version another writer lands after this read and overwrite that writer.
|
|
339
398
|
if (existing.version !== input.expectedVersion) {
|
|
340
|
-
throw
|
|
399
|
+
throw menuVersionConflictError({ id: input.id, expectedVersion: input.expectedVersion, found: existing.version });
|
|
341
400
|
}
|
|
342
401
|
const title = (input.title ?? existing.title).trim();
|
|
343
402
|
const slug = (input.slug ?? existing.slug).trim().toLowerCase();
|
|
@@ -348,7 +407,10 @@ export async function updateMenuTree(required, optional = {}) {
|
|
|
348
407
|
throw new MenuConflictError({ message: `slug '${slug}' already exists` });
|
|
349
408
|
}
|
|
350
409
|
}
|
|
351
|
-
const items =
|
|
410
|
+
const items = carryForwardEntryHints({
|
|
411
|
+
previous: existing.doc.items,
|
|
412
|
+
next: validateAndCloneTree({ items: input.items }, optional.limits),
|
|
413
|
+
});
|
|
352
414
|
const menu = {
|
|
353
415
|
...existing,
|
|
354
416
|
title,
|
|
@@ -357,7 +419,9 @@ export async function updateMenuTree(required, optional = {}) {
|
|
|
357
419
|
updatedAt: kernelNowIso({ clock: deps.clock }),
|
|
358
420
|
version: existing.version + 1,
|
|
359
421
|
};
|
|
360
|
-
|
|
422
|
+
// The version check is ALSO the save's compare-and-set, not only the read above: a check before
|
|
423
|
+
// the write alone let two editors that read the same version both land (wm S4).
|
|
424
|
+
await deps.repo.save(menu, { expectedVersion: input.expectedVersion });
|
|
361
425
|
await deps.outbox.enqueue(buildEvent({
|
|
362
426
|
idGen: deps.idGen,
|
|
363
427
|
clock: deps.clock,
|
|
@@ -379,24 +443,18 @@ export async function updateMenuTree(required, optional = {}) {
|
|
|
379
443
|
* default) and its `locations` field is updated to drop the key — recorded as
|
|
380
444
|
* its own revision-worthy write.
|
|
381
445
|
*
|
|
382
|
-
* Atomicity
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
* has real transactions available.
|
|
446
|
+
* Atomicity: these writes are ONE transaction (`repo.transaction`) — the displaced
|
|
447
|
+
* menu's save, this menu's save, the binding upsert and the outbox events commit
|
|
448
|
+
* together or not at all. Both menu saves are compare-and-set, so the second can
|
|
449
|
+
* lose to another editor AFTER the first landed: without the transaction, that
|
|
450
|
+
* left the displaced menu's `locations` without the key while the binding still
|
|
451
|
+
* pointed at it, and a binding rebuild then dropped the assignment. A
|
|
452
|
+
* `MenuVersionConflictError` (or any other throw) rolls the whole unit back in a
|
|
453
|
+
* store that can roll back; `InMemoryMenuRepo` cannot (see its `transaction`).
|
|
391
454
|
*
|
|
392
455
|
* @complexity O repo calls (bounded: at most two menu reads/writes plus one
|
|
393
456
|
* binding upsert), independent of workspace size.
|
|
394
|
-
* @overallScore
|
|
395
|
-
* @findings Medium: no rollback across the two writes (menu save, then
|
|
396
|
-
* binding upsert) if the second fails, leaving the derived index stale until
|
|
397
|
-
* a rebuild. Acceptable because the index is declared rebuildable-by-definition;
|
|
398
|
-
* flagged as tech debt for when transactions
|
|
399
|
-
* exist.
|
|
457
|
+
* @overallScore 100
|
|
400
458
|
*/
|
|
401
459
|
export async function assignLocation(required, _optional = {}) {
|
|
402
460
|
const { deps, input } = required;
|
|
@@ -405,27 +463,6 @@ export async function assignLocation(required, _optional = {}) {
|
|
|
405
463
|
throw new MenuNotFoundError({ message: `menu '${input.menuId}' was not found` });
|
|
406
464
|
assertEntityLive({ entityType: "menu", entityId: input.menuId, state: menu.status === "trash" ? "trashed" : "live" });
|
|
407
465
|
const now = kernelNowIso({ clock: deps.clock });
|
|
408
|
-
const existingBinding = await deps.bindingRepo.findByLocation({
|
|
409
|
-
workspaceId: input.workspaceId,
|
|
410
|
-
locationKey: input.locationKey,
|
|
411
|
-
});
|
|
412
|
-
let displacedMenu = null;
|
|
413
|
-
if (existingBinding && existingBinding.menuId !== input.menuId) {
|
|
414
|
-
const displaced = await deps.repo.findById({
|
|
415
|
-
workspaceId: input.workspaceId,
|
|
416
|
-
id: existingBinding.menuId,
|
|
417
|
-
});
|
|
418
|
-
if (displaced) {
|
|
419
|
-
const updatedDisplaced = {
|
|
420
|
-
...displaced,
|
|
421
|
-
locations: displaced.locations.filter((key) => key !== input.locationKey),
|
|
422
|
-
updatedAt: now,
|
|
423
|
-
version: displaced.version + 1,
|
|
424
|
-
};
|
|
425
|
-
await deps.repo.save(updatedDisplaced);
|
|
426
|
-
displacedMenu = updatedDisplaced;
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
466
|
const updatedMenu = {
|
|
430
467
|
...menu,
|
|
431
468
|
locations: menu.locations.includes(input.locationKey)
|
|
@@ -434,32 +471,55 @@ export async function assignLocation(required, _optional = {}) {
|
|
|
434
471
|
updatedAt: now,
|
|
435
472
|
version: menu.version + 1,
|
|
436
473
|
};
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
474
|
+
return deps.repo.transaction({ fn: async () => {
|
|
475
|
+
const existingBinding = await deps.bindingRepo.findByLocation({
|
|
476
|
+
workspaceId: input.workspaceId,
|
|
477
|
+
locationKey: input.locationKey,
|
|
478
|
+
});
|
|
479
|
+
let displacedMenu = null;
|
|
480
|
+
if (existingBinding && existingBinding.menuId !== input.menuId) {
|
|
481
|
+
const displaced = await deps.repo.findById({
|
|
482
|
+
workspaceId: input.workspaceId,
|
|
483
|
+
id: existingBinding.menuId,
|
|
484
|
+
});
|
|
485
|
+
if (displaced) {
|
|
486
|
+
const updatedDisplaced = {
|
|
487
|
+
...displaced,
|
|
488
|
+
locations: displaced.locations.filter((key) => key !== input.locationKey),
|
|
489
|
+
updatedAt: now,
|
|
490
|
+
version: displaced.version + 1,
|
|
491
|
+
};
|
|
492
|
+
await deps.repo.save(updatedDisplaced, { expectedVersion: displaced.version });
|
|
493
|
+
displacedMenu = updatedDisplaced;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
await deps.repo.save(updatedMenu, { expectedVersion: menu.version });
|
|
497
|
+
const binding = await deps.bindingRepo.upsert({
|
|
498
|
+
workspaceId: input.workspaceId,
|
|
499
|
+
locationKey: input.locationKey,
|
|
500
|
+
menuId: input.menuId,
|
|
501
|
+
boundAt: now,
|
|
502
|
+
});
|
|
503
|
+
if (displacedMenu) {
|
|
504
|
+
await deps.outbox.enqueue(buildEvent({
|
|
505
|
+
idGen: deps.idGen,
|
|
506
|
+
clock: deps.clock,
|
|
507
|
+
name: "navigation.location.unassigned",
|
|
508
|
+
workspaceId: input.workspaceId,
|
|
509
|
+
aggregateId: displacedMenu.id,
|
|
510
|
+
payload: { locationKey: input.locationKey, menuId: displacedMenu.id },
|
|
511
|
+
}));
|
|
512
|
+
}
|
|
513
|
+
await deps.outbox.enqueue(buildEvent({
|
|
514
|
+
idGen: deps.idGen,
|
|
515
|
+
clock: deps.clock,
|
|
516
|
+
name: "navigation.location.assigned",
|
|
517
|
+
workspaceId: input.workspaceId,
|
|
518
|
+
aggregateId: input.menuId,
|
|
519
|
+
payload: { locationKey: input.locationKey, menuId: input.menuId },
|
|
520
|
+
}));
|
|
521
|
+
return { menu: updatedMenu, binding, displacedMenu };
|
|
522
|
+
} });
|
|
463
523
|
}
|
|
464
524
|
/**
|
|
465
525
|
* Deletion ladder (mirrors the media library's):
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
* collections.
|
|
33
33
|
*/
|
|
34
34
|
import type { UUID } from "@jini-ai/core/primitives";
|
|
35
|
+
import { menuVersionConflictError } from "./menu-service.js";
|
|
35
36
|
import type { NavLocationBindingRepoPort } from "./ports.js";
|
|
36
37
|
import type { NavLocationBindingRow, NavLocationKey, NavMenuEntry } from "./types.js";
|
|
37
38
|
/**
|
|
@@ -53,13 +54,46 @@ export interface MenuRepoPort {
|
|
|
53
54
|
list(required: {
|
|
54
55
|
workspaceId: UUID;
|
|
55
56
|
}): Promise<NavMenuEntry[]>;
|
|
56
|
-
|
|
57
|
+
/**
|
|
58
|
+
* Writes `record` by id. With `expectedVersion`, the write is a compare-and-set: it lands only when
|
|
59
|
+
* the stored row is live (not `status: "trash"`) and still holds that version, checked atomically
|
|
60
|
+
* with the write (one conditional UPDATE in a SQL adapter), so two writers that read the same
|
|
61
|
+
* version cannot both land. With `expectedVersion: null` it is insert-if-absent (a create that
|
|
62
|
+
* keeps a caller-given id, e.g. a host's id-preserving import; `INSERT ... ON CONFLICT DO NOTHING`
|
|
63
|
+
* in a SQL adapter), so two creates of one id cannot both land either. Without it, the write is
|
|
64
|
+
* unconditional (`createMenu`'s freshly minted id, trash seams).
|
|
65
|
+
* @throws MenuVersionConflictError ``menu '<id>' was modified concurrently (expected version <n>, found <stored|none>)``,
|
|
66
|
+
* or ``menu '<id>' already exists (expected no menu, found version <n>)`` for a create
|
|
67
|
+
* ({@link menuVersionConflictError}).
|
|
68
|
+
*/
|
|
69
|
+
save(record: NavMenuEntry, options?: MenuSaveOptions): Promise<void>;
|
|
57
70
|
/** Hard-remove a menu row. Only called after the trash step. */
|
|
58
71
|
remove(required: {
|
|
59
72
|
workspaceId: UUID;
|
|
60
73
|
id: UUID;
|
|
61
74
|
}): Promise<void>;
|
|
75
|
+
/**
|
|
76
|
+
* Runs `fn` as ONE unit of work: every menu save, binding write and outbox enqueue inside it commits
|
|
77
|
+
* together or not at all, and a nested call joins the outer one. `assignLocation` needs it because it
|
|
78
|
+
* writes two menus, then the binding: a conflict on the second menu must not leave the first one's
|
|
79
|
+
* `locations` changed while the binding still points at it. A SQL adapter runs its connection's
|
|
80
|
+
* transaction, and the binding repo and outbox must write through that same connection (a wiring
|
|
81
|
+
* obligation of the host). A store with nothing to roll back may just call `fn` (see
|
|
82
|
+
* `InMemoryMenuRepo.transaction`).
|
|
83
|
+
*/
|
|
84
|
+
transaction<T>(required: {
|
|
85
|
+
fn: () => Promise<T>;
|
|
86
|
+
}): Promise<T>;
|
|
62
87
|
}
|
|
88
|
+
/** Options for {@link MenuRepoPort.save}. */
|
|
89
|
+
export interface MenuSaveOptions {
|
|
90
|
+
/** The version the caller read; the save throws `MenuVersionConflictError` unless the stored live row still holds it.
|
|
91
|
+
* `null` is a create: the save throws unless no row (live, trashed, or in another workspace) holds the id. */
|
|
92
|
+
expectedVersion?: number | null | undefined;
|
|
93
|
+
}
|
|
94
|
+
/** Re-exported from here, where it has always been exported from; it lives in `menu-service.ts` so that
|
|
95
|
+
* module's own read check can throw it without a value import cycle. */
|
|
96
|
+
export { menuVersionConflictError };
|
|
63
97
|
/**
|
|
64
98
|
* In-memory `MenuRepoPort` adapter for dev/tests. See file header for why a
|
|
65
99
|
* self-contained repo exists instead of an entries-backed one.
|
|
@@ -86,7 +120,17 @@ export declare class InMemoryMenuRepo implements MenuRepoPort {
|
|
|
86
120
|
list(required: {
|
|
87
121
|
workspaceId: UUID;
|
|
88
122
|
}): Promise<NavMenuEntry[]>;
|
|
89
|
-
save(record: NavMenuEntry): Promise<void>;
|
|
123
|
+
save(record: NavMenuEntry, options?: MenuSaveOptions): Promise<void>;
|
|
124
|
+
/**
|
|
125
|
+
* Just runs `fn`: nothing here rolls back, the same disclosed choice as `InMemoryEntryRepo`'s. A
|
|
126
|
+
* partial write can only be left by a version conflict on a later save of the same unit, which in
|
|
127
|
+
* this single-process store needs another writer to land between that unit's awaits; dev and test
|
|
128
|
+
* hosts accept that, and every durable host adapter must roll back.
|
|
129
|
+
* @complexity O(1) beyond `fn`.
|
|
130
|
+
*/
|
|
131
|
+
transaction<T>(required: {
|
|
132
|
+
fn: () => Promise<T>;
|
|
133
|
+
}): Promise<T>;
|
|
90
134
|
remove(required: {
|
|
91
135
|
workspaceId: UUID;
|
|
92
136
|
id: UUID;
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { menuVersionConflictError } from "./menu-service.js";
|
|
2
|
+
/** Re-exported from here, where it has always been exported from; it lives in `menu-service.ts` so that
|
|
3
|
+
* module's own read check can throw it without a value import cycle. */
|
|
4
|
+
export { menuVersionConflictError };
|
|
1
5
|
/**
|
|
2
6
|
* In-memory `MenuRepoPort` adapter for dev/tests. See file header for why a
|
|
3
7
|
* self-contained repo exists instead of an entries-backed one.
|
|
@@ -23,14 +27,37 @@ export class InMemoryMenuRepo {
|
|
|
23
27
|
async list(required) {
|
|
24
28
|
return this.rows.filter((row) => row.workspaceId === required.workspaceId);
|
|
25
29
|
}
|
|
26
|
-
async save(record) {
|
|
30
|
+
async save(record, options = {}) {
|
|
27
31
|
const index = this.rows.findIndex((row) => row.workspaceId === record.workspaceId && row.id === record.id);
|
|
32
|
+
if (options.expectedVersion === null) {
|
|
33
|
+
// A create needs the id free in every workspace and status, as a SQL primary key on `id` does.
|
|
34
|
+
const holder = this.rows.find((row) => row.id === record.id);
|
|
35
|
+
if (holder)
|
|
36
|
+
throw menuVersionConflictError({ id: record.id, expectedVersion: null, found: holder.version });
|
|
37
|
+
}
|
|
38
|
+
else if (options.expectedVersion !== undefined) {
|
|
39
|
+
const stored = this.rows[index];
|
|
40
|
+
const found = stored && stored.status !== "trash" ? stored.version : null;
|
|
41
|
+
if (found !== options.expectedVersion) {
|
|
42
|
+
throw menuVersionConflictError({ id: record.id, expectedVersion: options.expectedVersion, found });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
28
45
|
if (index === -1) {
|
|
29
46
|
this.rows.push(record);
|
|
30
47
|
return;
|
|
31
48
|
}
|
|
32
49
|
this.rows[index] = record;
|
|
33
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Just runs `fn`: nothing here rolls back, the same disclosed choice as `InMemoryEntryRepo`'s. A
|
|
53
|
+
* partial write can only be left by a version conflict on a later save of the same unit, which in
|
|
54
|
+
* this single-process store needs another writer to land between that unit's awaits; dev and test
|
|
55
|
+
* hosts accept that, and every durable host adapter must roll back.
|
|
56
|
+
* @complexity O(1) beyond `fn`.
|
|
57
|
+
*/
|
|
58
|
+
async transaction(required) {
|
|
59
|
+
return required.fn();
|
|
60
|
+
}
|
|
34
61
|
async remove(required) {
|
|
35
62
|
this.rows = this.rows.filter((row) => !(row.workspaceId === required.workspaceId && row.id === required.id));
|
|
36
63
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jini-ai/cms",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Content-model capability for Jini-hosted products: framework-free contracts, ports, and domain services in `/core` and per-domain subpaths; Node-bound adapters in domain-specific subpaths.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -132,7 +132,7 @@
|
|
|
132
132
|
"@jini-ai/http-kit": "0.4.0"
|
|
133
133
|
},
|
|
134
134
|
"dependencies": {
|
|
135
|
-
"@jini-ai/core": "^0.4.
|
|
135
|
+
"@jini-ai/core": "^0.4.1"
|
|
136
136
|
},
|
|
137
137
|
"peerDependencies": {
|
|
138
138
|
"sharp": "^0.35.3",
|