@jini-ai/cms 0.3.8 → 0.3.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/content-types/errors.d.ts +41 -12
- package/dist/content-types/errors.js +47 -12
- package/dist/content-types/index.d.ts +1 -1
- package/dist/content-types/index.js +1 -1
- package/dist/content-types/write-service.d.ts +9 -0
- package/dist/content-types/write-service.js +45 -17
- package/dist/entries/index.d.ts +2 -2
- package/dist/entries/index.js +1 -1
- package/dist/entries/write-service.d.ts +58 -1
- package/dist/entries/write-service.js +87 -0
- package/dist/media/media-service.js +2 -0
- package/dist/navigation/menu-service.d.ts +0 -24
- package/dist/navigation/menu-service.js +27 -0
- package/dist/navigation/tool-registrations.d.ts +0 -11
- package/dist/navigation/tool-registrations.js +15 -0
- package/dist/taxonomy/content-lookup.d.ts +6 -0
- package/dist/taxonomy/content-lookup.js +11 -1
- package/dist/taxonomy/index.d.ts +1 -1
- package/dist/taxonomy/index.js +1 -1
- package/dist/taxonomy/repo.memory.d.ts +8 -0
- package/dist/taxonomy/repo.memory.js +17 -0
- package/dist/taxonomy/write-service.d.ts +85 -1
- package/dist/taxonomy/write-service.js +166 -1
- package/package.json +1 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ToolInputError } from "@jini-ai/core";
|
|
1
2
|
/**
|
|
2
3
|
* @file Typed error surface for the `content-types` package.
|
|
3
4
|
*
|
|
@@ -10,26 +11,41 @@
|
|
|
10
11
|
* the constructor sets `this.name` explicitly (confirmed empirically in Session 2 of this
|
|
11
12
|
* workstream) — every class below sets it.
|
|
12
13
|
*
|
|
14
|
+
* Every class the five wired agent tools (`define`/`update_fields`/`deprecate`/`reactivate`/
|
|
15
|
+
* `tombstone`) can actually throw extends `ToolInputError` (`@jini-ai/core`), not plain `Error` —
|
|
16
|
+
* mirroring `core/entity-liveness.ts`'s `EntityNotLiveError` (see its file header for the full
|
|
17
|
+
* rationale). Without that, `write-service.ts`/`lifecycle.ts` return these via a `Result` that
|
|
18
|
+
* `tool-registrations.ts`'s `fromResult` re-throws unwrapped, `@jini-ai/daemon`'s `ToolExecutor`
|
|
19
|
+
* sees a plain `Error` it cannot distinguish from a genuine internal fault, and every HTTP-facing
|
|
20
|
+
* mapping of that failure (`@jini-ai/http-kit`'s `delegated-tools.ts`) redacts it to a 500
|
|
21
|
+
* `INTERNAL_ERROR` — so a model calling `collections_content_type_define` on a key that already
|
|
22
|
+
* exists never sees "use collections_content_type_update_fields", only an opaque failure it cannot
|
|
23
|
+
* act on. `CleanupNotEligibleError` is the one exception: `collections_plan_cleanup`/
|
|
24
|
+
* `collections_execute_cleanup` are declared but not yet wired to any handler
|
|
25
|
+
* (`tool-registrations.ts`'s `UNWIRED_CONTENT_TYPES_TOOL_IDS`), so nothing reaches a model through
|
|
26
|
+
* it today — left as plain `Error` until that wiring exists and it can be verified the same way.
|
|
27
|
+
*
|
|
13
28
|
* Architectural role:
|
|
14
|
-
* `features/content-types` domain logic.
|
|
29
|
+
* `features/content-types` domain logic. Depends on `@jini-ai/core` only for the `ToolInputError`
|
|
30
|
+
* marker above.
|
|
15
31
|
*/
|
|
16
|
-
export declare class ForbiddenError extends
|
|
32
|
+
export declare class ForbiddenError extends ToolInputError {
|
|
17
33
|
constructor(message: string);
|
|
18
34
|
}
|
|
19
35
|
/** CIC U-002-B1 guard 1 — `key`/field-name grammar gate `^[a-z][a-z0-9_]{0,63}$` (U-001-B2). */
|
|
20
|
-
export declare class InvalidKeyGrammarError extends
|
|
36
|
+
export declare class InvalidKeyGrammarError extends ToolInputError {
|
|
21
37
|
constructor(message: string);
|
|
22
38
|
}
|
|
23
39
|
/** CIC U-002-B1 guard 2 — `key` is one of the permanently reserved legacy `posts` keys. */
|
|
24
|
-
export declare class ReservedContentTypeKeyError extends
|
|
40
|
+
export declare class ReservedContentTypeKeyError extends ToolInputError {
|
|
25
41
|
constructor(message: string);
|
|
26
42
|
}
|
|
27
43
|
/** CIC U-002-B1 guard 3 — a field name fails the identifier grammar gate. */
|
|
28
|
-
export declare class InvalidFieldNameGrammarError extends
|
|
44
|
+
export declare class InvalidFieldNameGrammarError extends ToolInputError {
|
|
29
45
|
constructor(message: string);
|
|
30
46
|
}
|
|
31
47
|
/** CIC U-001-B1 / U-002-B1 guard 4 — a field `kind` is not one of the closed field-kind enum. */
|
|
32
|
-
export declare class InvalidFieldKindError extends
|
|
48
|
+
export declare class InvalidFieldKindError extends ToolInputError {
|
|
33
49
|
constructor(message: string);
|
|
34
50
|
}
|
|
35
51
|
/**
|
|
@@ -64,7 +80,7 @@ export declare class StorageOnlyFieldNotQueryableError extends InvalidFieldKindE
|
|
|
64
80
|
* `violation.received` names the offending value's TYPE, never the value — a field payload can
|
|
65
81
|
* carry operator content, and this message reaches both an HTTP client and a model.
|
|
66
82
|
*/
|
|
67
|
-
export declare class InvalidFieldShapeError extends
|
|
83
|
+
export declare class InvalidFieldShapeError extends ToolInputError {
|
|
68
84
|
readonly code: "VALIDATION_ERROR";
|
|
69
85
|
readonly violation: {
|
|
70
86
|
path: string;
|
|
@@ -78,18 +94,31 @@ export declare class InvalidFieldShapeError extends Error {
|
|
|
78
94
|
});
|
|
79
95
|
}
|
|
80
96
|
/** CIC U-002-B1 guard 5 — more than the per-type cap of `queryable` fields were submitted. */
|
|
81
|
-
export declare class QueryableFieldCapExceededError extends
|
|
97
|
+
export declare class QueryableFieldCapExceededError extends ToolInputError {
|
|
82
98
|
constructor(message: string);
|
|
83
99
|
}
|
|
84
100
|
/** CIC U-004-B1 — `expectedVersion` did not match the current row's version (OCC conflict). */
|
|
85
|
-
export declare class VersionConflictError extends
|
|
101
|
+
export declare class VersionConflictError extends ToolInputError {
|
|
86
102
|
constructor(message: string);
|
|
87
103
|
}
|
|
88
|
-
export declare class ContentTypeNotFoundError extends
|
|
104
|
+
export declare class ContentTypeNotFoundError extends ToolInputError {
|
|
89
105
|
constructor(message: string);
|
|
90
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* Row 7 (web-high fix plan, 2026-09-24) — `registerContentType` loaded no existing row before
|
|
109
|
+
* saving, so two concurrent first-widget creates (or a caller retrying an existing key) could
|
|
110
|
+
* silently overwrite the current fields, and registering a tombstoned key could resurrect it
|
|
111
|
+
* (INV-06 forbids any transition out of tombstone, including via a fresh register). Thrown when
|
|
112
|
+
* `deps.repo.findByKey` already returns a row for the key, whether that row is active/deprecated
|
|
113
|
+
* or tombstoned — `tombstoned` distinguishes the two so a caller/HTTP route can give the right
|
|
114
|
+
* guidance (retry with update-fields, vs. the key is permanently gone).
|
|
115
|
+
*/
|
|
116
|
+
export declare class ContentTypeAlreadyExistsError extends ToolInputError {
|
|
117
|
+
readonly tombstoned: boolean;
|
|
118
|
+
constructor(key: string, tombstoned: boolean);
|
|
119
|
+
}
|
|
91
120
|
/** A generic validation rejection carrying a stable machine-readable `details.reason`. */
|
|
92
|
-
export declare class ValidationError extends
|
|
121
|
+
export declare class ValidationError extends ToolInputError {
|
|
93
122
|
readonly code: "VALIDATION_ERROR";
|
|
94
123
|
readonly details: {
|
|
95
124
|
reason: string;
|
|
@@ -97,7 +126,7 @@ export declare class ValidationError extends Error {
|
|
|
97
126
|
constructor(message: string, reason: string);
|
|
98
127
|
}
|
|
99
128
|
/** REQ-09..12 lifecycle state-machine guard rejection (INV-06 terminal-tombstone, EC-09 deprecate-first). */
|
|
100
|
-
export declare class ContentTypeLifecycleError extends
|
|
129
|
+
export declare class ContentTypeLifecycleError extends ToolInputError {
|
|
101
130
|
constructor(message: string);
|
|
102
131
|
}
|
|
103
132
|
/** REQ-20 `planCleanup` eligibility-gate rejection; `reason` is one of a closed, ordered set. */
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ToolInputError } from "@jini-ai/core";
|
|
1
2
|
/**
|
|
2
3
|
* @file Typed error surface for the `content-types` package.
|
|
3
4
|
*
|
|
@@ -10,38 +11,53 @@
|
|
|
10
11
|
* the constructor sets `this.name` explicitly (confirmed empirically in Session 2 of this
|
|
11
12
|
* workstream) — every class below sets it.
|
|
12
13
|
*
|
|
14
|
+
* Every class the five wired agent tools (`define`/`update_fields`/`deprecate`/`reactivate`/
|
|
15
|
+
* `tombstone`) can actually throw extends `ToolInputError` (`@jini-ai/core`), not plain `Error` —
|
|
16
|
+
* mirroring `core/entity-liveness.ts`'s `EntityNotLiveError` (see its file header for the full
|
|
17
|
+
* rationale). Without that, `write-service.ts`/`lifecycle.ts` return these via a `Result` that
|
|
18
|
+
* `tool-registrations.ts`'s `fromResult` re-throws unwrapped, `@jini-ai/daemon`'s `ToolExecutor`
|
|
19
|
+
* sees a plain `Error` it cannot distinguish from a genuine internal fault, and every HTTP-facing
|
|
20
|
+
* mapping of that failure (`@jini-ai/http-kit`'s `delegated-tools.ts`) redacts it to a 500
|
|
21
|
+
* `INTERNAL_ERROR` — so a model calling `collections_content_type_define` on a key that already
|
|
22
|
+
* exists never sees "use collections_content_type_update_fields", only an opaque failure it cannot
|
|
23
|
+
* act on. `CleanupNotEligibleError` is the one exception: `collections_plan_cleanup`/
|
|
24
|
+
* `collections_execute_cleanup` are declared but not yet wired to any handler
|
|
25
|
+
* (`tool-registrations.ts`'s `UNWIRED_CONTENT_TYPES_TOOL_IDS`), so nothing reaches a model through
|
|
26
|
+
* it today — left as plain `Error` until that wiring exists and it can be verified the same way.
|
|
27
|
+
*
|
|
13
28
|
* Architectural role:
|
|
14
|
-
* `features/content-types` domain logic.
|
|
29
|
+
* `features/content-types` domain logic. Depends on `@jini-ai/core` only for the `ToolInputError`
|
|
30
|
+
* marker above.
|
|
15
31
|
*/
|
|
16
|
-
export class ForbiddenError extends
|
|
32
|
+
export class ForbiddenError extends ToolInputError {
|
|
17
33
|
constructor(message) {
|
|
18
34
|
super(message);
|
|
19
35
|
this.name = "ForbiddenError";
|
|
20
36
|
}
|
|
21
37
|
}
|
|
22
38
|
/** CIC U-002-B1 guard 1 — `key`/field-name grammar gate `^[a-z][a-z0-9_]{0,63}$` (U-001-B2). */
|
|
23
|
-
export class InvalidKeyGrammarError extends
|
|
39
|
+
export class InvalidKeyGrammarError extends ToolInputError {
|
|
24
40
|
constructor(message) {
|
|
25
41
|
super(message);
|
|
26
42
|
this.name = "InvalidKeyGrammarError";
|
|
27
43
|
}
|
|
28
44
|
}
|
|
29
45
|
/** CIC U-002-B1 guard 2 — `key` is one of the permanently reserved legacy `posts` keys. */
|
|
30
|
-
export class ReservedContentTypeKeyError extends
|
|
46
|
+
export class ReservedContentTypeKeyError extends ToolInputError {
|
|
31
47
|
constructor(message) {
|
|
32
48
|
super(message);
|
|
33
49
|
this.name = "ReservedContentTypeKeyError";
|
|
34
50
|
}
|
|
35
51
|
}
|
|
36
52
|
/** CIC U-002-B1 guard 3 — a field name fails the identifier grammar gate. */
|
|
37
|
-
export class InvalidFieldNameGrammarError extends
|
|
53
|
+
export class InvalidFieldNameGrammarError extends ToolInputError {
|
|
38
54
|
constructor(message) {
|
|
39
55
|
super(message);
|
|
40
56
|
this.name = "InvalidFieldNameGrammarError";
|
|
41
57
|
}
|
|
42
58
|
}
|
|
43
59
|
/** CIC U-001-B1 / U-002-B1 guard 4 — a field `kind` is not one of the closed field-kind enum. */
|
|
44
|
-
export class InvalidFieldKindError extends
|
|
60
|
+
export class InvalidFieldKindError extends ToolInputError {
|
|
45
61
|
constructor(message) {
|
|
46
62
|
super(message);
|
|
47
63
|
this.name = "InvalidFieldKindError";
|
|
@@ -82,7 +98,7 @@ export class StorageOnlyFieldNotQueryableError extends InvalidFieldKindError {
|
|
|
82
98
|
* `violation.received` names the offending value's TYPE, never the value — a field payload can
|
|
83
99
|
* carry operator content, and this message reaches both an HTTP client and a model.
|
|
84
100
|
*/
|
|
85
|
-
export class InvalidFieldShapeError extends
|
|
101
|
+
export class InvalidFieldShapeError extends ToolInputError {
|
|
86
102
|
code = "VALIDATION_ERROR";
|
|
87
103
|
violation;
|
|
88
104
|
constructor(violation) {
|
|
@@ -92,27 +108,46 @@ export class InvalidFieldShapeError extends Error {
|
|
|
92
108
|
}
|
|
93
109
|
}
|
|
94
110
|
/** CIC U-002-B1 guard 5 — more than the per-type cap of `queryable` fields were submitted. */
|
|
95
|
-
export class QueryableFieldCapExceededError extends
|
|
111
|
+
export class QueryableFieldCapExceededError extends ToolInputError {
|
|
96
112
|
constructor(message) {
|
|
97
113
|
super(message);
|
|
98
114
|
this.name = "QueryableFieldCapExceededError";
|
|
99
115
|
}
|
|
100
116
|
}
|
|
101
117
|
/** CIC U-004-B1 — `expectedVersion` did not match the current row's version (OCC conflict). */
|
|
102
|
-
export class VersionConflictError extends
|
|
118
|
+
export class VersionConflictError extends ToolInputError {
|
|
103
119
|
constructor(message) {
|
|
104
120
|
super(message);
|
|
105
121
|
this.name = "VersionConflictError";
|
|
106
122
|
}
|
|
107
123
|
}
|
|
108
|
-
export class ContentTypeNotFoundError extends
|
|
124
|
+
export class ContentTypeNotFoundError extends ToolInputError {
|
|
109
125
|
constructor(message) {
|
|
110
126
|
super(message);
|
|
111
127
|
this.name = "CONTENT_TYPE_NOT_FOUND";
|
|
112
128
|
}
|
|
113
129
|
}
|
|
130
|
+
/**
|
|
131
|
+
* Row 7 (web-high fix plan, 2026-09-24) — `registerContentType` loaded no existing row before
|
|
132
|
+
* saving, so two concurrent first-widget creates (or a caller retrying an existing key) could
|
|
133
|
+
* silently overwrite the current fields, and registering a tombstoned key could resurrect it
|
|
134
|
+
* (INV-06 forbids any transition out of tombstone, including via a fresh register). Thrown when
|
|
135
|
+
* `deps.repo.findByKey` already returns a row for the key, whether that row is active/deprecated
|
|
136
|
+
* or tombstoned — `tombstoned` distinguishes the two so a caller/HTTP route can give the right
|
|
137
|
+
* guidance (retry with update-fields, vs. the key is permanently gone).
|
|
138
|
+
*/
|
|
139
|
+
export class ContentTypeAlreadyExistsError extends ToolInputError {
|
|
140
|
+
tombstoned;
|
|
141
|
+
constructor(key, tombstoned) {
|
|
142
|
+
super(tombstoned
|
|
143
|
+
? `content type '${key}' was permanently deleted; its key can't be reused (INV-06)`
|
|
144
|
+
: `content type '${key}' already exists; use collections_content_type_update_fields to change its fields`);
|
|
145
|
+
this.name = "ContentTypeAlreadyExistsError";
|
|
146
|
+
this.tombstoned = tombstoned;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
114
149
|
/** A generic validation rejection carrying a stable machine-readable `details.reason`. */
|
|
115
|
-
export class ValidationError extends
|
|
150
|
+
export class ValidationError extends ToolInputError {
|
|
116
151
|
code = "VALIDATION_ERROR";
|
|
117
152
|
details;
|
|
118
153
|
constructor(message, reason) {
|
|
@@ -122,7 +157,7 @@ export class ValidationError extends Error {
|
|
|
122
157
|
}
|
|
123
158
|
}
|
|
124
159
|
/** REQ-09..12 lifecycle state-machine guard rejection (INV-06 terminal-tombstone, EC-09 deprecate-first). */
|
|
125
|
-
export class ContentTypeLifecycleError extends
|
|
160
|
+
export class ContentTypeLifecycleError extends ToolInputError {
|
|
126
161
|
constructor(message) {
|
|
127
162
|
super(message);
|
|
128
163
|
this.name = "ContentTypeLifecycleError";
|
|
@@ -30,7 +30,7 @@ export { ForbiddenError, InvalidKeyGrammarError, ReservedContentTypeKeyError, In
|
|
|
30
30
|
* carries the parent maps this to the same `400 VALIDATION_ERROR` with no change. Catch it by
|
|
31
31
|
* name only if the finer distinction matters.
|
|
32
32
|
*/
|
|
33
|
-
StorageOnlyFieldNotQueryableError, InvalidFieldShapeError, QueryableFieldCapExceededError, VersionConflictError, ContentTypeNotFoundError, ValidationError, ContentTypeLifecycleError, CleanupNotEligibleError, } from "./errors.js";
|
|
33
|
+
StorageOnlyFieldNotQueryableError, InvalidFieldShapeError, QueryableFieldCapExceededError, VersionConflictError, ContentTypeNotFoundError, ContentTypeAlreadyExistsError, ValidationError, ContentTypeLifecycleError, CleanupNotEligibleError, } from "./errors.js";
|
|
34
34
|
/**
|
|
35
35
|
* The queryable-field index machinery. `IDENTIFIER_GRAMMAR_PATTERN` is exported because it is the
|
|
36
36
|
* single definition of the identifier grammar — the published agent-tool schemas reference it
|
|
@@ -20,7 +20,7 @@ export { ForbiddenError, InvalidKeyGrammarError, ReservedContentTypeKeyError, In
|
|
|
20
20
|
* carries the parent maps this to the same `400 VALIDATION_ERROR` with no change. Catch it by
|
|
21
21
|
* name only if the finer distinction matters.
|
|
22
22
|
*/
|
|
23
|
-
StorageOnlyFieldNotQueryableError, InvalidFieldShapeError, QueryableFieldCapExceededError, VersionConflictError, ContentTypeNotFoundError, ValidationError, ContentTypeLifecycleError, CleanupNotEligibleError, } from "./errors.js";
|
|
23
|
+
StorageOnlyFieldNotQueryableError, InvalidFieldShapeError, QueryableFieldCapExceededError, VersionConflictError, ContentTypeNotFoundError, ContentTypeAlreadyExistsError, ValidationError, ContentTypeLifecycleError, CleanupNotEligibleError, } from "./errors.js";
|
|
24
24
|
export { IDENTIFIER_GRAMMAR_PATTERN, validateIdentifierGrammar, mapFieldKindToCast, buildQueryableFieldIndexName, resolveFieldIndexTransition, } from "./index-provisioning.js";
|
|
25
25
|
export { registerContentType, updateContentTypeFields } from "./write-service.js";
|
|
26
26
|
export { deprecateContentType, reactivateContentType, tombstoneContentType, } from "./lifecycle.js";
|
|
@@ -140,6 +140,15 @@ export interface UpdateContentTypeFieldsRequired {
|
|
|
140
140
|
key: string;
|
|
141
141
|
fields: ContentTypeFieldDef[];
|
|
142
142
|
expectedVersion: number;
|
|
143
|
+
/**
|
|
144
|
+
* J1 (plan-publish-all-types-2026-09-25.md §0.2) — optional and additive. Before this, no
|
|
145
|
+
* write path could ever change `label` after `registerContentType`, so a publish that carried
|
|
146
|
+
* an imported `label` change hashed it (the planned publish factory's `transferred` field) but
|
|
147
|
+
* had nothing to call that would apply it — a permanent false "still changed" on every
|
|
148
|
+
* subsequent plan. Omitted, the current label is kept unchanged, exactly as before this field
|
|
149
|
+
* existed.
|
|
150
|
+
*/
|
|
151
|
+
label?: string | undefined;
|
|
143
152
|
};
|
|
144
153
|
}
|
|
145
154
|
/**
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { EntityNotLiveError } from "../core/entity-liveness.js";
|
|
2
|
+
import { ContentTypeAlreadyExistsError, ContentTypeNotFoundError, ForbiddenError, InvalidFieldKindError, InvalidFieldNameGrammarError, InvalidKeyGrammarError, QueryableFieldCapExceededError, ReservedContentTypeKeyError, StorageOnlyFieldNotQueryableError, ValidationError, VersionConflictError, } from "./errors.js";
|
|
2
3
|
import { resolveFieldIndexTransition, validateIdentifierGrammar } from "./index-provisioning.js";
|
|
3
4
|
import { isContentTypeFieldKind, isIndexableFieldKind, } from "./types.js";
|
|
4
5
|
/** Reserved forever for the legacy `posts` table — an operator Collection can never take these keys. */
|
|
@@ -84,6 +85,12 @@ export async function registerContentType(required) {
|
|
|
84
85
|
if (countQueryableFields(input.fields) > QUERYABLE_FIELD_CAP) {
|
|
85
86
|
return { ok: false, error: new QueryableFieldCapExceededError(`content type '${input.key}' submits more than ${QUERYABLE_FIELD_CAP} queryable fields`) };
|
|
86
87
|
}
|
|
88
|
+
// Row 7 — a pre-check outside the transaction. Not race-safe by itself (that's the in-tx check
|
|
89
|
+
// below), but it avoids opening a transaction for the overwhelmingly common not-found case.
|
|
90
|
+
const existingBeforeTx = await deps.repo.findByKey({ workspaceId: input.workspaceId, key: input.key });
|
|
91
|
+
if (existingBeforeTx) {
|
|
92
|
+
return { ok: false, error: new ContentTypeAlreadyExistsError(input.key, existingBeforeTx.status === "tombstone") };
|
|
93
|
+
}
|
|
87
94
|
const now = deps.clock.nowIso();
|
|
88
95
|
const contentType = {
|
|
89
96
|
workspaceId: input.workspaceId,
|
|
@@ -94,22 +101,37 @@ export async function registerContentType(required) {
|
|
|
94
101
|
version: 1,
|
|
95
102
|
tombstonedAt: null,
|
|
96
103
|
};
|
|
97
|
-
|
|
98
|
-
await deps.repo.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
workspaceId: input.workspaceId,
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
104
|
+
try {
|
|
105
|
+
await deps.repo.transaction(async () => {
|
|
106
|
+
// The check that actually closes the race: two concurrent first-widget creates both see the
|
|
107
|
+
// pre-check above return null, but only one of them can win this in-tx re-check before save.
|
|
108
|
+
const existingInTx = await deps.repo.findByKey({ workspaceId: input.workspaceId, key: input.key });
|
|
109
|
+
if (existingInTx) {
|
|
110
|
+
throw new ContentTypeAlreadyExistsError(input.key, existingInTx.status === "tombstone");
|
|
111
|
+
}
|
|
112
|
+
await deps.repo.save(contentType);
|
|
113
|
+
await deps.repo.appendRevision({
|
|
114
|
+
contentTypeKey: input.key,
|
|
115
|
+
workspaceId: input.workspaceId,
|
|
116
|
+
op: "register",
|
|
117
|
+
stateJson: contentType,
|
|
118
|
+
actorId: input.actorId,
|
|
119
|
+
principalKind: input.principalKind ?? null,
|
|
120
|
+
delegatedByWorkspaceId: input.delegatedByWorkspaceId ?? null,
|
|
121
|
+
delegatedById: input.delegatedById ?? null,
|
|
122
|
+
recordedAt: now,
|
|
123
|
+
});
|
|
124
|
+
if (deps.watermark)
|
|
125
|
+
await deps.watermark.stampWatermark({ workspaceId: input.workspaceId });
|
|
109
126
|
});
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
if (err instanceof ContentTypeAlreadyExistsError)
|
|
130
|
+
return { ok: false, error: err };
|
|
131
|
+
throw err;
|
|
132
|
+
}
|
|
133
|
+
// Index provisioning runs only once the transaction has committed a new row — never on the
|
|
134
|
+
// already-exists path above.
|
|
113
135
|
await deps.indexProvisioner.provisionIndexesForNewContentType({
|
|
114
136
|
workspaceId: input.workspaceId,
|
|
115
137
|
contentTypeKey: input.key,
|
|
@@ -140,6 +162,12 @@ export async function updateContentTypeFields(required) {
|
|
|
140
162
|
if (!current) {
|
|
141
163
|
return { ok: false, error: new ContentTypeNotFoundError(`content type '${input.key}' was not found in workspace '${input.workspaceId}'`) };
|
|
142
164
|
}
|
|
165
|
+
// Row 15's sibling — a tombstoned type is terminal (INV-06); this check runs before the version
|
|
166
|
+
// check so a stale-version update against a tombstoned row still reports "tombstoned", not
|
|
167
|
+
// "version conflict" (the tombstone tore down every queryable index, so the schema is moot).
|
|
168
|
+
if (current.status === "tombstone") {
|
|
169
|
+
return { ok: false, error: new EntityNotLiveError("content type", input.key, "tombstoned") };
|
|
170
|
+
}
|
|
143
171
|
// U-004-B1: expectedVersion checked FIRST — before fields_empty, before any per-field guard.
|
|
144
172
|
if (input.expectedVersion !== current.version) {
|
|
145
173
|
return { ok: false, error: new VersionConflictError(`expected version ${input.expectedVersion} for content type '${input.key}', found ${current.version}`) };
|
|
@@ -173,7 +201,7 @@ export async function updateContentTypeFields(required) {
|
|
|
173
201
|
const before = current.fields;
|
|
174
202
|
const after = input.fields;
|
|
175
203
|
const now = deps.clock.nowIso();
|
|
176
|
-
const updated = { ...current, fields: after, version: current.version + 1 };
|
|
204
|
+
const updated = { ...current, fields: after, label: input.label ?? current.label, version: current.version + 1 };
|
|
177
205
|
await deps.repo.transaction(async () => {
|
|
178
206
|
await deps.repo.save(updated);
|
|
179
207
|
await deps.repo.appendRevision({
|
package/dist/entries/index.d.ts
CHANGED
|
@@ -28,8 +28,8 @@ export { validateFieldsAgainstSchema, selectVisibleEntryFields } from "./field-v
|
|
|
28
28
|
export { ForbiddenError, EntryNotFoundError, ContentTypeNotFoundError, ContentTypeNotActiveError, EntrySlugConflictError, VersionConflictError, EntryFieldValidationError, } 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, } from "./write-service.js";
|
|
32
|
-
export { createEntry, updateEntry, publishEntry, unpublishEntry } from "./write-service.js";
|
|
31
|
+
export type { AuthorizeFn, EntryRevisionInput, EntryRepoPort, ContentTypeLookupPort, OutboxPort, WatermarkPort, CreateEntryRequired, UpdateEntryRequired, PublishUnpublishEntryRequired, ImportEntryRequired, } from "./write-service.js";
|
|
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
|
|
35
35
|
* concrete adapter implements the port directly, and the in-memory implementation is the reference
|
package/dist/entries/index.js
CHANGED
|
@@ -12,7 +12,7 @@ export { validateFieldsAgainstSchema, selectVisibleEntryFields } from "./field-v
|
|
|
12
12
|
*/
|
|
13
13
|
export { ForbiddenError, EntryNotFoundError, ContentTypeNotFoundError, ContentTypeNotActiveError, EntrySlugConflictError, VersionConflictError, EntryFieldValidationError, } from "./errors.js";
|
|
14
14
|
export { ENTRY_LIFECYCLE_OP_NAMES, ENTRY_LIFECYCLE_OPS, parseEntryLifecycleOp, } from "./lifecycle-dispatch.js";
|
|
15
|
-
export { createEntry, updateEntry, publishEntry, unpublishEntry } from "./write-service.js";
|
|
15
|
+
export { createEntry, updateEntry, publishEntry, unpublishEntry, importEntry } from "./write-service.js";
|
|
16
16
|
/**
|
|
17
17
|
* The in-memory repository. Exported for the same reason `navigation` exports its own: a host's
|
|
18
18
|
* concrete adapter implements the port directly, and the in-memory implementation is the reference
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ClockPort } from "../core/ports.js";
|
|
2
|
-
import type { ActorIdentityInput, EntryRecord, OwningContentType, Result } from "./types.js";
|
|
2
|
+
import type { ActorIdentityInput, EntryRecord, EntryStatus, OwningContentType, Result } from "./types.js";
|
|
3
3
|
/**
|
|
4
4
|
* @file The `entries` write chokepoint: `createEntry`,
|
|
5
5
|
* `updateEntry`, `publishEntry`, `unpublishEntry`.
|
|
@@ -184,6 +184,63 @@ export interface UpdateEntryRequired {
|
|
|
184
184
|
export declare function updateEntry(required: UpdateEntryRequired): Promise<Result<{
|
|
185
185
|
entry: EntryRecord;
|
|
186
186
|
}, Error>>;
|
|
187
|
+
export interface ImportEntryRequired {
|
|
188
|
+
deps: {
|
|
189
|
+
entryRepo: EntryRepoPort;
|
|
190
|
+
contentTypeRepo: ContentTypeLookupPort;
|
|
191
|
+
clock: ClockPort;
|
|
192
|
+
authorize: AuthorizeFn;
|
|
193
|
+
outbox: OutboxPort;
|
|
194
|
+
watermark?: WatermarkPort;
|
|
195
|
+
onWritten?: (entry: EntryRecord) => Promise<void>;
|
|
196
|
+
};
|
|
197
|
+
input: ActorIdentityInput & {
|
|
198
|
+
workspaceId: string;
|
|
199
|
+
/** The source's own id, preserved verbatim — unlike `createEntry`, which always mints a fresh
|
|
200
|
+
* one. Anything referencing this entry by id (widgets, term assignments) needs it to survive
|
|
201
|
+
* a publish round trip unchanged (plan-publish-all-types-2026-09-25.md §0.3 "Identity"). */
|
|
202
|
+
id: string;
|
|
203
|
+
type: string;
|
|
204
|
+
slug: string;
|
|
205
|
+
title: string;
|
|
206
|
+
/** Caller-given, unlike `createEntry`'s forced `"draft"` — an import carries the source's real
|
|
207
|
+
* status across. */
|
|
208
|
+
status: EntryStatus;
|
|
209
|
+
fieldsJson: unknown;
|
|
210
|
+
bodyJson?: unknown;
|
|
211
|
+
/** Caller-given, unlike `createEntry`'s forced `null`. */
|
|
212
|
+
publishedAt: string | null;
|
|
213
|
+
/**
|
|
214
|
+
* The publish factory's three-way CAS contract: `undefined` means "no row with this id may
|
|
215
|
+
* already exist" (an import-as-create); a number means "a row with this id must exist and be
|
|
216
|
+
* at exactly this version" (an import-as-update). Both cases end in `VersionConflictError` on
|
|
217
|
+
* mismatch, never a silent create-over-existing or a silent no-op.
|
|
218
|
+
*/
|
|
219
|
+
expectedVersion: number | undefined;
|
|
220
|
+
/** The `ext` sub-key `fieldsJson` is namespaced under. Defaults to `"site"` — see `field-validation.ts`'s `validateFieldsAgainstSchema`. */
|
|
221
|
+
owner?: string | undefined;
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Slice J1 (plan-publish-all-types-2026-09-25.md) — the publish-content import chokepoint for
|
|
226
|
+
* `entries`. Combines `createEntry`'s owning-type/field-validation guards with `updateEntry`'s
|
|
227
|
+
* version-CAS discipline, but preserves the caller's own `id`/`status`/`publishedAt` instead of
|
|
228
|
+
* minting a fresh id and forcing `draft`/`null` — the two behaviors a straight `createEntry` call
|
|
229
|
+
* cannot produce, and the reason this is a new function rather than a wider `createEntry`.
|
|
230
|
+
*
|
|
231
|
+
* A tombstoned owning type refuses the import (mirrors `resolveExistingEntryForTransition`'s
|
|
232
|
+
* REQ-28 rule); unlike `createEntry`'s REQ-10, a `deprecated` owning type does NOT block an
|
|
233
|
+
* import — REQ-10 exists to stop new manual authoring against a type an operator is winding down,
|
|
234
|
+
* not to stop a publish run from keeping an already-existing entry's already-existing type in
|
|
235
|
+
* sync. Slug uniqueness is deliberately not checked here: the publish factory's own `address`
|
|
236
|
+
* precheck (config.address, F1) owns that refusal before `importEntry` is ever called.
|
|
237
|
+
*
|
|
238
|
+
* @complexity O(1) plus one content-type read, one field-validation pass, one id lookup, and one
|
|
239
|
+
* same-tx write pair.
|
|
240
|
+
*/
|
|
241
|
+
export declare function importEntry(required: ImportEntryRequired): Promise<Result<{
|
|
242
|
+
entry: EntryRecord;
|
|
243
|
+
}, Error>>;
|
|
187
244
|
export interface PublishUnpublishEntryRequired {
|
|
188
245
|
deps: ExistingEntryTransitionDeps;
|
|
189
246
|
input: ActorIdentityInput & {
|
|
@@ -150,6 +150,93 @@ export async function updateEntry(required) {
|
|
|
150
150
|
await deps.outbox.enqueue({ name: "entry.updated", payload: { workspaceId: input.workspaceId, entryId: current.id } });
|
|
151
151
|
return { ok: true, value: { entry: updated } };
|
|
152
152
|
}
|
|
153
|
+
/**
|
|
154
|
+
* Slice J1 (plan-publish-all-types-2026-09-25.md) — the publish-content import chokepoint for
|
|
155
|
+
* `entries`. Combines `createEntry`'s owning-type/field-validation guards with `updateEntry`'s
|
|
156
|
+
* version-CAS discipline, but preserves the caller's own `id`/`status`/`publishedAt` instead of
|
|
157
|
+
* minting a fresh id and forcing `draft`/`null` — the two behaviors a straight `createEntry` call
|
|
158
|
+
* cannot produce, and the reason this is a new function rather than a wider `createEntry`.
|
|
159
|
+
*
|
|
160
|
+
* A tombstoned owning type refuses the import (mirrors `resolveExistingEntryForTransition`'s
|
|
161
|
+
* REQ-28 rule); unlike `createEntry`'s REQ-10, a `deprecated` owning type does NOT block an
|
|
162
|
+
* import — REQ-10 exists to stop new manual authoring against a type an operator is winding down,
|
|
163
|
+
* not to stop a publish run from keeping an already-existing entry's already-existing type in
|
|
164
|
+
* sync. Slug uniqueness is deliberately not checked here: the publish factory's own `address`
|
|
165
|
+
* precheck (config.address, F1) owns that refusal before `importEntry` is ever called.
|
|
166
|
+
*
|
|
167
|
+
* @complexity O(1) plus one content-type read, one field-validation pass, one id lookup, and one
|
|
168
|
+
* same-tx write pair.
|
|
169
|
+
*/
|
|
170
|
+
export async function importEntry(required) {
|
|
171
|
+
const { deps, input } = required;
|
|
172
|
+
const authResult = await deps.authorize({ principalId: input.actorId, permission: "admin.collections.manage", workspaceId: input.workspaceId, entityType: "entry" });
|
|
173
|
+
if (!authResult.allowed) {
|
|
174
|
+
return { ok: false, error: new ForbiddenError(`principal '${input.actorId}' cannot import entry '${input.id}' (${authResult.reason})`) };
|
|
175
|
+
}
|
|
176
|
+
const contentType = await deps.contentTypeRepo.findByKey({ workspaceId: input.workspaceId, key: input.type });
|
|
177
|
+
if (!contentType || contentType.workspaceId !== input.workspaceId) {
|
|
178
|
+
return { ok: false, error: new ContentTypeNotFoundError(`content type '${input.type}' was not found in workspace '${input.workspaceId}'`) };
|
|
179
|
+
}
|
|
180
|
+
if (contentType.status === "tombstone") {
|
|
181
|
+
return { ok: false, error: new ContentTypeNotActiveError(`content type '${input.type}' is tombstoned; entries cannot be imported into it`) };
|
|
182
|
+
}
|
|
183
|
+
const validation = validateFieldsAgainstSchema({ schema: contentType.fields, fieldsJson: input.fieldsJson, owner: input.owner });
|
|
184
|
+
if (!validation.valid) {
|
|
185
|
+
return { ok: false, error: new EntryFieldValidationError(validation.fieldErrors) };
|
|
186
|
+
}
|
|
187
|
+
const existing = await deps.entryRepo.findById({ workspaceId: input.workspaceId, id: input.id });
|
|
188
|
+
if (input.expectedVersion === undefined) {
|
|
189
|
+
if (existing) {
|
|
190
|
+
return { ok: false, error: new VersionConflictError(`entry '${input.id}' already exists in workspace '${input.workspaceId}', but no expectedVersion was supplied for import`) };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
if (!existing) {
|
|
195
|
+
return { ok: false, error: new VersionConflictError(`expected version ${input.expectedVersion} for entry '${input.id}', but no such entry exists`) };
|
|
196
|
+
}
|
|
197
|
+
if (existing.version !== input.expectedVersion) {
|
|
198
|
+
return { ok: false, error: new VersionConflictError(`expected version ${input.expectedVersion} for entry '${input.id}', found ${existing.version}`) };
|
|
199
|
+
}
|
|
200
|
+
// Same id, different type means this is not the row the caller's plan was made against; a
|
|
201
|
+
// silent type move would also dodge the REQ-28 tombstone check on the entry's REAL type.
|
|
202
|
+
if (existing.type !== input.type) {
|
|
203
|
+
return { ok: false, error: new VersionConflictError(`entry '${input.id}' is of type '${existing.type}', not '${input.type}'; an import cannot change an entry's type`) };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const now = deps.clock.nowIso();
|
|
207
|
+
const entry = {
|
|
208
|
+
id: input.id,
|
|
209
|
+
workspaceId: input.workspaceId,
|
|
210
|
+
type: input.type,
|
|
211
|
+
slug: input.slug,
|
|
212
|
+
status: input.status,
|
|
213
|
+
title: input.title,
|
|
214
|
+
bodyJson: input.bodyJson ?? null,
|
|
215
|
+
fieldsJson: input.fieldsJson,
|
|
216
|
+
publishedAt: input.publishedAt,
|
|
217
|
+
createdAt: existing?.createdAt ?? now,
|
|
218
|
+
updatedAt: now,
|
|
219
|
+
version: existing ? existing.version + 1 : 1,
|
|
220
|
+
};
|
|
221
|
+
await deps.entryRepo.transaction(async () => {
|
|
222
|
+
await deps.entryRepo.save(entry);
|
|
223
|
+
await deps.entryRepo.appendRevision({
|
|
224
|
+
entryId: entry.id,
|
|
225
|
+
workspaceId: input.workspaceId,
|
|
226
|
+
op: existing ? "update" : "create",
|
|
227
|
+
stateJson: entry,
|
|
228
|
+
actorId: input.actorId,
|
|
229
|
+
...delegationFields(input),
|
|
230
|
+
recordedAt: now,
|
|
231
|
+
});
|
|
232
|
+
if (deps.watermark)
|
|
233
|
+
await deps.watermark.stampWatermark({ workspaceId: input.workspaceId });
|
|
234
|
+
if (deps.onWritten)
|
|
235
|
+
await deps.onWritten(entry);
|
|
236
|
+
});
|
|
237
|
+
await deps.outbox.enqueue({ name: "entry.imported", payload: { workspaceId: input.workspaceId, entryId: entry.id, type: input.type, slug: input.slug } });
|
|
238
|
+
return { ok: true, value: { entry } };
|
|
239
|
+
}
|
|
153
240
|
async function transitionEntryStatus(required, target) {
|
|
154
241
|
const { deps, input } = required;
|
|
155
242
|
const resolved = await resolveExistingEntryForTransition(deps, input);
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
* bespoke `MediaRecord` table in place of a generic-entries model.
|
|
52
52
|
*/
|
|
53
53
|
import { createHash } from "node:crypto";
|
|
54
|
+
import { assertEntityLive } from "../core/entity-liveness.js";
|
|
54
55
|
import { MediaConflictError, MediaNotFoundError, MediaSourceImmutableError, MediaStillReferencedError, MediaValidationError, } from "./types.js";
|
|
55
56
|
import { withSha256Lock } from "./blob-gc-lock.js";
|
|
56
57
|
import { tombstoneBlobIfUnreferenced } from "./blob-gc.js";
|
|
@@ -457,6 +458,7 @@ export async function updateMediaMetadata(required, _optional = {}) {
|
|
|
457
458
|
const existing = await deps.mediaRepo.findById({ workspaceId: input.workspaceId, id: input.id });
|
|
458
459
|
if (!existing)
|
|
459
460
|
throw new MediaNotFoundError(`media '${input.id}' was not found`);
|
|
461
|
+
assertEntityLive({ entityType: "media", entityId: input.id, state: existing.status === "trashed" ? "trashed" : "live" });
|
|
460
462
|
if (input.width !== undefined && input.width !== null)
|
|
461
463
|
assertPositiveIntegerOrThrow(input.width, "width");
|
|
462
464
|
if (input.height !== undefined && input.height !== null)
|
|
@@ -1,27 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file Write-service for the `navigation` library.
|
|
3
|
-
*
|
|
4
|
-
* Purpose:
|
|
5
|
-
* Implements the menu mutation slice: create, whole-tree update (OCC), soft
|
|
6
|
-
* delete → hard purge, and location assignment. Mirrors the host's `post`
|
|
7
|
-
* feature style (required/optional param objects, typed errors, OCC via
|
|
8
|
-
* `version`).
|
|
9
|
-
*
|
|
10
|
-
* How it relates to the project:
|
|
11
|
-
* - Storage is `MenuRepoPort` (`repo.memory.ts`) — a self-contained repo, not
|
|
12
|
-
* the generic entries repo (see `repo.memory.ts`'s file header for why).
|
|
13
|
-
* - Location assignment additionally writes `NavLocationBindingRepoPort`
|
|
14
|
-
* (`ports.ts`), the one real port this library declares.
|
|
15
|
-
* - In production this whole module runs inside the command
|
|
16
|
-
* gateway/write chokepoint (same-transaction revision, attribution,
|
|
17
|
-
* `entry_refs` extraction). That gateway is not implemented as running code
|
|
18
|
-
* yet, so these functions are the chokepoint's future *contents*, called
|
|
19
|
-
* directly for now.
|
|
20
|
-
*
|
|
21
|
-
* Architectural role:
|
|
22
|
-
* Feature logic only. No Express/route code, no direct SQL — everything goes
|
|
23
|
-
* through the injected repo ports (`deps`).
|
|
24
|
-
*/
|
|
25
1
|
import type { ClockPort, IdGeneratorPort, OutboxPort, UUID } from "../core/ports.js";
|
|
26
2
|
import type { MenuRepoPort } from "./repo.memory.js";
|
|
27
3
|
import type { NavLocationBindingRepoPort } from "./ports.js";
|
|
@@ -1,3 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Write-service for the `navigation` library.
|
|
3
|
+
*
|
|
4
|
+
* Purpose:
|
|
5
|
+
* Implements the menu mutation slice: create, whole-tree update (OCC), soft
|
|
6
|
+
* delete → hard purge, and location assignment. Mirrors the host's `post`
|
|
7
|
+
* feature style (required/optional param objects, typed errors, OCC via
|
|
8
|
+
* `version`).
|
|
9
|
+
*
|
|
10
|
+
* How it relates to the project:
|
|
11
|
+
* - Storage is `MenuRepoPort` (`repo.memory.ts`) — a self-contained repo, not
|
|
12
|
+
* the generic entries repo (see `repo.memory.ts`'s file header for why).
|
|
13
|
+
* - Location assignment additionally writes `NavLocationBindingRepoPort`
|
|
14
|
+
* (`ports.ts`), the one real port this library declares.
|
|
15
|
+
* - In production this whole module runs inside the command
|
|
16
|
+
* gateway/write chokepoint (same-transaction revision, attribution,
|
|
17
|
+
* `entry_refs` extraction). That gateway is not implemented as running code
|
|
18
|
+
* yet, so these functions are the chokepoint's future *contents*, called
|
|
19
|
+
* directly for now.
|
|
20
|
+
*
|
|
21
|
+
* Architectural role:
|
|
22
|
+
* Feature logic only. No Express/route code, no direct SQL — everything goes
|
|
23
|
+
* through the injected repo ports (`deps`).
|
|
24
|
+
*/
|
|
25
|
+
import { assertEntityLive } from "../core/entity-liveness.js";
|
|
1
26
|
import { NAV_DOC_TYPE, } from "./types.js";
|
|
2
27
|
// ---------------------------------------------------------------------------
|
|
3
28
|
// Outbox event publication — each mutating function below
|
|
@@ -293,6 +318,7 @@ export async function updateMenuTree(required, optional = {}) {
|
|
|
293
318
|
const existing = await deps.repo.findById({ workspaceId: input.workspaceId, id: input.id });
|
|
294
319
|
if (!existing)
|
|
295
320
|
throw new MenuNotFoundError(`menu '${input.id}' was not found`);
|
|
321
|
+
assertEntityLive({ entityType: "menu", entityId: input.id, state: existing.status === "trash" ? "trashed" : "live" });
|
|
296
322
|
if (existing.version !== input.expectedVersion) {
|
|
297
323
|
throw new MenuConflictError(`menu '${input.id}' was modified concurrently (expected version ${input.expectedVersion}, found ${existing.version})`);
|
|
298
324
|
}
|
|
@@ -360,6 +386,7 @@ export async function assignLocation(required, _optional = {}) {
|
|
|
360
386
|
const menu = await deps.repo.findById({ workspaceId: input.workspaceId, id: input.menuId });
|
|
361
387
|
if (!menu)
|
|
362
388
|
throw new MenuNotFoundError(`menu '${input.menuId}' was not found`);
|
|
389
|
+
assertEntityLive({ entityType: "menu", entityId: input.menuId, state: menu.status === "trash" ? "trashed" : "live" });
|
|
363
390
|
const now = deps.clock.nowIso();
|
|
364
391
|
const existingBinding = await deps.bindingRepo.findByLocation({
|
|
365
392
|
workspaceId: input.workspaceId,
|
|
@@ -1,14 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file Navigation's (Menus') half of the agent-tool authorization wiring: maps `agent-tools.ts`'s five
|
|
3
|
-
* catalog entries onto `menu-service.ts`'s read/create/update/assign operations, as
|
|
4
|
-
* `ToolRegistration`s. The entire catalog is wired — `deleteMenu` is deliberately absent from the
|
|
5
|
-
* catalog rather than present-but-unwired; see `navigation/agent-tools.ts`'s own header.
|
|
6
|
-
*
|
|
7
|
-
* Authorization shape: like Media and unlike Forms/Identity/Widgets, `menu-service.ts`'s functions
|
|
8
|
-
* perform NO internal `authorize()` call of their own — every admin HTTP route a host builds gates
|
|
9
|
-
* inline. Every handler here does the same via the kit's `requireToolPermission`, which is the
|
|
10
|
-
* single evaluation for these tools, located where the real route locates it.
|
|
11
|
-
*/
|
|
12
1
|
import type { AuthorizeFn } from "../core/commands/command.js";
|
|
13
2
|
import type { OutboxPort } from "../core/ports.js";
|
|
14
3
|
import { type DerivedRiskByToolId, type ToolRegistration } from "../core/tools/registration-kit.js";
|
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Navigation's (Menus') half of the agent-tool authorization wiring: maps `agent-tools.ts`'s five
|
|
3
|
+
* catalog entries onto `menu-service.ts`'s read/create/update/assign operations, as
|
|
4
|
+
* `ToolRegistration`s. The entire catalog is wired — `deleteMenu` is deliberately absent from the
|
|
5
|
+
* catalog rather than present-but-unwired; see `navigation/agent-tools.ts`'s own header.
|
|
6
|
+
*
|
|
7
|
+
* Authorization shape: like Media and unlike Forms/Identity/Widgets, `menu-service.ts`'s functions
|
|
8
|
+
* perform NO internal `authorize()` call of their own — every admin HTTP route a host builds gates
|
|
9
|
+
* inline. Every handler here does the same via the kit's `requireToolPermission`, which is the
|
|
10
|
+
* single evaluation for these tools, located where the real route locates it.
|
|
11
|
+
*/
|
|
12
|
+
import { ToolInputError } from "@jini-ai/core";
|
|
1
13
|
import { buildDomainRegistrations, indexCatalogById, requireInputRecord, requireNumber, requireString, requireToolPermission, withSchemaOnRejection, } from "../core/tools/registration-kit.js";
|
|
2
14
|
import { menusAgentToolCatalog } from "./agent-tools.js";
|
|
3
15
|
import { assignLocation, createMenu, MenuNotFoundError, MenuValidationError, updateMenuTree } from "./menu-service.js";
|
|
@@ -65,6 +77,9 @@ export function buildMenusRegistrations(routeDeps) {
|
|
|
65
77
|
menus_create_menu: async (ctx) => {
|
|
66
78
|
const input = requireInputRecord(ctx.input);
|
|
67
79
|
await requireToolPermission(routeDeps, { principalId: ctx.principal.id, permission: "admin.menus.create", entityType: "menu" });
|
|
80
|
+
if (input.items !== undefined && !Array.isArray(input.items)) {
|
|
81
|
+
throw new ToolInputError("'items' must be an array of nav items");
|
|
82
|
+
}
|
|
68
83
|
return withSchemaOnRejection({ toolId: "menus_create_menu", catalog: CATALOG_BY_ID, isShapeRejection: isMenusShapeRejection }, async () => {
|
|
69
84
|
const { menu } = await createMenu({
|
|
70
85
|
deps: menusDeps(routeDeps),
|
|
@@ -36,8 +36,14 @@ export interface ContentRecordLookupPort {
|
|
|
36
36
|
}): Promise<{
|
|
37
37
|
workspaceId: string;
|
|
38
38
|
kind: string;
|
|
39
|
+
deletedAt?: string | null;
|
|
39
40
|
} | null>;
|
|
40
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Row 63 (web-high fix plan, 2026-09-24): a trashed post/page must refuse a term assign/unassign,
|
|
44
|
+
* not silently accept one — `assertEntityLive` throws `EntityNotLiveError` the moment `deletedAt`
|
|
45
|
+
* is set, before this ever returns a resolvable target to `write-service.ts`'s join guards.
|
|
46
|
+
*/
|
|
41
47
|
export declare function createPostBackedContentLookup(deps: {
|
|
42
48
|
postRepo: ContentRecordLookupPort;
|
|
43
49
|
workspaceId: string;
|
|
@@ -1,9 +1,19 @@
|
|
|
1
|
+
import { assertEntityLive } from "../core/entity-liveness.js";
|
|
1
2
|
import { isContentTypeOnAllowList } from "./write-service.js";
|
|
3
|
+
/**
|
|
4
|
+
* Row 63 (web-high fix plan, 2026-09-24): a trashed post/page must refuse a term assign/unassign,
|
|
5
|
+
* not silently accept one — `assertEntityLive` throws `EntityNotLiveError` the moment `deletedAt`
|
|
6
|
+
* is set, before this ever returns a resolvable target to `write-service.ts`'s join guards.
|
|
7
|
+
*/
|
|
2
8
|
export function createPostBackedContentLookup(deps) {
|
|
3
9
|
return {
|
|
4
10
|
async resolve({ contentId }) {
|
|
5
11
|
const post = await deps.postRepo.findById({ workspaceId: deps.workspaceId, id: contentId });
|
|
6
|
-
|
|
12
|
+
if (!post)
|
|
13
|
+
return null;
|
|
14
|
+
if (post.deletedAt)
|
|
15
|
+
assertEntityLive({ entityType: post.kind, entityId: contentId, state: "trashed" });
|
|
16
|
+
return { workspaceId: post.workspaceId, kind: post.kind };
|
|
7
17
|
},
|
|
8
18
|
};
|
|
9
19
|
}
|
package/dist/taxonomy/index.d.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* has no imports at all: the ceremony is a parameter, not a dependency.
|
|
20
20
|
*/
|
|
21
21
|
export { TaxonomyNotApplicableError, WorkspaceMismatchError, ContentTypeMismatchError, TaxonomyNotHierarchicalError, ParentCrossTaxonomyError, TermNotFoundError, HierarchyCycleDetectedError, wouldCreateCycle, validateContentJoin, validateHierarchyAssignment, type TermTreeLookup, } from "./validation-chain.js";
|
|
22
|
-
export { TAXONOMY_ALLOWED_CONTENT_TYPES, isContentTypeOnAllowList, TaxonomyRecordNotFoundError, TermRecordNotFoundError, ContentRecordNotFoundError, TermHasAssignedContentError, TermHasChildTermsError, TaxonomyHasAssignedContentError, createTaxonomy, createTerm, renameTerm, assignTerms, unassignTerms, deleteTerm, deleteTaxonomy, onContentDeleted, type AuthorizeFn, type Taxonomy, type Term, type TaxonomyRepoPort, type TermRepoPort, type EntryTermRepoPort, type ContentLookupPort, type ContentTypeTaxonomyPolicyPort, type UnassignableEntryTermRepoPort, type TaxonomyRevisionRow, type TaxonomyRevisionRepoPort, type ClockPort, type IdGeneratorPort, type WriteServiceDeps, type CreateTaxonomyRequired, type CreateTermRequired, type RenameTermRequired, type AssignTermsRequired, type UnassignTermsRequired, type DeleteTermRequired, type DeleteTaxonomyRequired, type DeletableTaxonomyRepoPort, type DeletableTermRepoPort, type AssignmentCountEntryTermRepoPort, type TransactionalRepoPort, type EntryTermsCleanupPort, type OnContentDeletedRequired, } from "./write-service.js";
|
|
22
|
+
export { TAXONOMY_ALLOWED_CONTENT_TYPES, isContentTypeOnAllowList, TaxonomyRecordNotFoundError, TermRecordNotFoundError, ContentRecordNotFoundError, TermHasAssignedContentError, TermHasChildTermsError, TaxonomyHasAssignedContentError, TaxonomyVersionConflictError, createTaxonomy, createTerm, renameTerm, assignTerms, unassignTerms, deleteTerm, deleteTaxonomy, onContentDeleted, importTaxonomy, importTerm, type AuthorizeFn, type Taxonomy, type Term, type TaxonomyRepoPort, type TermRepoPort, type EntryTermRepoPort, type ContentLookupPort, type ContentTypeTaxonomyPolicyPort, type UnassignableEntryTermRepoPort, type TaxonomyRevisionRow, type TaxonomyRevisionRepoPort, type ClockPort, type IdGeneratorPort, type WriteServiceDeps, type CreateTaxonomyRequired, type CreateTermRequired, type RenameTermRequired, type AssignTermsRequired, type UnassignTermsRequired, type DeleteTermRequired, type DeleteTaxonomyRequired, type DeletableTaxonomyRepoPort, type DeletableTermRepoPort, type AssignmentCountEntryTermRepoPort, type TransactionalRepoPort, type EntryTermsCleanupPort, type OnContentDeletedRequired, type ImportableTaxonomyRepoPort, type ImportableTermRepoPort, type ImportTaxonomyRequired, type ImportTermRequired, } from "./write-service.js";
|
|
23
23
|
export { listTaxonomiesWithTerms, type TaxonomyListPort, type TermListPort, type TaxonomyWithTerms, } from "./list.js";
|
|
24
24
|
/**
|
|
25
25
|
* The domain half of the `mergeTerm` ceremony — see this file's header for why the gateway itself
|
package/dist/taxonomy/index.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* has no imports at all: the ceremony is a parameter, not a dependency.
|
|
20
20
|
*/
|
|
21
21
|
export { TaxonomyNotApplicableError, WorkspaceMismatchError, ContentTypeMismatchError, TaxonomyNotHierarchicalError, ParentCrossTaxonomyError, TermNotFoundError, HierarchyCycleDetectedError, wouldCreateCycle, validateContentJoin, validateHierarchyAssignment, } from "./validation-chain.js";
|
|
22
|
-
export { TAXONOMY_ALLOWED_CONTENT_TYPES, isContentTypeOnAllowList, TaxonomyRecordNotFoundError, TermRecordNotFoundError, ContentRecordNotFoundError, TermHasAssignedContentError, TermHasChildTermsError, TaxonomyHasAssignedContentError, createTaxonomy, createTerm, renameTerm, assignTerms, unassignTerms, deleteTerm, deleteTaxonomy, onContentDeleted, } from "./write-service.js";
|
|
22
|
+
export { TAXONOMY_ALLOWED_CONTENT_TYPES, isContentTypeOnAllowList, TaxonomyRecordNotFoundError, TermRecordNotFoundError, ContentRecordNotFoundError, TermHasAssignedContentError, TermHasChildTermsError, TaxonomyHasAssignedContentError, TaxonomyVersionConflictError, createTaxonomy, createTerm, renameTerm, assignTerms, unassignTerms, deleteTerm, deleteTaxonomy, onContentDeleted, importTaxonomy, importTerm, } from "./write-service.js";
|
|
23
23
|
export { listTaxonomiesWithTerms, } from "./list.js";
|
|
24
24
|
/**
|
|
25
25
|
* The domain half of the `mergeTerm` ceremony — see this file's header for why the gateway itself
|
|
@@ -13,6 +13,11 @@ export declare class InMemoryTaxonomyRepo implements TaxonomyRepoPort, TaxonomyL
|
|
|
13
13
|
hierarchical: boolean;
|
|
14
14
|
allowList?: string[] | undefined;
|
|
15
15
|
} | null>;
|
|
16
|
+
/** `ImportableTaxonomyRepoPort` — additive capability for `importTaxonomy`'s CAS check and
|
|
17
|
+
* update-in-place, see `write-service.ts`'s doc comment on that interface. */
|
|
18
|
+
findByIdFull(id: string): Promise<Taxonomy | null>;
|
|
19
|
+
/** `ImportableTaxonomyRepoPort` — writes an existing row back in place, preserving its id. */
|
|
20
|
+
update(row: Taxonomy): Promise<unknown>;
|
|
16
21
|
list(): Promise<Taxonomy[]>;
|
|
17
22
|
/** `DeletableTaxonomyRepoPort` — additive capability for `deleteTaxonomy`, see
|
|
18
23
|
* `write-service.ts`'s doc comment on that interface. */
|
|
@@ -44,6 +49,9 @@ export declare class InMemoryTermRepo implements TermRepoPort, TermListPort {
|
|
|
44
49
|
taxonomyId: string;
|
|
45
50
|
name?: string | undefined;
|
|
46
51
|
} | null>;
|
|
52
|
+
/** `ImportableTermRepoPort` — additive capability for `importTerm`'s CAS check, see
|
|
53
|
+
* `write-service.ts`'s doc comment on that interface. */
|
|
54
|
+
findByIdFull(id: string): Promise<Term | null>;
|
|
47
55
|
listByTaxonomy(params: {
|
|
48
56
|
taxonomyId: string;
|
|
49
57
|
}): Promise<Term[]>;
|
|
@@ -12,6 +12,17 @@ export class InMemoryTaxonomyRepo {
|
|
|
12
12
|
const row = this.rows.get(id);
|
|
13
13
|
return row ? { id: row.id, hierarchical: row.hierarchical } : null;
|
|
14
14
|
}
|
|
15
|
+
/** `ImportableTaxonomyRepoPort` — additive capability for `importTaxonomy`'s CAS check and
|
|
16
|
+
* update-in-place, see `write-service.ts`'s doc comment on that interface. */
|
|
17
|
+
async findByIdFull(id) {
|
|
18
|
+
const row = this.rows.get(id);
|
|
19
|
+
return row ? { ...row } : null;
|
|
20
|
+
}
|
|
21
|
+
/** `ImportableTaxonomyRepoPort` — writes an existing row back in place, preserving its id. */
|
|
22
|
+
async update(row) {
|
|
23
|
+
this.rows.set(row.id, { ...row });
|
|
24
|
+
return row;
|
|
25
|
+
}
|
|
15
26
|
async list() {
|
|
16
27
|
return [...this.rows.values()].map((row) => ({ ...row }));
|
|
17
28
|
}
|
|
@@ -54,6 +65,12 @@ export class InMemoryTermRepo {
|
|
|
54
65
|
const row = this.rows.get(id);
|
|
55
66
|
return row ? { id: row.id, taxonomyId: row.taxonomyId, name: row.name } : null;
|
|
56
67
|
}
|
|
68
|
+
/** `ImportableTermRepoPort` — additive capability for `importTerm`'s CAS check, see
|
|
69
|
+
* `write-service.ts`'s doc comment on that interface. */
|
|
70
|
+
async findByIdFull(id) {
|
|
71
|
+
const row = this.rows.get(id);
|
|
72
|
+
return row ? { ...row } : null;
|
|
73
|
+
}
|
|
57
74
|
async listByTaxonomy(params) {
|
|
58
75
|
return [...this.rows.values()].filter((row) => row.taxonomyId === params.taxonomyId).map((row) => ({ ...row }));
|
|
59
76
|
}
|
|
@@ -124,7 +124,7 @@ export interface TaxonomyRevisionRow {
|
|
|
124
124
|
* `"deprecate"` (that ceremony's own header explains why widening this union was out of its
|
|
125
125
|
* scope), a delete has no honest fit among the other four ops, so this widens the union rather
|
|
126
126
|
* than overloading an existing member. */
|
|
127
|
-
op: "create" | "rename" | "reparent" | "deprecate" | "delete";
|
|
127
|
+
op: "create" | "rename" | "reparent" | "deprecate" | "delete" | "import";
|
|
128
128
|
previousState: Record<string, unknown> | null;
|
|
129
129
|
actorId: string;
|
|
130
130
|
recordedAt: string;
|
|
@@ -171,6 +171,17 @@ export declare class TermRecordNotFoundError extends Error {
|
|
|
171
171
|
export declare class ContentRecordNotFoundError extends Error {
|
|
172
172
|
constructor(message: string);
|
|
173
173
|
}
|
|
174
|
+
/**
|
|
175
|
+
* J1 (plan-publish-all-types-2026-09-25.md) — `importTaxonomy`/`importTerm`'s optimistic-concurrency
|
|
176
|
+
* rejection. Neither `Taxonomy` nor `Term` had a version-checked write before this slice
|
|
177
|
+
* (`createTaxonomy`/`createTerm` always insert a fresh row; `renameTerm` doesn't check a caller-
|
|
178
|
+
* supplied version either), so this is a new class rather than a reused one — `content-types`' and
|
|
179
|
+
* `entries`' own `VersionConflictError`s are each that package's own, per this file's header's
|
|
180
|
+
* "catch the one it actually called into" rule.
|
|
181
|
+
*/
|
|
182
|
+
export declare class TaxonomyVersionConflictError extends Error {
|
|
183
|
+
constructor(message: string);
|
|
184
|
+
}
|
|
174
185
|
export interface CreateTaxonomyRequired {
|
|
175
186
|
deps: WriteServiceDeps;
|
|
176
187
|
principalId: string;
|
|
@@ -192,6 +203,79 @@ export interface CreateTermRequired {
|
|
|
192
203
|
* cycle check is therefore always a no-op (`() => false`) here, unlike a reparent of an existing
|
|
193
204
|
* term (not yet built — no certified test in this slice exercises it). */
|
|
194
205
|
export declare function createTerm(required: CreateTermRequired, _optional?: Record<string, never>): Promise<Term>;
|
|
206
|
+
/**
|
|
207
|
+
* Additive capability beyond the certified `TaxonomyRepoPort` — `findById`'s narrow return
|
|
208
|
+
* (`{id, hierarchical, allowList?}`) doesn't carry `version`/`name`/`status`/`updatedAt`, which
|
|
209
|
+
* `importTaxonomy`'s CAS check and update-in-place both need, and `insert`-only leaves no way to
|
|
210
|
+
* write an existing row back. Same beyond-the-certified-port precedent as `DeletableTaxonomyRepoPort`.
|
|
211
|
+
*/
|
|
212
|
+
export interface ImportableTaxonomyRepoPort {
|
|
213
|
+
findByIdFull(id: string): Promise<Taxonomy | null>;
|
|
214
|
+
update(row: Taxonomy): Promise<unknown>;
|
|
215
|
+
}
|
|
216
|
+
export interface ImportTaxonomyRequired {
|
|
217
|
+
deps: WriteServiceDeps & {
|
|
218
|
+
taxonomies: TaxonomyRepoPort & ImportableTaxonomyRepoPort;
|
|
219
|
+
};
|
|
220
|
+
principalId: string;
|
|
221
|
+
/** The source's own id, preserved verbatim — unlike `createTaxonomy`, which always mints a fresh
|
|
222
|
+
* one. Anything referencing this taxonomy by id (its member terms) needs it to survive a publish
|
|
223
|
+
* round trip unchanged. */
|
|
224
|
+
id: string;
|
|
225
|
+
name: string;
|
|
226
|
+
hierarchical: boolean;
|
|
227
|
+
/** Same three-way CAS contract as `importEntry`'s `expectedVersion`: `undefined` means "this id
|
|
228
|
+
* must not already exist" (import-as-create); a number means "a row at exactly this version must
|
|
229
|
+
* exist" (import-as-update). Either mismatch is a `TaxonomyVersionConflictError`, never a silent
|
|
230
|
+
* overwrite or a silent no-op. */
|
|
231
|
+
expectedVersion: number | undefined;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Slice J1 — the publish-content import chokepoint for `taxonomies`. Address (name) uniqueness is
|
|
235
|
+
* deliberately not checked here: the publish factory's own `address` precheck (F1's
|
|
236
|
+
* `config.address`) owns that refusal before this is ever called, mirroring `importEntry`'s
|
|
237
|
+
* identical split from slug-uniqueness.
|
|
238
|
+
*
|
|
239
|
+
* @complexity O(1) — one id lookup, one insert-or-update, one revision append, all outside a
|
|
240
|
+
* transaction (this package has no SQLite adapter to open one against yet — see `repo.memory.ts`'s
|
|
241
|
+
* header).
|
|
242
|
+
*/
|
|
243
|
+
export declare function importTaxonomy(required: ImportTaxonomyRequired, _optional?: Record<string, never>): Promise<Taxonomy>;
|
|
244
|
+
/**
|
|
245
|
+
* Additive capability beyond the certified `TermRepoPort` — `findById`'s narrow return
|
|
246
|
+
* (`{id, taxonomyId, name?}`) doesn't carry `version`, which `importTerm`'s CAS check needs.
|
|
247
|
+
* `TermRepoPort` already has `update`, unlike taxonomies, so no additive `update` is needed here.
|
|
248
|
+
*/
|
|
249
|
+
export interface ImportableTermRepoPort {
|
|
250
|
+
findByIdFull(id: string): Promise<Term | null>;
|
|
251
|
+
}
|
|
252
|
+
export interface ImportTermRequired {
|
|
253
|
+
deps: WriteServiceDeps & {
|
|
254
|
+
terms: TermRepoPort & ImportableTermRepoPort;
|
|
255
|
+
};
|
|
256
|
+
principalId: string;
|
|
257
|
+
/** The source's own id, preserved verbatim — unlike `createTerm`, which always mints a fresh one. */
|
|
258
|
+
id: string;
|
|
259
|
+
taxonomyId: string;
|
|
260
|
+
name: string;
|
|
261
|
+
parentId?: string | null | undefined;
|
|
262
|
+
/** Same three-way CAS contract as `importTaxonomy.expectedVersion`. */
|
|
263
|
+
expectedVersion: number | undefined;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Slice J1 — the publish-content import chokepoint for `terms`. Address (name within
|
|
267
|
+
* `(taxonomyId, parentId)`) uniqueness is the publish factory's own precheck, not this function's
|
|
268
|
+
* job — same split as `importTaxonomy`/`importEntry`.
|
|
269
|
+
*
|
|
270
|
+
* Unlike `createTerm`, an import can MOVE an existing term (an import-as-update with a new
|
|
271
|
+
* `parentId`), so the destination's real ancestor chain is loaded first (`loadAncestorChain`) and
|
|
272
|
+
* handed to the certified `wouldCreateCycle` walk — a publish must never write a parent loop into
|
|
273
|
+
* live taxonomy, where every tree walk over it would spin forever.
|
|
274
|
+
*
|
|
275
|
+
* @complexity O(d) — one taxonomy lookup, one id lookup, one optional parent lookup, `d` ancestor
|
|
276
|
+
* lookups (the candidate parent's depth), one insert-or-update, one revision append.
|
|
277
|
+
*/
|
|
278
|
+
export declare function importTerm(required: ImportTermRequired, _optional?: Record<string, never>): Promise<Term>;
|
|
195
279
|
export interface RenameTermRequired {
|
|
196
280
|
deps: WriteServiceDeps;
|
|
197
281
|
principalId: string;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ForbiddenError } from "../core/commands/command.js";
|
|
2
|
-
import { validateContentJoin, validateHierarchyAssignment } from "./validation-chain.js";
|
|
2
|
+
import { validateContentJoin, validateHierarchyAssignment, wouldCreateCycle } from "./validation-chain.js";
|
|
3
3
|
/** The hardcoded post/page taxonomy allow-list (permanent, see
|
|
4
4
|
* `docs/decisions/taxonomy-content-type-allow-list.md`) — every taxonomy is applicable to
|
|
5
5
|
* `post`/`page` content. This set is never grown to admit a Collection's content-type key; a
|
|
@@ -29,6 +29,20 @@ export class ContentRecordNotFoundError extends Error {
|
|
|
29
29
|
this.name = "ContentRecordNotFoundError";
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* J1 (plan-publish-all-types-2026-09-25.md) — `importTaxonomy`/`importTerm`'s optimistic-concurrency
|
|
34
|
+
* rejection. Neither `Taxonomy` nor `Term` had a version-checked write before this slice
|
|
35
|
+
* (`createTaxonomy`/`createTerm` always insert a fresh row; `renameTerm` doesn't check a caller-
|
|
36
|
+
* supplied version either), so this is a new class rather than a reused one — `content-types`' and
|
|
37
|
+
* `entries`' own `VersionConflictError`s are each that package's own, per this file's header's
|
|
38
|
+
* "catch the one it actually called into" rule.
|
|
39
|
+
*/
|
|
40
|
+
export class TaxonomyVersionConflictError extends Error {
|
|
41
|
+
constructor(message) {
|
|
42
|
+
super(message);
|
|
43
|
+
this.name = "TaxonomyVersionConflictError";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
32
46
|
/** `admin.taxonomy.manage` (the flat-string permission convention) — must run before any other
|
|
33
47
|
* side effect of every write-service export in this file. */
|
|
34
48
|
async function authorizeTaxonomyManage(deps, principalId) {
|
|
@@ -104,6 +118,157 @@ export async function createTerm(required, _optional = {}) {
|
|
|
104
118
|
await deps.outbox.enqueue({ name: "taxonomy.term_created", termId: term.id, actorId: principalId, occurredAt: now });
|
|
105
119
|
return term;
|
|
106
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* Slice J1 — the publish-content import chokepoint for `taxonomies`. Address (name) uniqueness is
|
|
123
|
+
* deliberately not checked here: the publish factory's own `address` precheck (F1's
|
|
124
|
+
* `config.address`) owns that refusal before this is ever called, mirroring `importEntry`'s
|
|
125
|
+
* identical split from slug-uniqueness.
|
|
126
|
+
*
|
|
127
|
+
* @complexity O(1) — one id lookup, one insert-or-update, one revision append, all outside a
|
|
128
|
+
* transaction (this package has no SQLite adapter to open one against yet — see `repo.memory.ts`'s
|
|
129
|
+
* header).
|
|
130
|
+
*/
|
|
131
|
+
export async function importTaxonomy(required, _optional = {}) {
|
|
132
|
+
const { deps, principalId, id, name, hierarchical, expectedVersion } = required;
|
|
133
|
+
await authorizeTaxonomyManage(deps, principalId);
|
|
134
|
+
const existing = await deps.taxonomies.findByIdFull(id);
|
|
135
|
+
if (expectedVersion === undefined) {
|
|
136
|
+
if (existing) {
|
|
137
|
+
throw new TaxonomyVersionConflictError(`taxonomy '${id}' already exists, but no expectedVersion was supplied for import`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
if (!existing) {
|
|
142
|
+
throw new TaxonomyVersionConflictError(`expected version ${expectedVersion} for taxonomy '${id}', but no such taxonomy exists`);
|
|
143
|
+
}
|
|
144
|
+
if (existing.version !== expectedVersion) {
|
|
145
|
+
throw new TaxonomyVersionConflictError(`expected version ${expectedVersion} for taxonomy '${id}', found ${existing.version}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const now = deps.clock.nowIso();
|
|
149
|
+
const taxonomy = {
|
|
150
|
+
id,
|
|
151
|
+
name,
|
|
152
|
+
hierarchical,
|
|
153
|
+
status: "active",
|
|
154
|
+
updatedAt: now,
|
|
155
|
+
version: existing ? existing.version + 1 : 1,
|
|
156
|
+
};
|
|
157
|
+
if (existing) {
|
|
158
|
+
await deps.taxonomies.update(taxonomy);
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
await deps.taxonomies.insert(taxonomy);
|
|
162
|
+
}
|
|
163
|
+
await deps.revisions.insert({
|
|
164
|
+
taxonomyId: taxonomy.id,
|
|
165
|
+
op: "import",
|
|
166
|
+
previousState: existing ? { name: existing.name, hierarchical: existing.hierarchical } : null,
|
|
167
|
+
actorId: principalId,
|
|
168
|
+
recordedAt: now,
|
|
169
|
+
});
|
|
170
|
+
deps.stampWatermark();
|
|
171
|
+
await deps.outbox.enqueue({ name: "taxonomy.imported", taxonomyId: taxonomy.id, actorId: principalId, occurredAt: now });
|
|
172
|
+
return taxonomy;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Loads `startId`'s ancestor chain (`termId -> parentId`, `startId` included) so the synchronous,
|
|
176
|
+
* certified `wouldCreateCycle` walk can run over the destination's REAL tree. Stops at a root, a
|
|
177
|
+
* missing row, or an already-seen id — the last bounds the loop against pre-existing malformed
|
|
178
|
+
* parent data, mirroring `wouldCreateCycle`'s own `visited` guard.
|
|
179
|
+
*
|
|
180
|
+
* @complexity O(d) repo reads for a chain of depth `d`.
|
|
181
|
+
*/
|
|
182
|
+
async function loadAncestorChain(terms, startId) {
|
|
183
|
+
const chain = new Map();
|
|
184
|
+
let current = startId;
|
|
185
|
+
while (current !== null && !chain.has(current)) {
|
|
186
|
+
const parentId = (await terms.findByIdFull(current))?.parentId ?? null;
|
|
187
|
+
chain.set(current, parentId);
|
|
188
|
+
current = parentId;
|
|
189
|
+
}
|
|
190
|
+
return chain;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Slice J1 — the publish-content import chokepoint for `terms`. Address (name within
|
|
194
|
+
* `(taxonomyId, parentId)`) uniqueness is the publish factory's own precheck, not this function's
|
|
195
|
+
* job — same split as `importTaxonomy`/`importEntry`.
|
|
196
|
+
*
|
|
197
|
+
* Unlike `createTerm`, an import can MOVE an existing term (an import-as-update with a new
|
|
198
|
+
* `parentId`), so the destination's real ancestor chain is loaded first (`loadAncestorChain`) and
|
|
199
|
+
* handed to the certified `wouldCreateCycle` walk — a publish must never write a parent loop into
|
|
200
|
+
* live taxonomy, where every tree walk over it would spin forever.
|
|
201
|
+
*
|
|
202
|
+
* @complexity O(d) — one taxonomy lookup, one id lookup, one optional parent lookup, `d` ancestor
|
|
203
|
+
* lookups (the candidate parent's depth), one insert-or-update, one revision append.
|
|
204
|
+
*/
|
|
205
|
+
export async function importTerm(required, _optional = {}) {
|
|
206
|
+
const { deps, principalId, id, taxonomyId, name, parentId, expectedVersion } = required;
|
|
207
|
+
await authorizeTaxonomyManage(deps, principalId);
|
|
208
|
+
const taxonomy = await deps.taxonomies.findById(taxonomyId);
|
|
209
|
+
if (!taxonomy) {
|
|
210
|
+
throw new TaxonomyRecordNotFoundError(`taxonomy '${taxonomyId}' was not found`);
|
|
211
|
+
}
|
|
212
|
+
const existing = await deps.terms.findByIdFull(id);
|
|
213
|
+
if (expectedVersion === undefined) {
|
|
214
|
+
if (existing) {
|
|
215
|
+
throw new TaxonomyVersionConflictError(`term '${id}' already exists, but no expectedVersion was supplied for import`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
if (!existing) {
|
|
220
|
+
throw new TaxonomyVersionConflictError(`expected version ${expectedVersion} for term '${id}', but no such term exists`);
|
|
221
|
+
}
|
|
222
|
+
if (existing.version !== expectedVersion) {
|
|
223
|
+
throw new TaxonomyVersionConflictError(`expected version ${expectedVersion} for term '${id}', found ${existing.version}`);
|
|
224
|
+
}
|
|
225
|
+
// A cross-taxonomy move would leave the term's children parented across taxonomies.
|
|
226
|
+
if (existing.taxonomyId !== taxonomyId) {
|
|
227
|
+
throw new TaxonomyVersionConflictError(`term '${id}' belongs to taxonomy '${existing.taxonomyId}', not '${taxonomyId}'; an import cannot move a term between taxonomies`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const candidateParentId = parentId ?? null;
|
|
231
|
+
let resolvedParent = "not-applicable";
|
|
232
|
+
if (candidateParentId !== null) {
|
|
233
|
+
const parentTerm = await deps.terms.findById(candidateParentId);
|
|
234
|
+
resolvedParent = parentTerm ? { id: parentTerm.id, taxonomyId: parentTerm.taxonomyId } : null;
|
|
235
|
+
}
|
|
236
|
+
const ancestors = candidateParentId === null ? new Map() : await loadAncestorChain(deps.terms, candidateParentId);
|
|
237
|
+
validateHierarchyAssignment({
|
|
238
|
+
childTaxonomyId: taxonomyId,
|
|
239
|
+
taxonomyIsHierarchical: taxonomy.hierarchical,
|
|
240
|
+
candidateParentId,
|
|
241
|
+
resolvedParent,
|
|
242
|
+
wouldCreateCycle: (parent) => wouldCreateCycle({ termId: id, candidateParentId: parent, tree: { getParentId: (termId) => ancestors.get(termId) ?? null } }),
|
|
243
|
+
termId: existing ? id : "__new__",
|
|
244
|
+
});
|
|
245
|
+
const now = deps.clock.nowIso();
|
|
246
|
+
const term = {
|
|
247
|
+
id,
|
|
248
|
+
taxonomyId,
|
|
249
|
+
parentId: candidateParentId,
|
|
250
|
+
name,
|
|
251
|
+
status: "active",
|
|
252
|
+
updatedAt: now,
|
|
253
|
+
version: existing ? existing.version + 1 : 1,
|
|
254
|
+
};
|
|
255
|
+
if (existing) {
|
|
256
|
+
await deps.terms.update(term);
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
await deps.terms.insert(term);
|
|
260
|
+
}
|
|
261
|
+
await deps.revisions.insert({
|
|
262
|
+
taxonomyId,
|
|
263
|
+
op: "import",
|
|
264
|
+
previousState: existing ? { name: existing.name, parentId: existing.parentId } : null,
|
|
265
|
+
actorId: principalId,
|
|
266
|
+
recordedAt: now,
|
|
267
|
+
});
|
|
268
|
+
deps.stampWatermark();
|
|
269
|
+
await deps.outbox.enqueue({ name: "taxonomy.term_imported", termId: term.id, actorId: principalId, occurredAt: now });
|
|
270
|
+
return term;
|
|
271
|
+
}
|
|
107
272
|
/** AC-15/AC-19/AC-25/REQ-12/REQ-17 — same-tx rename + revision (carrying the pre-rename state) +
|
|
108
273
|
* watermark + outbox. `authorize()` runs before the term lookup, so a denied caller produces zero
|
|
109
274
|
* side effects of any kind. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jini-ai/cms",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.10",
|
|
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 persistence adapters in `/server`.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|