@jini-ai/cms 0.3.8 → 0.3.9
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.js +44 -16
- 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/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";
|
|
@@ -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}`) };
|
|
@@ -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jini-ai/cms",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.9",
|
|
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": {
|