@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.
@@ -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. No dependencies.
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 Error {
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 Error {
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 Error {
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 Error {
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 Error {
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 Error {
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 Error {
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 Error {
101
+ export declare class VersionConflictError extends ToolInputError {
86
102
  constructor(message: string);
87
103
  }
88
- export declare class ContentTypeNotFoundError extends Error {
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 Error {
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 Error {
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. No dependencies.
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 Error {
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 Error {
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 Error {
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 Error {
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 Error {
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 Error {
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 Error {
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 Error {
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 Error {
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 Error {
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 Error {
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 { ContentTypeNotFoundError, ForbiddenError, InvalidFieldKindError, InvalidFieldNameGrammarError, InvalidKeyGrammarError, QueryableFieldCapExceededError, ReservedContentTypeKeyError, StorageOnlyFieldNotQueryableError, ValidationError, VersionConflictError, } from "./errors.js";
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
- await deps.repo.transaction(async () => {
98
- await deps.repo.save(contentType);
99
- await deps.repo.appendRevision({
100
- contentTypeKey: input.key,
101
- workspaceId: input.workspaceId,
102
- op: "register",
103
- stateJson: contentType,
104
- actorId: input.actorId,
105
- principalKind: input.principalKind ?? null,
106
- delegatedByWorkspaceId: input.delegatedByWorkspaceId ?? null,
107
- delegatedById: input.delegatedById ?? null,
108
- recordedAt: now,
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
- if (deps.watermark)
111
- await deps.watermark.stampWatermark({ workspaceId: input.workspaceId });
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({
@@ -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
@@ -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
- return post ? { workspaceId: post.workspaceId, kind: post.kind } : null;
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
  }
@@ -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
@@ -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.8",
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": {