@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.
- package/CHANGELOG.md +23 -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/media/blob-store.fs.d.ts +4 -0
- package/dist/media/blob-store.fs.js +27 -0
- package/dist/media/blob-store.memory.d.ts +3 -0
- package/dist/media/blob-store.memory.js +3 -0
- package/dist/media/created-by.d.ts +15 -0
- package/dist/media/created-by.js +20 -0
- package/dist/media/index.d.ts +1 -0
- package/dist/media/index.js +1 -0
- package/dist/media/media-service.d.ts +1 -1
- package/dist/media/media-service.js +1 -0
- package/dist/media/ports.d.ts +12 -2
- package/dist/media/repo.memory.js +2 -1
- package/dist/media/tool-registrations.js +1 -0
- package/dist/media/types.d.ts +4 -0
- package/dist/navigation/index.d.ts +2 -2
- package/dist/navigation/index.js +2 -2
- package/dist/navigation/menu-service.d.ts +28 -16
- package/dist/navigation/menu-service.js +90 -65
- package/dist/navigation/repo.memory.d.ts +46 -2
- package/dist/navigation/repo.memory.js +28 -1
- package/dist/navigation/types.d.ts +5 -0
- package/dist/taxonomy/write-service.d.ts +4 -2
- package/dist/taxonomy/write-service.js +8 -3
- package/dist/trash/write-service.js +19 -12
- package/package.json +2 -2
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { MediaRecord } from "./types.js";
|
|
2
|
+
import type { MediaRepoPort } from "./ports.js";
|
|
3
|
+
/** Bind creation writes to one authenticated caller without shared mutable actor state.
|
|
4
|
+
* Useful when adapting an older upload service that predates asset-level attribution. */
|
|
5
|
+
export declare function mediaRepoWithCreator(required: {
|
|
6
|
+
mediaRepo: MediaRepoPort;
|
|
7
|
+
principalId: string;
|
|
8
|
+
}, _optional?: Record<string, never>): MediaRepoPort;
|
|
9
|
+
/** An update retains creation attribution, even when the original creator is unknown.
|
|
10
|
+
* Omitting an unknown field preserves compatibility with legacy serialized records. */
|
|
11
|
+
export declare function preserveMediaCreator(required: {
|
|
12
|
+
record: MediaRecord;
|
|
13
|
+
original: MediaRecord;
|
|
14
|
+
}, _optional?: Record<string, never>): MediaRecord;
|
|
15
|
+
//# sourceMappingURL=created-by.d.ts.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Bind creation writes to one authenticated caller without shared mutable actor state.
|
|
2
|
+
* Useful when adapting an older upload service that predates asset-level attribution. */
|
|
3
|
+
export function mediaRepoWithCreator(required, _optional = {}) {
|
|
4
|
+
const { mediaRepo, principalId } = required;
|
|
5
|
+
return {
|
|
6
|
+
findById: input => mediaRepo.findById(input),
|
|
7
|
+
findBySlug: input => mediaRepo.findBySlug(input),
|
|
8
|
+
list: input => mediaRepo.list(input),
|
|
9
|
+
remove: input => mediaRepo.remove(input),
|
|
10
|
+
save: record => mediaRepo.save({ ...record, createdBy: principalId }),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
/** An update retains creation attribution, even when the original creator is unknown.
|
|
14
|
+
* Omitting an unknown field preserves compatibility with legacy serialized records. */
|
|
15
|
+
export function preserveMediaCreator(required, _optional = {}) {
|
|
16
|
+
const { createdBy: _ignored, ...metadata } = required.record;
|
|
17
|
+
return { ...metadata, ...(required.original.createdBy !== undefined
|
|
18
|
+
? { createdBy: required.original.createdBy } : {}) };
|
|
19
|
+
}
|
|
20
|
+
//# sourceMappingURL=created-by.js.map
|
package/dist/media/index.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ export type { MediaStatus, MediaSource, MediaRecord, AssetBlobStatus, AssetBlobR
|
|
|
15
15
|
export { MediaNotFoundError, MediaValidationError, MediaConflictError, MediaSourceImmutableError, MediaStillReferencedError, } from "./types.js";
|
|
16
16
|
export type { MediaRepoPort, AssetBlobRepoPort, AssetRenditionRepoPort, BlobStorePort, BlobGcJournalRepoPort, PutBlobInput, TransformDefinitionRepoPort, } from "./ports.js";
|
|
17
17
|
export { computeBlobStorageKey } from "./blob-key.js";
|
|
18
|
+
export { preserveMediaCreator, mediaRepoWithCreator } from "./created-by.js";
|
|
18
19
|
export { MEDIA_HTML_ATTRIBUTE_ALLOWED_NAMES, isAllowedMediaHtmlAttributeName, parseMediaHtmlAttributes, describeMediaHtmlAttributeError, type MediaHtmlAttributeRejectionReason, type MediaHtmlAttributeError, type ParsedMediaHtmlAttributes, } from "./html-attributes.js";
|
|
19
20
|
export { InMemoryMediaRepo, InMemoryAssetBlobRepo, InMemoryAssetRenditionRepo, InMemoryBlobGcJournalRepo, InMemoryTransformDefinitionRepo, } from "./repo.memory.js";
|
|
20
21
|
export { withSha256Lock } from "./blob-gc-lock.js";
|
package/dist/media/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { MediaNotFoundError, MediaValidationError, MediaConflictError, MediaSourceImmutableError, MediaStillReferencedError, } from "./types.js";
|
|
2
2
|
export { computeBlobStorageKey } from "./blob-key.js";
|
|
3
|
+
export { preserveMediaCreator, mediaRepoWithCreator } from "./created-by.js";
|
|
3
4
|
export { MEDIA_HTML_ATTRIBUTE_ALLOWED_NAMES, isAllowedMediaHtmlAttributeName, parseMediaHtmlAttributes, describeMediaHtmlAttributeError, } from "./html-attributes.js";
|
|
4
5
|
export { InMemoryMediaRepo, InMemoryAssetBlobRepo, InMemoryAssetRenditionRepo, InMemoryBlobGcJournalRepo, InMemoryTransformDefinitionRepo, } from "./repo.memory.js";
|
|
5
6
|
export { withSha256Lock } from "./blob-gc-lock.js";
|
|
@@ -60,7 +60,7 @@ export interface UploadMediaInput {
|
|
|
60
60
|
alt?: string | undefined;
|
|
61
61
|
caption?: string | undefined;
|
|
62
62
|
credit?: string | undefined;
|
|
63
|
-
/**
|
|
63
|
+
/** Acting principal, stamped on each media row and on a newly created asset_blobs row. */
|
|
64
64
|
createdByPrincipal: string;
|
|
65
65
|
}
|
|
66
66
|
export interface UploadMediaDeps {
|
package/dist/media/ports.d.ts
CHANGED
|
@@ -13,13 +13,15 @@
|
|
|
13
13
|
*
|
|
14
14
|
* `BlobStorePort`'s surface is deliberately narrower than the full
|
|
15
15
|
* design: no `capabilities()`/`createPresignedUpload()` (the presign surface is
|
|
16
|
-
* explicitly deferred by this task's scope). It
|
|
17
|
-
*
|
|
16
|
+
* explicitly deferred by this task's scope). It extends the original minimal
|
|
17
|
+
* 3 (`put`/`get`/`exists`) with `remove()`. That addition is
|
|
18
18
|
* necessary to implement `purgeMedia`'s explicitly-requested "best-effort blob
|
|
19
19
|
* unlink" (see `media-service.ts`) — without a way to delete bytes, purge could
|
|
20
20
|
* only ever delete rows, never reclaim storage. This is disclosed here, not
|
|
21
21
|
* silently smuggled in: it is still far short of the real journaled
|
|
22
22
|
* GC protocol (no epochs, no `BEGIN IMMEDIATE`, no crash-safety guarantee).
|
|
23
|
+
* `putIfAbsent` adds atomic create-only writes; optional `sizeOf` reports original bytes
|
|
24
|
+
* without reading payloads (successful filesystem sizes are cached by content-addressed key).
|
|
23
25
|
*
|
|
24
26
|
* Interfaces only — no feature logic.
|
|
25
27
|
*/
|
|
@@ -162,6 +164,14 @@ export interface BlobStorePort {
|
|
|
162
164
|
get(input: {
|
|
163
165
|
storageKey: string;
|
|
164
166
|
}): Promise<Uint8Array>;
|
|
167
|
+
/**
|
|
168
|
+
* Original byte count without reading the payload; null only when the key is absent.
|
|
169
|
+
* Other storage failures reject. Optional for older/custom adapters; consumers must report
|
|
170
|
+
* unknown size when unavailable, never download an entire blob just to count bytes.
|
|
171
|
+
*/
|
|
172
|
+
sizeOf?(required: {
|
|
173
|
+
storageKey: string;
|
|
174
|
+
}, optional?: Record<string, never>): Promise<number | null>;
|
|
165
175
|
exists(input: {
|
|
166
176
|
storageKey: string;
|
|
167
177
|
}): Promise<boolean>;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { preserveMediaCreator } from "./created-by.js";
|
|
1
2
|
export class InMemoryMediaRepo {
|
|
2
3
|
rows;
|
|
3
4
|
constructor(requiredArgs, optionalArgs = {}) {
|
|
@@ -19,7 +20,7 @@ export class InMemoryMediaRepo {
|
|
|
19
20
|
this.rows.push(record);
|
|
20
21
|
return;
|
|
21
22
|
}
|
|
22
|
-
this.rows[index] = record;
|
|
23
|
+
this.rows[index] = preserveMediaCreator({ record, original: this.rows[index] });
|
|
23
24
|
}
|
|
24
25
|
async remove(required) {
|
|
25
26
|
this.rows = this.rows.filter((row) => !(row.workspaceId === required.workspaceId && row.id === required.id));
|
package/dist/media/types.d.ts
CHANGED
|
@@ -41,6 +41,10 @@ export interface MediaSource {
|
|
|
41
41
|
export interface MediaRecord {
|
|
42
42
|
id: UUID;
|
|
43
43
|
workspaceId: UUID;
|
|
44
|
+
/** Acting principal at asset creation, independent of the producer/model and editable credit.
|
|
45
|
+
* Optional/null for records written before attribution existed. Never inferred from a shared
|
|
46
|
+
* blob's owner: identical bytes may have been uploaded by different principals. */
|
|
47
|
+
createdBy?: string | null | undefined;
|
|
44
48
|
title: string;
|
|
45
49
|
/**
|
|
46
50
|
* Human-memorable, unique-per-workspace lookup key (owner-directed, 2026-09-07) — an ADDITIONAL
|
|
@@ -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).
|
|
@@ -184,7 +200,9 @@ export interface UpdateMenuTreeOptional {
|
|
|
184
200
|
}
|
|
185
201
|
/**
|
|
186
202
|
* Replaces a menu's whole item tree, guarded by optimistic concurrency on the
|
|
187
|
-
* entry `version` (matches the host's `updatePost` pattern)
|
|
203
|
+
* entry `version` (matches the host's `updatePost` pattern): `expectedVersion` must equal the version
|
|
204
|
+
* read, and the repo's `save` compares it again atomically with the write; either miss throws
|
|
205
|
+
* `MenuVersionConflictError`.
|
|
188
206
|
*
|
|
189
207
|
* Id-stability note ("an update may not renumber surviving items"):
|
|
190
208
|
* this build enforces only that ids are present and unique within the
|
|
@@ -236,24 +254,18 @@ export interface AssignLocationOptional {
|
|
|
236
254
|
* default) and its `locations` field is updated to drop the key — recorded as
|
|
237
255
|
* its own revision-worthy write.
|
|
238
256
|
*
|
|
239
|
-
* Atomicity
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
* has real transactions available.
|
|
257
|
+
* Atomicity: these writes are ONE transaction (`repo.transaction`) — the displaced
|
|
258
|
+
* menu's save, this menu's save, the binding upsert and the outbox events commit
|
|
259
|
+
* together or not at all. Both menu saves are compare-and-set, so the second can
|
|
260
|
+
* lose to another editor AFTER the first landed: without the transaction, that
|
|
261
|
+
* left the displaced menu's `locations` without the key while the binding still
|
|
262
|
+
* pointed at it, and a binding rebuild then dropped the assignment. A
|
|
263
|
+
* `MenuVersionConflictError` (or any other throw) rolls the whole unit back in a
|
|
264
|
+
* store that can roll back; `InMemoryMenuRepo` cannot (see its `transaction`).
|
|
248
265
|
*
|
|
249
266
|
* @complexity O repo calls (bounded: at most two menu reads/writes plus one
|
|
250
267
|
* 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.
|
|
268
|
+
* @overallScore 100
|
|
257
269
|
*/
|
|
258
270
|
export declare function assignLocation(required: AssignLocationRequired, _optional?: AssignLocationOptional): Promise<{
|
|
259
271
|
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).
|
|
@@ -241,6 +258,12 @@ function validateTarget(target) {
|
|
|
241
258
|
if (!VALID_TARGET_KINDS.has(kind)) {
|
|
242
259
|
throw new MenuValidationError({ message: `unknown target kind '${kind}'` });
|
|
243
260
|
}
|
|
261
|
+
if (kind === "entryRef" && target.lastKnownHref !== undefined) {
|
|
262
|
+
const rawHref = target.lastKnownHref;
|
|
263
|
+
if (typeof rawHref !== "string" || !rawHref.trim() || !isAllowedHref({ rawHref })) {
|
|
264
|
+
throw new MenuValidationError({ message: `entryRef lastKnownHref must be ${ALLOWED_HREF_SHAPES_DESCRIPTION}.` });
|
|
265
|
+
}
|
|
266
|
+
}
|
|
244
267
|
if (kind === "url") {
|
|
245
268
|
const rawHref = target.href;
|
|
246
269
|
if (typeof rawHref !== "string" || !rawHref.trim()) {
|
|
@@ -306,7 +329,9 @@ export async function createMenu(required, optional = {}) {
|
|
|
306
329
|
}
|
|
307
330
|
/**
|
|
308
331
|
* Replaces a menu's whole item tree, guarded by optimistic concurrency on the
|
|
309
|
-
* entry `version` (matches the host's `updatePost` pattern)
|
|
332
|
+
* entry `version` (matches the host's `updatePost` pattern): `expectedVersion` must equal the version
|
|
333
|
+
* read, and the repo's `save` compares it again atomically with the write; either miss throws
|
|
334
|
+
* `MenuVersionConflictError`.
|
|
310
335
|
*
|
|
311
336
|
* Id-stability note ("an update may not renumber surviving items"):
|
|
312
337
|
* this build enforces only that ids are present and unique within the
|
|
@@ -330,8 +355,10 @@ export async function updateMenuTree(required, optional = {}) {
|
|
|
330
355
|
if (!existing)
|
|
331
356
|
throw new MenuNotFoundError({ message: `menu '${input.id}' was not found` });
|
|
332
357
|
assertEntityLive({ entityType: "menu", entityId: input.id, state: existing.status === "trash" ? "trashed" : "live" });
|
|
358
|
+
// The new record is built from this read, so a basis that differs from it must not reach the save:
|
|
359
|
+
// it could match a version another writer lands after this read and overwrite that writer.
|
|
333
360
|
if (existing.version !== input.expectedVersion) {
|
|
334
|
-
throw
|
|
361
|
+
throw menuVersionConflictError({ id: input.id, expectedVersion: input.expectedVersion, found: existing.version });
|
|
335
362
|
}
|
|
336
363
|
const title = (input.title ?? existing.title).trim();
|
|
337
364
|
const slug = (input.slug ?? existing.slug).trim().toLowerCase();
|
|
@@ -351,7 +378,9 @@ export async function updateMenuTree(required, optional = {}) {
|
|
|
351
378
|
updatedAt: kernelNowIso({ clock: deps.clock }),
|
|
352
379
|
version: existing.version + 1,
|
|
353
380
|
};
|
|
354
|
-
|
|
381
|
+
// The version check is ALSO the save's compare-and-set, not only the read above: a check before
|
|
382
|
+
// the write alone let two editors that read the same version both land (wm S4).
|
|
383
|
+
await deps.repo.save(menu, { expectedVersion: input.expectedVersion });
|
|
355
384
|
await deps.outbox.enqueue(buildEvent({
|
|
356
385
|
idGen: deps.idGen,
|
|
357
386
|
clock: deps.clock,
|
|
@@ -373,24 +402,18 @@ export async function updateMenuTree(required, optional = {}) {
|
|
|
373
402
|
* default) and its `locations` field is updated to drop the key — recorded as
|
|
374
403
|
* its own revision-worthy write.
|
|
375
404
|
*
|
|
376
|
-
* Atomicity
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
*
|
|
381
|
-
*
|
|
382
|
-
*
|
|
383
|
-
*
|
|
384
|
-
* has real transactions available.
|
|
405
|
+
* Atomicity: these writes are ONE transaction (`repo.transaction`) — the displaced
|
|
406
|
+
* menu's save, this menu's save, the binding upsert and the outbox events commit
|
|
407
|
+
* together or not at all. Both menu saves are compare-and-set, so the second can
|
|
408
|
+
* lose to another editor AFTER the first landed: without the transaction, that
|
|
409
|
+
* left the displaced menu's `locations` without the key while the binding still
|
|
410
|
+
* pointed at it, and a binding rebuild then dropped the assignment. A
|
|
411
|
+
* `MenuVersionConflictError` (or any other throw) rolls the whole unit back in a
|
|
412
|
+
* store that can roll back; `InMemoryMenuRepo` cannot (see its `transaction`).
|
|
385
413
|
*
|
|
386
414
|
* @complexity O repo calls (bounded: at most two menu reads/writes plus one
|
|
387
415
|
* binding upsert), independent of workspace size.
|
|
388
|
-
* @overallScore
|
|
389
|
-
* @findings Medium: no rollback across the two writes (menu save, then
|
|
390
|
-
* binding upsert) if the second fails, leaving the derived index stale until
|
|
391
|
-
* a rebuild. Acceptable because the index is declared rebuildable-by-definition;
|
|
392
|
-
* flagged as tech debt for when transactions
|
|
393
|
-
* exist.
|
|
416
|
+
* @overallScore 100
|
|
394
417
|
*/
|
|
395
418
|
export async function assignLocation(required, _optional = {}) {
|
|
396
419
|
const { deps, input } = required;
|
|
@@ -399,27 +422,6 @@ export async function assignLocation(required, _optional = {}) {
|
|
|
399
422
|
throw new MenuNotFoundError({ message: `menu '${input.menuId}' was not found` });
|
|
400
423
|
assertEntityLive({ entityType: "menu", entityId: input.menuId, state: menu.status === "trash" ? "trashed" : "live" });
|
|
401
424
|
const now = kernelNowIso({ clock: deps.clock });
|
|
402
|
-
const existingBinding = await deps.bindingRepo.findByLocation({
|
|
403
|
-
workspaceId: input.workspaceId,
|
|
404
|
-
locationKey: input.locationKey,
|
|
405
|
-
});
|
|
406
|
-
let displacedMenu = null;
|
|
407
|
-
if (existingBinding && existingBinding.menuId !== input.menuId) {
|
|
408
|
-
const displaced = await deps.repo.findById({
|
|
409
|
-
workspaceId: input.workspaceId,
|
|
410
|
-
id: existingBinding.menuId,
|
|
411
|
-
});
|
|
412
|
-
if (displaced) {
|
|
413
|
-
const updatedDisplaced = {
|
|
414
|
-
...displaced,
|
|
415
|
-
locations: displaced.locations.filter((key) => key !== input.locationKey),
|
|
416
|
-
updatedAt: now,
|
|
417
|
-
version: displaced.version + 1,
|
|
418
|
-
};
|
|
419
|
-
await deps.repo.save(updatedDisplaced);
|
|
420
|
-
displacedMenu = updatedDisplaced;
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
425
|
const updatedMenu = {
|
|
424
426
|
...menu,
|
|
425
427
|
locations: menu.locations.includes(input.locationKey)
|
|
@@ -428,32 +430,55 @@ export async function assignLocation(required, _optional = {}) {
|
|
|
428
430
|
updatedAt: now,
|
|
429
431
|
version: menu.version + 1,
|
|
430
432
|
};
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
433
|
+
return deps.repo.transaction({ fn: async () => {
|
|
434
|
+
const existingBinding = await deps.bindingRepo.findByLocation({
|
|
435
|
+
workspaceId: input.workspaceId,
|
|
436
|
+
locationKey: input.locationKey,
|
|
437
|
+
});
|
|
438
|
+
let displacedMenu = null;
|
|
439
|
+
if (existingBinding && existingBinding.menuId !== input.menuId) {
|
|
440
|
+
const displaced = await deps.repo.findById({
|
|
441
|
+
workspaceId: input.workspaceId,
|
|
442
|
+
id: existingBinding.menuId,
|
|
443
|
+
});
|
|
444
|
+
if (displaced) {
|
|
445
|
+
const updatedDisplaced = {
|
|
446
|
+
...displaced,
|
|
447
|
+
locations: displaced.locations.filter((key) => key !== input.locationKey),
|
|
448
|
+
updatedAt: now,
|
|
449
|
+
version: displaced.version + 1,
|
|
450
|
+
};
|
|
451
|
+
await deps.repo.save(updatedDisplaced, { expectedVersion: displaced.version });
|
|
452
|
+
displacedMenu = updatedDisplaced;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
await deps.repo.save(updatedMenu, { expectedVersion: menu.version });
|
|
456
|
+
const binding = await deps.bindingRepo.upsert({
|
|
457
|
+
workspaceId: input.workspaceId,
|
|
458
|
+
locationKey: input.locationKey,
|
|
459
|
+
menuId: input.menuId,
|
|
460
|
+
boundAt: now,
|
|
461
|
+
});
|
|
462
|
+
if (displacedMenu) {
|
|
463
|
+
await deps.outbox.enqueue(buildEvent({
|
|
464
|
+
idGen: deps.idGen,
|
|
465
|
+
clock: deps.clock,
|
|
466
|
+
name: "navigation.location.unassigned",
|
|
467
|
+
workspaceId: input.workspaceId,
|
|
468
|
+
aggregateId: displacedMenu.id,
|
|
469
|
+
payload: { locationKey: input.locationKey, menuId: displacedMenu.id },
|
|
470
|
+
}));
|
|
471
|
+
}
|
|
472
|
+
await deps.outbox.enqueue(buildEvent({
|
|
473
|
+
idGen: deps.idGen,
|
|
474
|
+
clock: deps.clock,
|
|
475
|
+
name: "navigation.location.assigned",
|
|
476
|
+
workspaceId: input.workspaceId,
|
|
477
|
+
aggregateId: input.menuId,
|
|
478
|
+
payload: { locationKey: input.locationKey, menuId: input.menuId },
|
|
479
|
+
}));
|
|
480
|
+
return { menu: updatedMenu, binding, displacedMenu };
|
|
481
|
+
} });
|
|
457
482
|
}
|
|
458
483
|
/**
|
|
459
484
|
* 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
|
}
|
|
@@ -49,6 +49,11 @@ export type ReservedNavTargetKind = "dynamicQuery" | "content";
|
|
|
49
49
|
/** A link to another content entry (page/post/media/…). Integrity-tracked. */
|
|
50
50
|
export interface NavEntryTarget {
|
|
51
51
|
readonly kind: "entryRef";
|
|
52
|
+
/** Optional content-type hint for editors; reference integrity and routing still use entryId. */
|
|
53
|
+
readonly entryType?: string;
|
|
54
|
+
/** Author's last known URL, retained for exports and routing misses. A known unavailable
|
|
55
|
+
* entry must remain hidden; this snapshot never overrides an availability decision. */
|
|
56
|
+
readonly lastKnownHref?: string;
|
|
52
57
|
/** Target entry id. Extracted to `entry_refs` at the write chokepoint. */
|
|
53
58
|
readonly entryId: UUID;
|
|
54
59
|
}
|
|
@@ -352,12 +352,14 @@ export interface UnassignTermsRequired {
|
|
|
352
352
|
* authorize -> validateAssignmentTarget -> write -> stamp -> outbox chokepoint as `assignTerms`,
|
|
353
353
|
* and — like `assignTerms` — never produces a `taxonomy_revisions` row ( disclosed
|
|
354
354
|
* narrowing applies equally to removal). Idempotent: unassigning a row that isn't there is a no-op,
|
|
355
|
-
* not an error
|
|
355
|
+
* not an error. Only removed ids are returned, stamped, and emitted; an entirely absent set is a no-op. Chosen over
|
|
356
356
|
* a "set terms" replace call — see the taxonomy plan's "What the docs settle" #3 for why replace
|
|
357
357
|
* risks silently erasing a concurrent assignment.
|
|
358
358
|
* See docs/decisions/DR-005-ordered-taxonomy-validation.md.
|
|
359
359
|
*/
|
|
360
|
-
export declare function unassignTerms(required: UnassignTermsRequired, _optional?: Record<string, never>): Promise<
|
|
360
|
+
export declare function unassignTerms(required: UnassignTermsRequired, _optional?: Record<string, never>): Promise<{
|
|
361
|
+
removedTermIds: string[];
|
|
362
|
+
}>;
|
|
361
363
|
export interface EntryTermsCleanupPort {
|
|
362
364
|
deleteByContent(params: {
|
|
363
365
|
workspaceId: string;
|