@jini-ai/cms 0.4.0 → 0.4.2
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/CHANGELOG.md +7 -0
- package/dist/media/blob-store.fs.d.ts +4 -0
- package/dist/media/blob-store.fs.js +27 -0
- package/dist/media/blob-store.memory.d.ts +3 -0
- package/dist/media/blob-store.memory.js +3 -0
- package/dist/media/created-by.d.ts +15 -0
- package/dist/media/created-by.js +20 -0
- package/dist/media/index.d.ts +1 -0
- package/dist/media/index.js +1 -0
- package/dist/media/media-service.d.ts +1 -1
- package/dist/media/media-service.js +1 -0
- package/dist/media/ports.d.ts +12 -2
- package/dist/media/repo.memory.js +2 -1
- package/dist/media/tool-registrations.js +1 -0
- package/dist/media/types.d.ts +4 -0
- package/dist/navigation/menu-service.js +6 -0
- package/dist/navigation/types.d.ts +5 -0
- package/dist/settings/agent-value-write-tools.js +2 -2
- package/dist/settings/tool-registrations.d.ts +5 -3
- package/dist/taxonomy/write-service.d.ts +4 -2
- package/dist/taxonomy/write-service.js +8 -3
- package/dist/trash/write-service.js +19 -12
- package/package.json +2 -2
- package/dist/core/tools/registration-kit.d.ts +0 -346
- package/dist/core/tools/registration-kit.js +0 -426
- package/dist/server/index.d.ts +0 -28
- package/dist/server/index.js +0 -28
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.2 — 2026-10-04
|
|
4
|
+
|
|
5
|
+
- Media: optional `BlobStorePort.sizeOf` reports original blob bytes (filesystem store memoizes `stat`); `MediaRecord.createdBy` is stamped at creation and write-once.
|
|
6
|
+
- Navigation: entry targets carry optional `lastKnownHref` and `entryType` hints.
|
|
7
|
+
- Taxonomy: `unassignTerms` returns the removed ids. Trash: a no-op trash/restore does not notify again.
|
|
8
|
+
- Internal `@jini-ai/*` dependencies are caret ranges (`workspace:^`) instead of exact pins, so a host on a newer patch resolves a single copy without an override.
|
|
9
|
+
|
|
3
10
|
## Unreleased — Trash host adoption
|
|
4
11
|
|
|
5
12
|
- Preserve `adapter-unavailable` for absent domains before checking entity policy; selected purge
|
|
@@ -5,6 +5,7 @@ export interface LocalFsBlobStoreDeps {
|
|
|
5
5
|
}
|
|
6
6
|
export declare class LocalFsBlobStore implements BlobStorePort {
|
|
7
7
|
private readonly rootDir;
|
|
8
|
+
private readonly sizesByKey;
|
|
8
9
|
constructor(deps: LocalFsBlobStoreDeps);
|
|
9
10
|
private resolvePath;
|
|
10
11
|
put(input: PutBlobInput): Promise<{
|
|
@@ -28,6 +29,9 @@ export declare class LocalFsBlobStore implements BlobStorePort {
|
|
|
28
29
|
get(input: {
|
|
29
30
|
storageKey: string;
|
|
30
31
|
}): Promise<Uint8Array>;
|
|
32
|
+
sizeOf({ storageKey }: {
|
|
33
|
+
storageKey: string;
|
|
34
|
+
}, _optional?: Record<string, never>): Promise<number | null>;
|
|
31
35
|
/**
|
|
32
36
|
* `ENOENT` (the path genuinely does not exist) resolves to `false`. Any other `stat()` failure
|
|
33
37
|
* (`EACCES` permission denied, `ENOTDIR`, `EIO`, ...) is rethrown raw — same "catch the one
|
|
@@ -16,6 +16,9 @@ import { dirname, join } from "node:path";
|
|
|
16
16
|
import { computeBlobStorageKey } from "./blob-key.js";
|
|
17
17
|
export class LocalFsBlobStore {
|
|
18
18
|
rootDir;
|
|
19
|
+
// Storage keys include workspace + content hash: immutable bytes make successful sizes
|
|
20
|
+
// reusable for this process. Promises coalesce concurrent reads of the same hash as well.
|
|
21
|
+
sizesByKey = new Map();
|
|
19
22
|
constructor(deps) {
|
|
20
23
|
this.rootDir = deps.rootDir;
|
|
21
24
|
}
|
|
@@ -27,6 +30,7 @@ export class LocalFsBlobStore {
|
|
|
27
30
|
const path = this.resolvePath(storageKey);
|
|
28
31
|
await mkdir(dirname(path), { recursive: true });
|
|
29
32
|
await writeFile(path, input.bytes);
|
|
33
|
+
this.sizesByKey.delete(storageKey);
|
|
30
34
|
return { storageKey };
|
|
31
35
|
}
|
|
32
36
|
/**
|
|
@@ -46,6 +50,7 @@ export class LocalFsBlobStore {
|
|
|
46
50
|
await mkdir(dirname(path), { recursive: true });
|
|
47
51
|
try {
|
|
48
52
|
await writeFile(path, input.bytes, { flag: "wx" });
|
|
53
|
+
this.sizesByKey.delete(storageKey);
|
|
49
54
|
return { storageKey, written: true };
|
|
50
55
|
}
|
|
51
56
|
catch (err) {
|
|
@@ -58,6 +63,27 @@ export class LocalFsBlobStore {
|
|
|
58
63
|
async get(input) {
|
|
59
64
|
return readFile(this.resolvePath(input.storageKey));
|
|
60
65
|
}
|
|
66
|
+
sizeOf({ storageKey }, _optional = {}) {
|
|
67
|
+
const cached = this.sizesByKey.get(storageKey);
|
|
68
|
+
if (cached)
|
|
69
|
+
return cached;
|
|
70
|
+
const pending = stat(this.resolvePath(storageKey)).then((info) => info.size).catch((err) => {
|
|
71
|
+
if (err.code === "ENOENT")
|
|
72
|
+
return null;
|
|
73
|
+
throw err;
|
|
74
|
+
});
|
|
75
|
+
this.sizesByKey.set(storageKey, pending);
|
|
76
|
+
// Missing/restored blobs and transient failures must be retried. Identity guards prevent
|
|
77
|
+
// an older stat from clearing a newer entry after a write/remove invalidated the cache.
|
|
78
|
+
void pending.then((size) => {
|
|
79
|
+
if (size === null && this.sizesByKey.get(storageKey) === pending)
|
|
80
|
+
this.sizesByKey.delete(storageKey);
|
|
81
|
+
}, () => {
|
|
82
|
+
if (this.sizesByKey.get(storageKey) === pending)
|
|
83
|
+
this.sizesByKey.delete(storageKey);
|
|
84
|
+
});
|
|
85
|
+
return pending;
|
|
86
|
+
}
|
|
61
87
|
/**
|
|
62
88
|
* `ENOENT` (the path genuinely does not exist) resolves to `false`. Any other `stat()` failure
|
|
63
89
|
* (`EACCES` permission denied, `ENOTDIR`, `EIO`, ...) is rethrown raw — same "catch the one
|
|
@@ -79,6 +105,7 @@ export class LocalFsBlobStore {
|
|
|
79
105
|
}
|
|
80
106
|
async remove(input) {
|
|
81
107
|
await rm(this.resolvePath(input.storageKey), { force: true });
|
|
108
|
+
this.sizesByKey.delete(input.storageKey);
|
|
82
109
|
}
|
|
83
110
|
}
|
|
84
111
|
//# sourceMappingURL=blob-store.fs.js.map
|
|
@@ -24,6 +24,9 @@ export declare class InMemoryBlobStore implements BlobStorePort {
|
|
|
24
24
|
exists(input: {
|
|
25
25
|
storageKey: string;
|
|
26
26
|
}): Promise<boolean>;
|
|
27
|
+
sizeOf({ storageKey }: {
|
|
28
|
+
storageKey: string;
|
|
29
|
+
}, _optional?: Record<string, never>): Promise<number | null>;
|
|
27
30
|
remove(input: {
|
|
28
31
|
storageKey: string;
|
|
29
32
|
}): Promise<void>;
|
|
@@ -29,6 +29,9 @@ export class InMemoryBlobStore {
|
|
|
29
29
|
async exists(input) {
|
|
30
30
|
return this.bytesByKey.has(input.storageKey);
|
|
31
31
|
}
|
|
32
|
+
async sizeOf({ storageKey }, _optional = {}) {
|
|
33
|
+
return this.bytesByKey.get(storageKey)?.byteLength ?? null;
|
|
34
|
+
}
|
|
32
35
|
async remove(input) {
|
|
33
36
|
this.bytesByKey.delete(input.storageKey);
|
|
34
37
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { MediaRecord } from "./types.js";
|
|
2
|
+
import type { MediaRepoPort } from "./ports.js";
|
|
3
|
+
/** Bind creation writes to one authenticated caller without shared mutable actor state.
|
|
4
|
+
* Useful when adapting an older upload service that predates asset-level attribution. */
|
|
5
|
+
export declare function mediaRepoWithCreator(required: {
|
|
6
|
+
mediaRepo: MediaRepoPort;
|
|
7
|
+
principalId: string;
|
|
8
|
+
}, _optional?: Record<string, never>): MediaRepoPort;
|
|
9
|
+
/** An update retains creation attribution, even when the original creator is unknown.
|
|
10
|
+
* Omitting an unknown field preserves compatibility with legacy serialized records. */
|
|
11
|
+
export declare function preserveMediaCreator(required: {
|
|
12
|
+
record: MediaRecord;
|
|
13
|
+
original: MediaRecord;
|
|
14
|
+
}, _optional?: Record<string, never>): MediaRecord;
|
|
15
|
+
//# sourceMappingURL=created-by.d.ts.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Bind creation writes to one authenticated caller without shared mutable actor state.
|
|
2
|
+
* Useful when adapting an older upload service that predates asset-level attribution. */
|
|
3
|
+
export function mediaRepoWithCreator(required, _optional = {}) {
|
|
4
|
+
const { mediaRepo, principalId } = required;
|
|
5
|
+
return {
|
|
6
|
+
findById: input => mediaRepo.findById(input),
|
|
7
|
+
findBySlug: input => mediaRepo.findBySlug(input),
|
|
8
|
+
list: input => mediaRepo.list(input),
|
|
9
|
+
remove: input => mediaRepo.remove(input),
|
|
10
|
+
save: record => mediaRepo.save({ ...record, createdBy: principalId }),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
/** An update retains creation attribution, even when the original creator is unknown.
|
|
14
|
+
* Omitting an unknown field preserves compatibility with legacy serialized records. */
|
|
15
|
+
export function preserveMediaCreator(required, _optional = {}) {
|
|
16
|
+
const { createdBy: _ignored, ...metadata } = required.record;
|
|
17
|
+
return { ...metadata, ...(required.original.createdBy !== undefined
|
|
18
|
+
? { createdBy: required.original.createdBy } : {}) };
|
|
19
|
+
}
|
|
20
|
+
//# sourceMappingURL=created-by.js.map
|
package/dist/media/index.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ export type { MediaStatus, MediaSource, MediaRecord, AssetBlobStatus, AssetBlobR
|
|
|
15
15
|
export { MediaNotFoundError, MediaValidationError, MediaConflictError, MediaSourceImmutableError, MediaStillReferencedError, } from "./types.js";
|
|
16
16
|
export type { MediaRepoPort, AssetBlobRepoPort, AssetRenditionRepoPort, BlobStorePort, BlobGcJournalRepoPort, PutBlobInput, TransformDefinitionRepoPort, } from "./ports.js";
|
|
17
17
|
export { computeBlobStorageKey } from "./blob-key.js";
|
|
18
|
+
export { preserveMediaCreator, mediaRepoWithCreator } from "./created-by.js";
|
|
18
19
|
export { MEDIA_HTML_ATTRIBUTE_ALLOWED_NAMES, isAllowedMediaHtmlAttributeName, parseMediaHtmlAttributes, describeMediaHtmlAttributeError, type MediaHtmlAttributeRejectionReason, type MediaHtmlAttributeError, type ParsedMediaHtmlAttributes, } from "./html-attributes.js";
|
|
19
20
|
export { InMemoryMediaRepo, InMemoryAssetBlobRepo, InMemoryAssetRenditionRepo, InMemoryBlobGcJournalRepo, InMemoryTransformDefinitionRepo, } from "./repo.memory.js";
|
|
20
21
|
export { withSha256Lock } from "./blob-gc-lock.js";
|
package/dist/media/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { MediaNotFoundError, MediaValidationError, MediaConflictError, MediaSourceImmutableError, MediaStillReferencedError, } from "./types.js";
|
|
2
2
|
export { computeBlobStorageKey } from "./blob-key.js";
|
|
3
|
+
export { preserveMediaCreator, mediaRepoWithCreator } from "./created-by.js";
|
|
3
4
|
export { MEDIA_HTML_ATTRIBUTE_ALLOWED_NAMES, isAllowedMediaHtmlAttributeName, parseMediaHtmlAttributes, describeMediaHtmlAttributeError, } from "./html-attributes.js";
|
|
4
5
|
export { InMemoryMediaRepo, InMemoryAssetBlobRepo, InMemoryAssetRenditionRepo, InMemoryBlobGcJournalRepo, InMemoryTransformDefinitionRepo, } from "./repo.memory.js";
|
|
5
6
|
export { withSha256Lock } from "./blob-gc-lock.js";
|
|
@@ -60,7 +60,7 @@ export interface UploadMediaInput {
|
|
|
60
60
|
alt?: string | undefined;
|
|
61
61
|
caption?: string | undefined;
|
|
62
62
|
credit?: string | undefined;
|
|
63
|
-
/**
|
|
63
|
+
/** Acting principal, stamped on each media row and on a newly created asset_blobs row. */
|
|
64
64
|
createdByPrincipal: string;
|
|
65
65
|
}
|
|
66
66
|
export interface UploadMediaDeps {
|
package/dist/media/ports.d.ts
CHANGED
|
@@ -13,13 +13,15 @@
|
|
|
13
13
|
*
|
|
14
14
|
* `BlobStorePort`'s surface is deliberately narrower than the full
|
|
15
15
|
* design: no `capabilities()`/`createPresignedUpload()` (the presign surface is
|
|
16
|
-
* explicitly deferred by this task's scope). It
|
|
17
|
-
*
|
|
16
|
+
* explicitly deferred by this task's scope). It extends the original minimal
|
|
17
|
+
* 3 (`put`/`get`/`exists`) with `remove()`. That addition is
|
|
18
18
|
* necessary to implement `purgeMedia`'s explicitly-requested "best-effort blob
|
|
19
19
|
* unlink" (see `media-service.ts`) — without a way to delete bytes, purge could
|
|
20
20
|
* only ever delete rows, never reclaim storage. This is disclosed here, not
|
|
21
21
|
* silently smuggled in: it is still far short of the real journaled
|
|
22
22
|
* GC protocol (no epochs, no `BEGIN IMMEDIATE`, no crash-safety guarantee).
|
|
23
|
+
* `putIfAbsent` adds atomic create-only writes; optional `sizeOf` reports original bytes
|
|
24
|
+
* without reading payloads (successful filesystem sizes are cached by content-addressed key).
|
|
23
25
|
*
|
|
24
26
|
* Interfaces only — no feature logic.
|
|
25
27
|
*/
|
|
@@ -162,6 +164,14 @@ export interface BlobStorePort {
|
|
|
162
164
|
get(input: {
|
|
163
165
|
storageKey: string;
|
|
164
166
|
}): Promise<Uint8Array>;
|
|
167
|
+
/**
|
|
168
|
+
* Original byte count without reading the payload; null only when the key is absent.
|
|
169
|
+
* Other storage failures reject. Optional for older/custom adapters; consumers must report
|
|
170
|
+
* unknown size when unavailable, never download an entire blob just to count bytes.
|
|
171
|
+
*/
|
|
172
|
+
sizeOf?(required: {
|
|
173
|
+
storageKey: string;
|
|
174
|
+
}, optional?: Record<string, never>): Promise<number | null>;
|
|
165
175
|
exists(input: {
|
|
166
176
|
storageKey: string;
|
|
167
177
|
}): Promise<boolean>;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { preserveMediaCreator } from "./created-by.js";
|
|
1
2
|
export class InMemoryMediaRepo {
|
|
2
3
|
rows;
|
|
3
4
|
constructor(requiredArgs, optionalArgs = {}) {
|
|
@@ -19,7 +20,7 @@ export class InMemoryMediaRepo {
|
|
|
19
20
|
this.rows.push(record);
|
|
20
21
|
return;
|
|
21
22
|
}
|
|
22
|
-
this.rows[index] = record;
|
|
23
|
+
this.rows[index] = preserveMediaCreator({ record, original: this.rows[index] });
|
|
23
24
|
}
|
|
24
25
|
async remove(required) {
|
|
25
26
|
this.rows = this.rows.filter((row) => !(row.workspaceId === required.workspaceId && row.id === required.id));
|
package/dist/media/types.d.ts
CHANGED
|
@@ -41,6 +41,10 @@ export interface MediaSource {
|
|
|
41
41
|
export interface MediaRecord {
|
|
42
42
|
id: UUID;
|
|
43
43
|
workspaceId: UUID;
|
|
44
|
+
/** Acting principal at asset creation, independent of the producer/model and editable credit.
|
|
45
|
+
* Optional/null for records written before attribution existed. Never inferred from a shared
|
|
46
|
+
* blob's owner: identical bytes may have been uploaded by different principals. */
|
|
47
|
+
createdBy?: string | null | undefined;
|
|
44
48
|
title: string;
|
|
45
49
|
/**
|
|
46
50
|
* Human-memorable, unique-per-workspace lookup key (owner-directed, 2026-09-07) — an ADDITIONAL
|
|
@@ -241,6 +241,12 @@ function validateTarget(target) {
|
|
|
241
241
|
if (!VALID_TARGET_KINDS.has(kind)) {
|
|
242
242
|
throw new MenuValidationError({ message: `unknown target kind '${kind}'` });
|
|
243
243
|
}
|
|
244
|
+
if (kind === "entryRef" && target.lastKnownHref !== undefined) {
|
|
245
|
+
const rawHref = target.lastKnownHref;
|
|
246
|
+
if (typeof rawHref !== "string" || !rawHref.trim() || !isAllowedHref({ rawHref })) {
|
|
247
|
+
throw new MenuValidationError({ message: `entryRef lastKnownHref must be ${ALLOWED_HREF_SHAPES_DESCRIPTION}.` });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
244
250
|
if (kind === "url") {
|
|
245
251
|
const rawHref = target.href;
|
|
246
252
|
if (typeof rawHref !== "string" || !rawHref.trim()) {
|
|
@@ -49,6 +49,11 @@ export type ReservedNavTargetKind = "dynamicQuery" | "content";
|
|
|
49
49
|
/** A link to another content entry (page/post/media/…). Integrity-tracked. */
|
|
50
50
|
export interface NavEntryTarget {
|
|
51
51
|
readonly kind: "entryRef";
|
|
52
|
+
/** Optional content-type hint for editors; reference integrity and routing still use entryId. */
|
|
53
|
+
readonly entryType?: string;
|
|
54
|
+
/** Author's last known URL, retained for exports and routing misses. A known unavailable
|
|
55
|
+
* entry must remain hidden; this snapshot never overrides an availability decision. */
|
|
56
|
+
readonly lastKnownHref?: string;
|
|
52
57
|
/** Target entry id. Extracted to `entry_refs` at the write chokepoint. */
|
|
53
58
|
readonly entryId: UUID;
|
|
54
59
|
}
|
|
@@ -103,7 +103,7 @@ function rethrowWriteError(error, toolId, target, permission) {
|
|
|
103
103
|
* @complexity O(value size + rule count) plus bounded repository calls and a human wait.
|
|
104
104
|
* @throws ToolInputError for invalid input, denied keys, absent/declined/stale confirmation. */
|
|
105
105
|
export function buildSettingsValueWriteHandler(deps, toolId) {
|
|
106
|
-
return async (ctx) => {
|
|
106
|
+
return async (ctx, options) => {
|
|
107
107
|
const target = parseTarget(toolId, ctx.input);
|
|
108
108
|
const permission = deriveRequiredPermission({ scope: target.scope, callerPrincipalId: ctx.principal.id });
|
|
109
109
|
const isSet = toolId === "settings_set_value";
|
|
@@ -124,7 +124,7 @@ export function buildSettingsValueWriteHandler(deps, toolId) {
|
|
|
124
124
|
if (rule) {
|
|
125
125
|
if (!deps.confirmWrite)
|
|
126
126
|
throw new ToolInputError({ message: `${toolId}: '${rule.namespace}.${rule.key}' requires a human confirmation card: ${rule.reason}. The owner can change it in Settings.` });
|
|
127
|
-
const confirmed = await deps.confirmWrite({ ctx, toolId, namespace: rule.namespace, key: rule.key, scope: target.scope, previous: structuredClone(previous), ...(isSet ? { value: structuredClone(target.value) } : {}), reason: rule.reason });
|
|
127
|
+
const confirmed = await deps.confirmWrite({ ctx, toolId, namespace: rule.namespace, key: rule.key, scope: target.scope, previous: structuredClone(previous), ...(isSet ? { value: structuredClone(target.value) } : {}), reason: rule.reason }, options);
|
|
128
128
|
if (confirmed !== true)
|
|
129
129
|
throw new ToolInputError({ message: `${toolId}: the human did not confirm the change. Nothing was changed.` });
|
|
130
130
|
const current = await resolveTarget(deps, target, isSet);
|
|
@@ -11,7 +11,7 @@ import type { Clock } from "@jini-ai/core/primitives";
|
|
|
11
11
|
import type { AuthorizeFn } from "../core/commands/command.js";
|
|
12
12
|
import { type SettingsWriteConfirmation } from "./agent-value-write-tools.js";
|
|
13
13
|
import { type AgentSettingWriteRule } from "./agent-write-denylist.js";
|
|
14
|
-
import { type DerivedRiskByToolId, type ToolRegistration } from "@jini-ai/core";
|
|
14
|
+
import { type DerivedRiskByToolId, type ToolExecutionOptions, type ToolRegistration } from "@jini-ai/core";
|
|
15
15
|
import type { SettingsPrincipalLookupPort } from "./principal-lookup.js";
|
|
16
16
|
import type { SettingsRepoPort } from "./ports.js";
|
|
17
17
|
import { set as setSettingValue } from "./write-service.js";
|
|
@@ -33,8 +33,10 @@ export interface SettingsToolDeps {
|
|
|
33
33
|
/** Host extensions: explicit deny or human-confirmation rules, never agent inputs. */
|
|
34
34
|
extraDeniedSettings?: readonly AgentSettingWriteRule[];
|
|
35
35
|
extraConfirmationSettings?: readonly AgentSettingWriteRule[];
|
|
36
|
-
/** Must obtain an authenticated human card decision; absent means protected keys fail closed.
|
|
37
|
-
|
|
36
|
+
/** Must obtain an authenticated human card decision; absent means protected keys fail closed.
|
|
37
|
+
* `options` is the invoking transport's execution options (its `emitSurface`), forwarded from the
|
|
38
|
+
* tool handler's second argument: the card can only reach a human through that seam. */
|
|
39
|
+
confirmWrite?: (request: SettingsWriteConfirmation, options?: ToolExecutionOptions) => Promise<boolean>;
|
|
38
40
|
/** Host's validation wrapper; defaults to the package's existing set chokepoint. */
|
|
39
41
|
setValue?: typeof setSettingValue;
|
|
40
42
|
}
|
|
@@ -352,12 +352,14 @@ export interface UnassignTermsRequired {
|
|
|
352
352
|
* authorize -> validateAssignmentTarget -> write -> stamp -> outbox chokepoint as `assignTerms`,
|
|
353
353
|
* and — like `assignTerms` — never produces a `taxonomy_revisions` row ( disclosed
|
|
354
354
|
* narrowing applies equally to removal). Idempotent: unassigning a row that isn't there is a no-op,
|
|
355
|
-
* not an error
|
|
355
|
+
* not an error. Only removed ids are returned, stamped, and emitted; an entirely absent set is a no-op. Chosen over
|
|
356
356
|
* a "set terms" replace call — see the taxonomy plan's "What the docs settle" #3 for why replace
|
|
357
357
|
* risks silently erasing a concurrent assignment.
|
|
358
358
|
* See docs/decisions/DR-005-ordered-taxonomy-validation.md.
|
|
359
359
|
*/
|
|
360
|
-
export declare function unassignTerms(required: UnassignTermsRequired, _optional?: Record<string, never>): Promise<
|
|
360
|
+
export declare function unassignTerms(required: UnassignTermsRequired, _optional?: Record<string, never>): Promise<{
|
|
361
|
+
removedTermIds: string[];
|
|
362
|
+
}>;
|
|
361
363
|
export interface EntryTermsCleanupPort {
|
|
362
364
|
deleteByContent(params: {
|
|
363
365
|
workspaceId: string;
|
|
@@ -408,7 +408,7 @@ export async function assignTerms(required, _optional = {}) {
|
|
|
408
408
|
* authorize -> validateAssignmentTarget -> write -> stamp -> outbox chokepoint as `assignTerms`,
|
|
409
409
|
* and — like `assignTerms` — never produces a `taxonomy_revisions` row ( disclosed
|
|
410
410
|
* narrowing applies equally to removal). Idempotent: unassigning a row that isn't there is a no-op,
|
|
411
|
-
* not an error
|
|
411
|
+
* not an error. Only removed ids are returned, stamped, and emitted; an entirely absent set is a no-op. Chosen over
|
|
412
412
|
* a "set terms" replace call — see the taxonomy plan's "What the docs settle" #3 for why replace
|
|
413
413
|
* risks silently erasing a concurrent assignment.
|
|
414
414
|
* See docs/decisions/DR-005-ordered-taxonomy-validation.md.
|
|
@@ -417,19 +417,24 @@ export async function unassignTerms(required, _optional = {}) {
|
|
|
417
417
|
const { deps, principalId, contentType, contentId, termIds } = required;
|
|
418
418
|
await authorizeTaxonomyManage(deps, principalId);
|
|
419
419
|
await validateAssignmentTarget(deps, contentType, contentId, termIds);
|
|
420
|
+
const removedTermIds = [];
|
|
420
421
|
for (const termId of termIds) {
|
|
421
|
-
await deps.entryTerms.remove({ contentType, contentId, termId })
|
|
422
|
+
if (await deps.entryTerms.remove({ contentType, contentId, termId }) > 0)
|
|
423
|
+
removedTermIds.push(termId);
|
|
422
424
|
}
|
|
425
|
+
if (removedTermIds.length === 0)
|
|
426
|
+
return { removedTermIds };
|
|
423
427
|
const now = kernelNowIso({ clock: deps.clock });
|
|
424
428
|
await deps.stampWatermark({});
|
|
425
429
|
await deps.outbox.enqueue({ event: {
|
|
426
430
|
name: "taxonomy.terms_unassigned",
|
|
427
431
|
contentType,
|
|
428
432
|
contentId,
|
|
429
|
-
termIds,
|
|
433
|
+
termIds: removedTermIds,
|
|
430
434
|
actorId: principalId,
|
|
431
435
|
occurredAt: now,
|
|
432
436
|
} });
|
|
437
|
+
return { removedTermIds };
|
|
433
438
|
}
|
|
434
439
|
/** Content-deletion event subscriber. Best-effort (a missed event leaves an orphaned
|
|
435
440
|
* `entry_terms` row that is inert on read; a periodic/boot reconciliation sweep — modeled by
|
|
@@ -95,12 +95,16 @@ export function createTrashService(deps, optional = {}) {
|
|
|
95
95
|
priorMarker: marker.priorMarker ?? trashOptional.priorMarker ?? null,
|
|
96
96
|
};
|
|
97
97
|
await deps.repo.insert({ row });
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
98
|
+
// A successful noop may repair a missing index, but did not change the domain marker.
|
|
99
|
+
// Repeated removal must not emit another recovery watermark or outbox event (F1487).
|
|
100
|
+
if (!marker.noop) {
|
|
101
|
+
await notifyChanged(optional, {
|
|
102
|
+
workspaceId: required.workspaceId,
|
|
103
|
+
entityType: required.entityType,
|
|
104
|
+
entityId: required.entityId,
|
|
105
|
+
change: "trash",
|
|
106
|
+
});
|
|
107
|
+
}
|
|
104
108
|
return marker;
|
|
105
109
|
}
|
|
106
110
|
});
|
|
@@ -135,12 +139,15 @@ export function createTrashService(deps, optional = {}) {
|
|
|
135
139
|
if (!marker.ok)
|
|
136
140
|
return marker.reason === "not-found" ? "not-found" : "version-changed";
|
|
137
141
|
await deps.repo.deleteById({ workspaceId: required.workspaceId, id: row.id });
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
142
|
+
// An already-clear marker still needs index cleanup, without a duplicate notification.
|
|
143
|
+
if (!marker.noop) {
|
|
144
|
+
await notifyChanged(optional, {
|
|
145
|
+
workspaceId: required.workspaceId,
|
|
146
|
+
entityType: required.entityType,
|
|
147
|
+
entityId: required.entityId,
|
|
148
|
+
change: "restore",
|
|
149
|
+
});
|
|
150
|
+
}
|
|
144
151
|
return "restored";
|
|
145
152
|
}
|
|
146
153
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jini-ai/cms",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
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 adapters in domain-specific subpaths.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -132,7 +132,7 @@
|
|
|
132
132
|
"@jini-ai/http-kit": "0.4.0"
|
|
133
133
|
},
|
|
134
134
|
"dependencies": {
|
|
135
|
-
"@jini-ai/core": "0.4.0"
|
|
135
|
+
"@jini-ai/core": "^0.4.0"
|
|
136
136
|
},
|
|
137
137
|
"peerDependencies": {
|
|
138
138
|
"sharp": "^0.35.3",
|