@jini-ai/cms 0.3.9 → 0.3.11

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.
@@ -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
  /**
@@ -201,7 +201,7 @@ export async function updateContentTypeFields(required) {
201
201
  const before = current.fields;
202
202
  const after = input.fields;
203
203
  const now = deps.clock.nowIso();
204
- const updated = { ...current, fields: after, version: current.version + 1 };
204
+ const updated = { ...current, fields: after, label: input.label ?? current.label, version: current.version + 1 };
205
205
  await deps.repo.transaction(async () => {
206
206
  await deps.repo.save(updated);
207
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);
@@ -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;
@@ -146,8 +146,10 @@ export interface WriteServiceDeps {
146
146
  terms: TermRepoPort;
147
147
  entryTerms: EntryTermRepoPort;
148
148
  revisions: TaxonomyRevisionRepoPort;
149
- /** `core/gated-mutations.stampWatermark`-shaped, injected — same-transaction stamp per mutation. */
150
- stampWatermark: (tx?: unknown) => void;
149
+ /** `core/gated-mutations.stampWatermark`-shaped, injected — same-transaction stamp per mutation.
150
+ * May be sync (SQLite) or async (PGlite/Postgres); every call site awaits it, so an async stamp
151
+ * finishes, and its failure fails the write, before the mutation returns. */
152
+ stampWatermark: (tx?: unknown) => Promise<void> | void;
151
153
  outbox: {
152
154
  enqueue: (event: unknown) => Promise<void>;
153
155
  };
@@ -171,6 +173,17 @@ export declare class TermRecordNotFoundError extends Error {
171
173
  export declare class ContentRecordNotFoundError extends Error {
172
174
  constructor(message: string);
173
175
  }
176
+ /**
177
+ * J1 (plan-publish-all-types-2026-09-25.md) — `importTaxonomy`/`importTerm`'s optimistic-concurrency
178
+ * rejection. Neither `Taxonomy` nor `Term` had a version-checked write before this slice
179
+ * (`createTaxonomy`/`createTerm` always insert a fresh row; `renameTerm` doesn't check a caller-
180
+ * supplied version either), so this is a new class rather than a reused one — `content-types`' and
181
+ * `entries`' own `VersionConflictError`s are each that package's own, per this file's header's
182
+ * "catch the one it actually called into" rule.
183
+ */
184
+ export declare class TaxonomyVersionConflictError extends Error {
185
+ constructor(message: string);
186
+ }
174
187
  export interface CreateTaxonomyRequired {
175
188
  deps: WriteServiceDeps;
176
189
  principalId: string;
@@ -192,6 +205,79 @@ export interface CreateTermRequired {
192
205
  * cycle check is therefore always a no-op (`() => false`) here, unlike a reparent of an existing
193
206
  * term (not yet built — no certified test in this slice exercises it). */
194
207
  export declare function createTerm(required: CreateTermRequired, _optional?: Record<string, never>): Promise<Term>;
208
+ /**
209
+ * Additive capability beyond the certified `TaxonomyRepoPort` — `findById`'s narrow return
210
+ * (`{id, hierarchical, allowList?}`) doesn't carry `version`/`name`/`status`/`updatedAt`, which
211
+ * `importTaxonomy`'s CAS check and update-in-place both need, and `insert`-only leaves no way to
212
+ * write an existing row back. Same beyond-the-certified-port precedent as `DeletableTaxonomyRepoPort`.
213
+ */
214
+ export interface ImportableTaxonomyRepoPort {
215
+ findByIdFull(id: string): Promise<Taxonomy | null>;
216
+ update(row: Taxonomy): Promise<unknown>;
217
+ }
218
+ export interface ImportTaxonomyRequired {
219
+ deps: WriteServiceDeps & {
220
+ taxonomies: TaxonomyRepoPort & ImportableTaxonomyRepoPort;
221
+ };
222
+ principalId: string;
223
+ /** The source's own id, preserved verbatim — unlike `createTaxonomy`, which always mints a fresh
224
+ * one. Anything referencing this taxonomy by id (its member terms) needs it to survive a publish
225
+ * round trip unchanged. */
226
+ id: string;
227
+ name: string;
228
+ hierarchical: boolean;
229
+ /** Same three-way CAS contract as `importEntry`'s `expectedVersion`: `undefined` means "this id
230
+ * must not already exist" (import-as-create); a number means "a row at exactly this version must
231
+ * exist" (import-as-update). Either mismatch is a `TaxonomyVersionConflictError`, never a silent
232
+ * overwrite or a silent no-op. */
233
+ expectedVersion: number | undefined;
234
+ }
235
+ /**
236
+ * Slice J1 — the publish-content import chokepoint for `taxonomies`. Address (name) uniqueness is
237
+ * deliberately not checked here: the publish factory's own `address` precheck (F1's
238
+ * `config.address`) owns that refusal before this is ever called, mirroring `importEntry`'s
239
+ * identical split from slug-uniqueness.
240
+ *
241
+ * @complexity O(1) — one id lookup, one insert-or-update, one revision append, all outside a
242
+ * transaction (this package has no SQLite adapter to open one against yet — see `repo.memory.ts`'s
243
+ * header).
244
+ */
245
+ export declare function importTaxonomy(required: ImportTaxonomyRequired, _optional?: Record<string, never>): Promise<Taxonomy>;
246
+ /**
247
+ * Additive capability beyond the certified `TermRepoPort` — `findById`'s narrow return
248
+ * (`{id, taxonomyId, name?}`) doesn't carry `version`, which `importTerm`'s CAS check needs.
249
+ * `TermRepoPort` already has `update`, unlike taxonomies, so no additive `update` is needed here.
250
+ */
251
+ export interface ImportableTermRepoPort {
252
+ findByIdFull(id: string): Promise<Term | null>;
253
+ }
254
+ export interface ImportTermRequired {
255
+ deps: WriteServiceDeps & {
256
+ terms: TermRepoPort & ImportableTermRepoPort;
257
+ };
258
+ principalId: string;
259
+ /** The source's own id, preserved verbatim — unlike `createTerm`, which always mints a fresh one. */
260
+ id: string;
261
+ taxonomyId: string;
262
+ name: string;
263
+ parentId?: string | null | undefined;
264
+ /** Same three-way CAS contract as `importTaxonomy.expectedVersion`. */
265
+ expectedVersion: number | undefined;
266
+ }
267
+ /**
268
+ * Slice J1 — the publish-content import chokepoint for `terms`. Address (name within
269
+ * `(taxonomyId, parentId)`) uniqueness is the publish factory's own precheck, not this function's
270
+ * job — same split as `importTaxonomy`/`importEntry`.
271
+ *
272
+ * Unlike `createTerm`, an import can MOVE an existing term (an import-as-update with a new
273
+ * `parentId`), so the destination's real ancestor chain is loaded first (`loadAncestorChain`) and
274
+ * handed to the certified `wouldCreateCycle` walk — a publish must never write a parent loop into
275
+ * live taxonomy, where every tree walk over it would spin forever.
276
+ *
277
+ * @complexity O(d) — one taxonomy lookup, one id lookup, one optional parent lookup, `d` ancestor
278
+ * lookups (the candidate parent's depth), one insert-or-update, one revision append.
279
+ */
280
+ export declare function importTerm(required: ImportTermRequired, _optional?: Record<string, never>): Promise<Term>;
195
281
  export interface RenameTermRequired {
196
282
  deps: WriteServiceDeps;
197
283
  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) {
@@ -59,7 +73,7 @@ export async function createTaxonomy(required, _optional = {}) {
59
73
  actorId: principalId,
60
74
  recordedAt: now,
61
75
  });
62
- deps.stampWatermark();
76
+ await deps.stampWatermark();
63
77
  await deps.outbox.enqueue({ name: "taxonomy.created", taxonomyId: taxonomy.id, actorId: principalId, occurredAt: now });
64
78
  return taxonomy;
65
79
  }
@@ -100,10 +114,161 @@ export async function createTerm(required, _optional = {}) {
100
114
  };
101
115
  await deps.terms.insert(term);
102
116
  await deps.revisions.insert({ taxonomyId, op: "create", previousState: null, actorId: principalId, recordedAt: now });
103
- deps.stampWatermark();
117
+ await deps.stampWatermark();
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
+ await 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
+ await 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. */
@@ -132,7 +297,7 @@ export async function renameTerm(required, _optional = {}) {
132
297
  actorId: principalId,
133
298
  recordedAt: now,
134
299
  });
135
- deps.stampWatermark();
300
+ await deps.stampWatermark();
136
301
  await deps.outbox.enqueue({ name: "taxonomy.term_renamed", termId, actorId: principalId, occurredAt: now });
137
302
  return updated;
138
303
  }
@@ -203,7 +368,7 @@ export async function assignTerms(required, _optional = {}) {
203
368
  for (const termId of termIds) {
204
369
  await deps.entryTerms.upsert({ contentType, contentId, termId, addedAt: now });
205
370
  }
206
- deps.stampWatermark();
371
+ await deps.stampWatermark();
207
372
  await deps.outbox.enqueue({
208
373
  name: "taxonomy.terms_assigned",
209
374
  contentType,
@@ -229,7 +394,7 @@ export async function unassignTerms(required, _optional = {}) {
229
394
  await deps.entryTerms.remove({ contentType, contentId, termId });
230
395
  }
231
396
  const now = deps.clock.nowIso();
232
- deps.stampWatermark();
397
+ await deps.stampWatermark();
233
398
  await deps.outbox.enqueue({
234
399
  name: "taxonomy.terms_unassigned",
235
400
  contentType,
@@ -329,7 +494,7 @@ export async function deleteTerm(required, _optional = {}) {
329
494
  actorId: principalId,
330
495
  recordedAt: now,
331
496
  });
332
- deps.stampWatermark();
497
+ await deps.stampWatermark();
333
498
  await deps.outbox.enqueue({
334
499
  name: "taxonomy.term_deleted",
335
500
  termId,
@@ -388,7 +553,7 @@ export async function deleteTaxonomy(required, _optional = {}) {
388
553
  actorId: principalId,
389
554
  recordedAt: now,
390
555
  });
391
- deps.stampWatermark();
556
+ await deps.stampWatermark();
392
557
  await deps.outbox.enqueue({
393
558
  name: "taxonomy.deleted",
394
559
  taxonomyId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/cms",
3
- "version": "0.3.9",
3
+ "version": "0.3.11",
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": {