@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.
- package/dist/content-types/write-service.d.ts +9 -0
- package/dist/content-types/write-service.js +1 -1
- package/dist/entries/index.d.ts +2 -2
- package/dist/entries/index.js +1 -1
- package/dist/entries/write-service.d.ts +58 -1
- package/dist/entries/write-service.js +87 -0
- package/dist/taxonomy/index.d.ts +1 -1
- package/dist/taxonomy/index.js +1 -1
- package/dist/taxonomy/repo.memory.d.ts +8 -0
- package/dist/taxonomy/repo.memory.js +17 -0
- package/dist/taxonomy/write-service.d.ts +89 -3
- package/dist/taxonomy/write-service.js +173 -8
- package/package.json +1 -1
|
@@ -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({
|
package/dist/entries/index.d.ts
CHANGED
|
@@ -28,8 +28,8 @@ export { validateFieldsAgainstSchema, selectVisibleEntryFields } from "./field-v
|
|
|
28
28
|
export { ForbiddenError, EntryNotFoundError, ContentTypeNotFoundError, ContentTypeNotActiveError, EntrySlugConflictError, VersionConflictError, EntryFieldValidationError, } from "./errors.js";
|
|
29
29
|
export type { EntryLifecycleOp, EntryLifecycleHandler } from "./lifecycle-dispatch.js";
|
|
30
30
|
export { ENTRY_LIFECYCLE_OP_NAMES, ENTRY_LIFECYCLE_OPS, parseEntryLifecycleOp, } from "./lifecycle-dispatch.js";
|
|
31
|
-
export type { AuthorizeFn, EntryRevisionInput, EntryRepoPort, ContentTypeLookupPort, OutboxPort, WatermarkPort, CreateEntryRequired, UpdateEntryRequired, PublishUnpublishEntryRequired, } from "./write-service.js";
|
|
32
|
-
export { createEntry, updateEntry, publishEntry, unpublishEntry } from "./write-service.js";
|
|
31
|
+
export type { AuthorizeFn, EntryRevisionInput, EntryRepoPort, ContentTypeLookupPort, OutboxPort, WatermarkPort, CreateEntryRequired, UpdateEntryRequired, PublishUnpublishEntryRequired, ImportEntryRequired, } from "./write-service.js";
|
|
32
|
+
export { createEntry, updateEntry, publishEntry, unpublishEntry, importEntry } from "./write-service.js";
|
|
33
33
|
/**
|
|
34
34
|
* The in-memory repository. Exported for the same reason `navigation` exports its own: a host's
|
|
35
35
|
* concrete adapter implements the port directly, and the in-memory implementation is the reference
|
package/dist/entries/index.js
CHANGED
|
@@ -12,7 +12,7 @@ export { validateFieldsAgainstSchema, selectVisibleEntryFields } from "./field-v
|
|
|
12
12
|
*/
|
|
13
13
|
export { ForbiddenError, EntryNotFoundError, ContentTypeNotFoundError, ContentTypeNotActiveError, EntrySlugConflictError, VersionConflictError, EntryFieldValidationError, } from "./errors.js";
|
|
14
14
|
export { ENTRY_LIFECYCLE_OP_NAMES, ENTRY_LIFECYCLE_OPS, parseEntryLifecycleOp, } from "./lifecycle-dispatch.js";
|
|
15
|
-
export { createEntry, updateEntry, publishEntry, unpublishEntry } from "./write-service.js";
|
|
15
|
+
export { createEntry, updateEntry, publishEntry, unpublishEntry, importEntry } from "./write-service.js";
|
|
16
16
|
/**
|
|
17
17
|
* The in-memory repository. Exported for the same reason `navigation` exports its own: a host's
|
|
18
18
|
* concrete adapter implements the port directly, and the in-memory implementation is the reference
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ClockPort } from "../core/ports.js";
|
|
2
|
-
import type { ActorIdentityInput, EntryRecord, OwningContentType, Result } from "./types.js";
|
|
2
|
+
import type { ActorIdentityInput, EntryRecord, EntryStatus, OwningContentType, Result } from "./types.js";
|
|
3
3
|
/**
|
|
4
4
|
* @file The `entries` write chokepoint: `createEntry`,
|
|
5
5
|
* `updateEntry`, `publishEntry`, `unpublishEntry`.
|
|
@@ -184,6 +184,63 @@ export interface UpdateEntryRequired {
|
|
|
184
184
|
export declare function updateEntry(required: UpdateEntryRequired): Promise<Result<{
|
|
185
185
|
entry: EntryRecord;
|
|
186
186
|
}, Error>>;
|
|
187
|
+
export interface ImportEntryRequired {
|
|
188
|
+
deps: {
|
|
189
|
+
entryRepo: EntryRepoPort;
|
|
190
|
+
contentTypeRepo: ContentTypeLookupPort;
|
|
191
|
+
clock: ClockPort;
|
|
192
|
+
authorize: AuthorizeFn;
|
|
193
|
+
outbox: OutboxPort;
|
|
194
|
+
watermark?: WatermarkPort;
|
|
195
|
+
onWritten?: (entry: EntryRecord) => Promise<void>;
|
|
196
|
+
};
|
|
197
|
+
input: ActorIdentityInput & {
|
|
198
|
+
workspaceId: string;
|
|
199
|
+
/** The source's own id, preserved verbatim — unlike `createEntry`, which always mints a fresh
|
|
200
|
+
* one. Anything referencing this entry by id (widgets, term assignments) needs it to survive
|
|
201
|
+
* a publish round trip unchanged (plan-publish-all-types-2026-09-25.md §0.3 "Identity"). */
|
|
202
|
+
id: string;
|
|
203
|
+
type: string;
|
|
204
|
+
slug: string;
|
|
205
|
+
title: string;
|
|
206
|
+
/** Caller-given, unlike `createEntry`'s forced `"draft"` — an import carries the source's real
|
|
207
|
+
* status across. */
|
|
208
|
+
status: EntryStatus;
|
|
209
|
+
fieldsJson: unknown;
|
|
210
|
+
bodyJson?: unknown;
|
|
211
|
+
/** Caller-given, unlike `createEntry`'s forced `null`. */
|
|
212
|
+
publishedAt: string | null;
|
|
213
|
+
/**
|
|
214
|
+
* The publish factory's three-way CAS contract: `undefined` means "no row with this id may
|
|
215
|
+
* already exist" (an import-as-create); a number means "a row with this id must exist and be
|
|
216
|
+
* at exactly this version" (an import-as-update). Both cases end in `VersionConflictError` on
|
|
217
|
+
* mismatch, never a silent create-over-existing or a silent no-op.
|
|
218
|
+
*/
|
|
219
|
+
expectedVersion: number | undefined;
|
|
220
|
+
/** The `ext` sub-key `fieldsJson` is namespaced under. Defaults to `"site"` — see `field-validation.ts`'s `validateFieldsAgainstSchema`. */
|
|
221
|
+
owner?: string | undefined;
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Slice J1 (plan-publish-all-types-2026-09-25.md) — the publish-content import chokepoint for
|
|
226
|
+
* `entries`. Combines `createEntry`'s owning-type/field-validation guards with `updateEntry`'s
|
|
227
|
+
* version-CAS discipline, but preserves the caller's own `id`/`status`/`publishedAt` instead of
|
|
228
|
+
* minting a fresh id and forcing `draft`/`null` — the two behaviors a straight `createEntry` call
|
|
229
|
+
* cannot produce, and the reason this is a new function rather than a wider `createEntry`.
|
|
230
|
+
*
|
|
231
|
+
* A tombstoned owning type refuses the import (mirrors `resolveExistingEntryForTransition`'s
|
|
232
|
+
* REQ-28 rule); unlike `createEntry`'s REQ-10, a `deprecated` owning type does NOT block an
|
|
233
|
+
* import — REQ-10 exists to stop new manual authoring against a type an operator is winding down,
|
|
234
|
+
* not to stop a publish run from keeping an already-existing entry's already-existing type in
|
|
235
|
+
* sync. Slug uniqueness is deliberately not checked here: the publish factory's own `address`
|
|
236
|
+
* precheck (config.address, F1) owns that refusal before `importEntry` is ever called.
|
|
237
|
+
*
|
|
238
|
+
* @complexity O(1) plus one content-type read, one field-validation pass, one id lookup, and one
|
|
239
|
+
* same-tx write pair.
|
|
240
|
+
*/
|
|
241
|
+
export declare function importEntry(required: ImportEntryRequired): Promise<Result<{
|
|
242
|
+
entry: EntryRecord;
|
|
243
|
+
}, Error>>;
|
|
187
244
|
export interface PublishUnpublishEntryRequired {
|
|
188
245
|
deps: ExistingEntryTransitionDeps;
|
|
189
246
|
input: ActorIdentityInput & {
|
|
@@ -150,6 +150,93 @@ export async function updateEntry(required) {
|
|
|
150
150
|
await deps.outbox.enqueue({ name: "entry.updated", payload: { workspaceId: input.workspaceId, entryId: current.id } });
|
|
151
151
|
return { ok: true, value: { entry: updated } };
|
|
152
152
|
}
|
|
153
|
+
/**
|
|
154
|
+
* Slice J1 (plan-publish-all-types-2026-09-25.md) — the publish-content import chokepoint for
|
|
155
|
+
* `entries`. Combines `createEntry`'s owning-type/field-validation guards with `updateEntry`'s
|
|
156
|
+
* version-CAS discipline, but preserves the caller's own `id`/`status`/`publishedAt` instead of
|
|
157
|
+
* minting a fresh id and forcing `draft`/`null` — the two behaviors a straight `createEntry` call
|
|
158
|
+
* cannot produce, and the reason this is a new function rather than a wider `createEntry`.
|
|
159
|
+
*
|
|
160
|
+
* A tombstoned owning type refuses the import (mirrors `resolveExistingEntryForTransition`'s
|
|
161
|
+
* REQ-28 rule); unlike `createEntry`'s REQ-10, a `deprecated` owning type does NOT block an
|
|
162
|
+
* import — REQ-10 exists to stop new manual authoring against a type an operator is winding down,
|
|
163
|
+
* not to stop a publish run from keeping an already-existing entry's already-existing type in
|
|
164
|
+
* sync. Slug uniqueness is deliberately not checked here: the publish factory's own `address`
|
|
165
|
+
* precheck (config.address, F1) owns that refusal before `importEntry` is ever called.
|
|
166
|
+
*
|
|
167
|
+
* @complexity O(1) plus one content-type read, one field-validation pass, one id lookup, and one
|
|
168
|
+
* same-tx write pair.
|
|
169
|
+
*/
|
|
170
|
+
export async function importEntry(required) {
|
|
171
|
+
const { deps, input } = required;
|
|
172
|
+
const authResult = await deps.authorize({ principalId: input.actorId, permission: "admin.collections.manage", workspaceId: input.workspaceId, entityType: "entry" });
|
|
173
|
+
if (!authResult.allowed) {
|
|
174
|
+
return { ok: false, error: new ForbiddenError(`principal '${input.actorId}' cannot import entry '${input.id}' (${authResult.reason})`) };
|
|
175
|
+
}
|
|
176
|
+
const contentType = await deps.contentTypeRepo.findByKey({ workspaceId: input.workspaceId, key: input.type });
|
|
177
|
+
if (!contentType || contentType.workspaceId !== input.workspaceId) {
|
|
178
|
+
return { ok: false, error: new ContentTypeNotFoundError(`content type '${input.type}' was not found in workspace '${input.workspaceId}'`) };
|
|
179
|
+
}
|
|
180
|
+
if (contentType.status === "tombstone") {
|
|
181
|
+
return { ok: false, error: new ContentTypeNotActiveError(`content type '${input.type}' is tombstoned; entries cannot be imported into it`) };
|
|
182
|
+
}
|
|
183
|
+
const validation = validateFieldsAgainstSchema({ schema: contentType.fields, fieldsJson: input.fieldsJson, owner: input.owner });
|
|
184
|
+
if (!validation.valid) {
|
|
185
|
+
return { ok: false, error: new EntryFieldValidationError(validation.fieldErrors) };
|
|
186
|
+
}
|
|
187
|
+
const existing = await deps.entryRepo.findById({ workspaceId: input.workspaceId, id: input.id });
|
|
188
|
+
if (input.expectedVersion === undefined) {
|
|
189
|
+
if (existing) {
|
|
190
|
+
return { ok: false, error: new VersionConflictError(`entry '${input.id}' already exists in workspace '${input.workspaceId}', but no expectedVersion was supplied for import`) };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
if (!existing) {
|
|
195
|
+
return { ok: false, error: new VersionConflictError(`expected version ${input.expectedVersion} for entry '${input.id}', but no such entry exists`) };
|
|
196
|
+
}
|
|
197
|
+
if (existing.version !== input.expectedVersion) {
|
|
198
|
+
return { ok: false, error: new VersionConflictError(`expected version ${input.expectedVersion} for entry '${input.id}', found ${existing.version}`) };
|
|
199
|
+
}
|
|
200
|
+
// Same id, different type means this is not the row the caller's plan was made against; a
|
|
201
|
+
// silent type move would also dodge the REQ-28 tombstone check on the entry's REAL type.
|
|
202
|
+
if (existing.type !== input.type) {
|
|
203
|
+
return { ok: false, error: new VersionConflictError(`entry '${input.id}' is of type '${existing.type}', not '${input.type}'; an import cannot change an entry's type`) };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const now = deps.clock.nowIso();
|
|
207
|
+
const entry = {
|
|
208
|
+
id: input.id,
|
|
209
|
+
workspaceId: input.workspaceId,
|
|
210
|
+
type: input.type,
|
|
211
|
+
slug: input.slug,
|
|
212
|
+
status: input.status,
|
|
213
|
+
title: input.title,
|
|
214
|
+
bodyJson: input.bodyJson ?? null,
|
|
215
|
+
fieldsJson: input.fieldsJson,
|
|
216
|
+
publishedAt: input.publishedAt,
|
|
217
|
+
createdAt: existing?.createdAt ?? now,
|
|
218
|
+
updatedAt: now,
|
|
219
|
+
version: existing ? existing.version + 1 : 1,
|
|
220
|
+
};
|
|
221
|
+
await deps.entryRepo.transaction(async () => {
|
|
222
|
+
await deps.entryRepo.save(entry);
|
|
223
|
+
await deps.entryRepo.appendRevision({
|
|
224
|
+
entryId: entry.id,
|
|
225
|
+
workspaceId: input.workspaceId,
|
|
226
|
+
op: existing ? "update" : "create",
|
|
227
|
+
stateJson: entry,
|
|
228
|
+
actorId: input.actorId,
|
|
229
|
+
...delegationFields(input),
|
|
230
|
+
recordedAt: now,
|
|
231
|
+
});
|
|
232
|
+
if (deps.watermark)
|
|
233
|
+
await deps.watermark.stampWatermark({ workspaceId: input.workspaceId });
|
|
234
|
+
if (deps.onWritten)
|
|
235
|
+
await deps.onWritten(entry);
|
|
236
|
+
});
|
|
237
|
+
await deps.outbox.enqueue({ name: "entry.imported", payload: { workspaceId: input.workspaceId, entryId: entry.id, type: input.type, slug: input.slug } });
|
|
238
|
+
return { ok: true, value: { entry } };
|
|
239
|
+
}
|
|
153
240
|
async function transitionEntryStatus(required, target) {
|
|
154
241
|
const { deps, input } = required;
|
|
155
242
|
const resolved = await resolveExistingEntryForTransition(deps, input);
|
package/dist/taxonomy/index.d.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* has no imports at all: the ceremony is a parameter, not a dependency.
|
|
20
20
|
*/
|
|
21
21
|
export { TaxonomyNotApplicableError, WorkspaceMismatchError, ContentTypeMismatchError, TaxonomyNotHierarchicalError, ParentCrossTaxonomyError, TermNotFoundError, HierarchyCycleDetectedError, wouldCreateCycle, validateContentJoin, validateHierarchyAssignment, type TermTreeLookup, } from "./validation-chain.js";
|
|
22
|
-
export { TAXONOMY_ALLOWED_CONTENT_TYPES, isContentTypeOnAllowList, TaxonomyRecordNotFoundError, TermRecordNotFoundError, ContentRecordNotFoundError, TermHasAssignedContentError, TermHasChildTermsError, TaxonomyHasAssignedContentError, createTaxonomy, createTerm, renameTerm, assignTerms, unassignTerms, deleteTerm, deleteTaxonomy, onContentDeleted, type AuthorizeFn, type Taxonomy, type Term, type TaxonomyRepoPort, type TermRepoPort, type EntryTermRepoPort, type ContentLookupPort, type ContentTypeTaxonomyPolicyPort, type UnassignableEntryTermRepoPort, type TaxonomyRevisionRow, type TaxonomyRevisionRepoPort, type ClockPort, type IdGeneratorPort, type WriteServiceDeps, type CreateTaxonomyRequired, type CreateTermRequired, type RenameTermRequired, type AssignTermsRequired, type UnassignTermsRequired, type DeleteTermRequired, type DeleteTaxonomyRequired, type DeletableTaxonomyRepoPort, type DeletableTermRepoPort, type AssignmentCountEntryTermRepoPort, type TransactionalRepoPort, type EntryTermsCleanupPort, type OnContentDeletedRequired, } from "./write-service.js";
|
|
22
|
+
export { TAXONOMY_ALLOWED_CONTENT_TYPES, isContentTypeOnAllowList, TaxonomyRecordNotFoundError, TermRecordNotFoundError, ContentRecordNotFoundError, TermHasAssignedContentError, TermHasChildTermsError, TaxonomyHasAssignedContentError, TaxonomyVersionConflictError, createTaxonomy, createTerm, renameTerm, assignTerms, unassignTerms, deleteTerm, deleteTaxonomy, onContentDeleted, importTaxonomy, importTerm, type AuthorizeFn, type Taxonomy, type Term, type TaxonomyRepoPort, type TermRepoPort, type EntryTermRepoPort, type ContentLookupPort, type ContentTypeTaxonomyPolicyPort, type UnassignableEntryTermRepoPort, type TaxonomyRevisionRow, type TaxonomyRevisionRepoPort, type ClockPort, type IdGeneratorPort, type WriteServiceDeps, type CreateTaxonomyRequired, type CreateTermRequired, type RenameTermRequired, type AssignTermsRequired, type UnassignTermsRequired, type DeleteTermRequired, type DeleteTaxonomyRequired, type DeletableTaxonomyRepoPort, type DeletableTermRepoPort, type AssignmentCountEntryTermRepoPort, type TransactionalRepoPort, type EntryTermsCleanupPort, type OnContentDeletedRequired, type ImportableTaxonomyRepoPort, type ImportableTermRepoPort, type ImportTaxonomyRequired, type ImportTermRequired, } from "./write-service.js";
|
|
23
23
|
export { listTaxonomiesWithTerms, type TaxonomyListPort, type TermListPort, type TaxonomyWithTerms, } from "./list.js";
|
|
24
24
|
/**
|
|
25
25
|
* The domain half of the `mergeTerm` ceremony — see this file's header for why the gateway itself
|
package/dist/taxonomy/index.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* has no imports at all: the ceremony is a parameter, not a dependency.
|
|
20
20
|
*/
|
|
21
21
|
export { TaxonomyNotApplicableError, WorkspaceMismatchError, ContentTypeMismatchError, TaxonomyNotHierarchicalError, ParentCrossTaxonomyError, TermNotFoundError, HierarchyCycleDetectedError, wouldCreateCycle, validateContentJoin, validateHierarchyAssignment, } from "./validation-chain.js";
|
|
22
|
-
export { TAXONOMY_ALLOWED_CONTENT_TYPES, isContentTypeOnAllowList, TaxonomyRecordNotFoundError, TermRecordNotFoundError, ContentRecordNotFoundError, TermHasAssignedContentError, TermHasChildTermsError, TaxonomyHasAssignedContentError, createTaxonomy, createTerm, renameTerm, assignTerms, unassignTerms, deleteTerm, deleteTaxonomy, onContentDeleted, } from "./write-service.js";
|
|
22
|
+
export { TAXONOMY_ALLOWED_CONTENT_TYPES, isContentTypeOnAllowList, TaxonomyRecordNotFoundError, TermRecordNotFoundError, ContentRecordNotFoundError, TermHasAssignedContentError, TermHasChildTermsError, TaxonomyHasAssignedContentError, TaxonomyVersionConflictError, createTaxonomy, createTerm, renameTerm, assignTerms, unassignTerms, deleteTerm, deleteTaxonomy, onContentDeleted, importTaxonomy, importTerm, } from "./write-service.js";
|
|
23
23
|
export { listTaxonomiesWithTerms, } from "./list.js";
|
|
24
24
|
/**
|
|
25
25
|
* The domain half of the `mergeTerm` ceremony — see this file's header for why the gateway itself
|
|
@@ -13,6 +13,11 @@ export declare class InMemoryTaxonomyRepo implements TaxonomyRepoPort, TaxonomyL
|
|
|
13
13
|
hierarchical: boolean;
|
|
14
14
|
allowList?: string[] | undefined;
|
|
15
15
|
} | null>;
|
|
16
|
+
/** `ImportableTaxonomyRepoPort` — additive capability for `importTaxonomy`'s CAS check and
|
|
17
|
+
* update-in-place, see `write-service.ts`'s doc comment on that interface. */
|
|
18
|
+
findByIdFull(id: string): Promise<Taxonomy | null>;
|
|
19
|
+
/** `ImportableTaxonomyRepoPort` — writes an existing row back in place, preserving its id. */
|
|
20
|
+
update(row: Taxonomy): Promise<unknown>;
|
|
16
21
|
list(): Promise<Taxonomy[]>;
|
|
17
22
|
/** `DeletableTaxonomyRepoPort` — additive capability for `deleteTaxonomy`, see
|
|
18
23
|
* `write-service.ts`'s doc comment on that interface. */
|
|
@@ -44,6 +49,9 @@ export declare class InMemoryTermRepo implements TermRepoPort, TermListPort {
|
|
|
44
49
|
taxonomyId: string;
|
|
45
50
|
name?: string | undefined;
|
|
46
51
|
} | null>;
|
|
52
|
+
/** `ImportableTermRepoPort` — additive capability for `importTerm`'s CAS check, see
|
|
53
|
+
* `write-service.ts`'s doc comment on that interface. */
|
|
54
|
+
findByIdFull(id: string): Promise<Term | null>;
|
|
47
55
|
listByTaxonomy(params: {
|
|
48
56
|
taxonomyId: string;
|
|
49
57
|
}): Promise<Term[]>;
|
|
@@ -12,6 +12,17 @@ export class InMemoryTaxonomyRepo {
|
|
|
12
12
|
const row = this.rows.get(id);
|
|
13
13
|
return row ? { id: row.id, hierarchical: row.hierarchical } : null;
|
|
14
14
|
}
|
|
15
|
+
/** `ImportableTaxonomyRepoPort` — additive capability for `importTaxonomy`'s CAS check and
|
|
16
|
+
* update-in-place, see `write-service.ts`'s doc comment on that interface. */
|
|
17
|
+
async findByIdFull(id) {
|
|
18
|
+
const row = this.rows.get(id);
|
|
19
|
+
return row ? { ...row } : null;
|
|
20
|
+
}
|
|
21
|
+
/** `ImportableTaxonomyRepoPort` — writes an existing row back in place, preserving its id. */
|
|
22
|
+
async update(row) {
|
|
23
|
+
this.rows.set(row.id, { ...row });
|
|
24
|
+
return row;
|
|
25
|
+
}
|
|
15
26
|
async list() {
|
|
16
27
|
return [...this.rows.values()].map((row) => ({ ...row }));
|
|
17
28
|
}
|
|
@@ -54,6 +65,12 @@ export class InMemoryTermRepo {
|
|
|
54
65
|
const row = this.rows.get(id);
|
|
55
66
|
return row ? { id: row.id, taxonomyId: row.taxonomyId, name: row.name } : null;
|
|
56
67
|
}
|
|
68
|
+
/** `ImportableTermRepoPort` — additive capability for `importTerm`'s CAS check, see
|
|
69
|
+
* `write-service.ts`'s doc comment on that interface. */
|
|
70
|
+
async findByIdFull(id) {
|
|
71
|
+
const row = this.rows.get(id);
|
|
72
|
+
return row ? { ...row } : null;
|
|
73
|
+
}
|
|
57
74
|
async listByTaxonomy(params) {
|
|
58
75
|
return [...this.rows.values()].filter((row) => row.taxonomyId === params.taxonomyId).map((row) => ({ ...row }));
|
|
59
76
|
}
|
|
@@ -124,7 +124,7 @@ export interface TaxonomyRevisionRow {
|
|
|
124
124
|
* `"deprecate"` (that ceremony's own header explains why widening this union was out of its
|
|
125
125
|
* scope), a delete has no honest fit among the other four ops, so this widens the union rather
|
|
126
126
|
* than overloading an existing member. */
|
|
127
|
-
op: "create" | "rename" | "reparent" | "deprecate" | "delete";
|
|
127
|
+
op: "create" | "rename" | "reparent" | "deprecate" | "delete" | "import";
|
|
128
128
|
previousState: Record<string, unknown> | null;
|
|
129
129
|
actorId: string;
|
|
130
130
|
recordedAt: string;
|
|
@@ -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
|
-
|
|
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.
|
|
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": {
|