@jini-ai/cms 0.3.11 → 0.4.1
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 +59 -0
- package/README.md +47 -19
- package/dist/content-types/agent-tools.d.ts +3 -56
- package/dist/content-types/agent-tools.js +22 -6
- package/dist/content-types/cleanup.d.ts +18 -13
- package/dist/content-types/cleanup.js +16 -11
- package/dist/content-types/errors.d.ts +57 -24
- package/dist/content-types/errors.js +56 -39
- package/dist/content-types/field-defs.d.ts +8 -4
- package/dist/content-types/field-defs.js +19 -14
- package/dist/content-types/index-provisioning.d.ts +61 -47
- package/dist/content-types/index-provisioning.js +90 -66
- package/dist/content-types/index.d.ts +4 -3
- package/dist/content-types/lifecycle-dispatch.d.ts +11 -5
- package/dist/content-types/lifecycle-dispatch.js +5 -4
- package/dist/content-types/lifecycle.d.ts +23 -18
- package/dist/content-types/lifecycle.js +71 -64
- package/dist/content-types/repo.memory.d.ts +5 -4
- package/dist/content-types/repo.memory.js +3 -2
- package/dist/content-types/tool-registrations.d.ts +6 -8
- package/dist/content-types/tool-registrations.js +24 -28
- package/dist/content-types/types.d.ts +17 -13
- package/dist/content-types/types.js +11 -5
- package/dist/content-types/write-service.d.ts +20 -22
- package/dist/content-types/write-service.js +78 -75
- package/dist/core/authorization.d.ts +15 -0
- package/dist/core/authorization.js +2 -0
- package/dist/core/commands/change-set.d.ts +15 -7
- package/dist/core/commands/command.d.ts +45 -26
- package/dist/core/commands/command.js +73 -63
- package/dist/core/entity-liveness.d.ts +11 -7
- package/dist/core/entity-liveness.js +5 -4
- package/dist/core/index.d.ts +12 -5
- package/dist/core/index.js +10 -4
- package/dist/core/ports.d.ts +25 -24
- package/dist/core/ports.js +1 -0
- package/dist/core/tools/args.d.ts +49 -0
- package/dist/core/tools/args.js +46 -0
- package/dist/core/tools/index.d.ts +3 -0
- package/dist/core/tools/index.js +3 -0
- package/dist/entries/agent-tools.d.ts +1 -73
- package/dist/entries/agent-tools.js +19 -13
- package/dist/entries/errors.d.ts +28 -14
- package/dist/entries/errors.js +17 -11
- package/dist/entries/field-validation.d.ts +5 -3
- package/dist/entries/field-validation.js +7 -5
- package/dist/entries/index.d.ts +2 -2
- package/dist/entries/lifecycle-dispatch.d.ts +5 -2
- package/dist/entries/lifecycle-dispatch.js +2 -1
- package/dist/entries/list.d.ts +4 -2
- package/dist/entries/list.js +4 -3
- package/dist/entries/repo.memory.d.ts +12 -10
- package/dist/entries/repo.memory.js +11 -10
- package/dist/entries/tool-registrations.d.ts +6 -8
- package/dist/entries/tool-registrations.js +42 -41
- package/dist/entries/types.d.ts +0 -7
- package/dist/entries/write-service.d.ts +28 -29
- package/dist/entries/write-service.js +114 -105
- package/dist/http/settings/cms-adapter.d.ts +20 -0
- package/dist/http/settings/cms-adapter.js +124 -0
- package/dist/http/settings/contracts.d.ts +135 -0
- package/dist/http/settings/contracts.js +2 -0
- package/dist/http/settings/http.d.ts +10 -0
- package/dist/http/settings/http.js +211 -0
- package/dist/http/settings/index.d.ts +5 -0
- package/dist/http/settings/index.js +4 -0
- package/dist/http/settings/shared.d.ts +68 -0
- package/dist/http/settings/shared.js +54 -0
- package/dist/http/settings/stream.d.ts +24 -0
- package/dist/http/settings/stream.js +147 -0
- package/dist/media/agent-tools.d.ts +1 -66
- package/dist/media/agent-tools.js +5 -1
- package/dist/media/blob-gc-lock.d.ts +6 -2
- package/dist/media/blob-gc-lock.js +4 -2
- package/dist/media/blob-gc.d.ts +64 -51
- package/dist/media/blob-gc.js +62 -52
- package/dist/media/blob-key.d.ts +1 -1
- package/dist/media/content-type-sniffer.d.ts +3 -1
- package/dist/media/content-type-sniffer.js +1 -1
- package/dist/media/html-attributes.d.ts +9 -5
- package/dist/media/html-attributes.js +8 -6
- package/dist/media/image-transformer.js +1 -1
- package/dist/media/image-transformer.sharp.d.ts +6 -0
- package/dist/media/image-transformer.sharp.js +12 -6
- package/dist/media/import/fetch-image.d.ts +24 -0
- package/dist/media/import/fetch-image.js +137 -0
- package/dist/media/import/index.d.ts +3 -0
- package/dist/media/import/index.js +3 -0
- package/dist/media/import/ports.d.ts +38 -0
- package/dist/media/index.d.ts +7 -1
- package/dist/media/index.js +1 -0
- package/dist/media/media-service.d.ts +14 -10
- package/dist/media/media-service.js +57 -52
- package/dist/media/ports.d.ts +3 -2
- package/dist/media/rendition-service.d.ts +3 -3
- package/dist/media/rendition-service.js +38 -37
- package/dist/media/repo.memory.d.ts +18 -7
- package/dist/media/repo.memory.js +12 -6
- package/dist/media/tool-registrations.d.ts +16 -15
- package/dist/media/tool-registrations.js +84 -72
- package/dist/media/transform-lock.d.ts +22 -12
- package/dist/media/transform-lock.js +14 -13
- package/dist/media/transform-registry.d.ts +3 -3
- package/dist/media/transform-registry.js +19 -18
- package/dist/media/transform-types.d.ts +8 -3
- package/dist/media/transform-types.js +10 -5
- package/dist/media/types.d.ts +21 -4
- package/dist/media/types.js +15 -2
- package/dist/navigation/agent-tools.d.ts +1 -80
- package/dist/navigation/agent-tools.js +15 -3
- package/dist/navigation/contracts.d.ts +1 -1
- package/dist/navigation/index.d.ts +2 -2
- package/dist/navigation/menu-service.d.ts +57 -40
- package/dist/navigation/menu-service.js +80 -69
- package/dist/navigation/ports.d.ts +1 -1
- package/dist/navigation/read-model.d.ts +3 -1
- package/dist/navigation/read-model.js +2 -2
- package/dist/navigation/reconcile.d.ts +2 -2
- package/dist/navigation/reconcile.js +2 -1
- package/dist/navigation/repo.memory.d.ts +7 -3
- package/dist/navigation/repo.memory.js +4 -2
- package/dist/navigation/resolver.d.ts +4 -1
- package/dist/navigation/resolver.js +1 -1
- package/dist/navigation/tool-registrations.d.ts +6 -8
- package/dist/navigation/tool-registrations.js +44 -42
- package/dist/navigation/types.d.ts +1 -1
- package/dist/presentation/presentation.d.ts +11 -3
- package/dist/presentation/presentation.js +13 -6
- package/dist/presentation/repo.memory.d.ts +6 -2
- package/dist/presentation/repo.memory.js +3 -2
- package/dist/settings/agent-tools.d.ts +2 -75
- package/dist/settings/agent-tools.js +49 -45
- package/dist/settings/agent-value-write-tools.d.ts +22 -0
- package/dist/settings/agent-value-write-tools.js +154 -0
- package/dist/settings/agent-writable-preferences.js +8 -20
- package/dist/settings/agent-write-denylist.d.ts +19 -0
- package/dist/settings/agent-write-denylist.js +15 -0
- package/dist/settings/change-feed.d.ts +11 -2
- package/dist/settings/change-feed.js +4 -4
- package/dist/settings/definitions-dispatch.d.ts +8 -3
- package/dist/settings/definitions-dispatch.js +5 -5
- package/dist/settings/dictionaries/index.d.ts +5 -2
- package/dist/settings/dictionaries/index.js +2 -2
- package/dist/settings/dictionaries/settings-dialog.ar.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.bn.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.de.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.es.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.fa.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.fr.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.hi.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.hu.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.id.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.it.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.ja.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.ko.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.pl.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.pt-BR.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.ru.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.th.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.tr.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.uk.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.ur.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.zh-CN.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.zh-TW.js +0 -1
- package/dist/settings/ensure-definitions.d.ts +5 -5
- package/dist/settings/errors.d.ts +7 -1
- package/dist/settings/errors.js +7 -1
- package/dist/settings/index.d.ts +4 -1
- package/dist/settings/index.js +1 -0
- package/dist/settings/ports.d.ts +6 -5
- package/dist/settings/principal-lookup.d.ts +9 -0
- package/dist/settings/principal-lookup.js +2 -0
- package/dist/settings/purge-service.d.ts +5 -4
- package/dist/settings/purge-service.js +3 -2
- package/dist/settings/settings.d.ts +22 -7
- package/dist/settings/settings.js +47 -26
- package/dist/settings/tool-registrations.d.ts +27 -32
- package/dist/settings/tool-registrations.js +66 -56
- package/dist/settings/types.d.ts +1 -1
- package/dist/settings/ui-tab-definitions.d.ts +1 -1
- package/dist/settings/write-service.d.ts +32 -26
- package/dist/settings/write-service.js +26 -20
- package/dist/taxonomy/agent-tools.d.ts +1 -66
- package/dist/taxonomy/agent-tools.js +25 -15
- package/dist/taxonomy/content-lookup.js +1 -1
- package/dist/taxonomy/index.d.ts +2 -2
- package/dist/taxonomy/merge-term.d.ts +7 -4
- package/dist/taxonomy/merge-term.js +7 -5
- package/dist/taxonomy/repo.memory.d.ts +42 -19
- package/dist/taxonomy/repo.memory.js +14 -12
- package/dist/taxonomy/validation-chain.d.ts +39 -18
- package/dist/taxonomy/validation-chain.js +35 -25
- package/dist/taxonomy/write-service.d.ts +103 -44
- package/dist/taxonomy/write-service.js +221 -179
- package/dist/trash/adapters/record-store.d.ts +52 -0
- package/dist/trash/adapters/record-store.js +36 -0
- package/dist/trash/cursor.d.ts +20 -0
- package/dist/trash/cursor.js +36 -0
- package/dist/trash/follow-ups.d.ts +76 -0
- package/dist/trash/follow-ups.js +46 -0
- package/dist/trash/index.d.ts +8 -0
- package/dist/trash/index.js +8 -0
- package/dist/trash/ports.d.ts +308 -0
- package/dist/trash/ports.js +2 -0
- package/dist/trash/repo.memory.d.ts +50 -0
- package/dist/trash/repo.memory.js +114 -0
- package/dist/trash/sweeper.d.ts +77 -0
- package/dist/trash/sweeper.js +127 -0
- package/dist/trash/write-service.d.ts +100 -0
- package/dist/trash/write-service.js +242 -0
- package/dist/workspace/agent-tools.d.ts +1 -59
- package/dist/workspace/agent-tools.js +27 -12
- package/dist/workspace/create.d.ts +31 -6
- package/dist/workspace/create.js +18 -5
- package/dist/workspace/delete.d.ts +18 -13
- package/dist/workspace/delete.js +20 -12
- package/dist/workspace/index.d.ts +1 -1
- package/dist/workspace/repo.memory.d.ts +19 -4
- package/dist/workspace/repo.memory.js +26 -4
- package/dist/workspace/tool-registrations.d.ts +2 -2
- package/dist/workspace/tool-registrations.js +13 -13
- package/dist/workspace/update.d.ts +6 -5
- package/dist/workspace/update.js +9 -8
- package/package.json +44 -38
- package/dist/core/tools/registration-kit.d.ts +0 -346
- package/dist/core/tools/registration-kit.js +0 -426
- package/dist/identity/admin-crud-service.d.ts +0 -280
- package/dist/identity/admin-crud-service.js +0 -468
- package/dist/identity/agent-tool-input.d.ts +0 -58
- package/dist/identity/agent-tool-input.js +0 -126
- package/dist/identity/agent-tools.d.ts +0 -101
- package/dist/identity/agent-tools.js +0 -290
- package/dist/identity/auth-service.d.ts +0 -118
- package/dist/identity/auth-service.js +0 -170
- package/dist/identity/authorize.d.ts +0 -90
- package/dist/identity/authorize.js +0 -94
- package/dist/identity/grant-service.d.ts +0 -198
- package/dist/identity/grant-service.js +0 -361
- package/dist/identity/hasher.d.ts +0 -39
- package/dist/identity/hasher.js +0 -39
- package/dist/identity/index.d.ts +0 -62
- package/dist/identity/index.js +0 -54
- package/dist/identity/password-policy.d.ts +0 -57
- package/dist/identity/password-policy.js +0 -66
- package/dist/identity/permission-migrations.d.ts +0 -86
- package/dist/identity/permission-migrations.js +0 -67
- package/dist/identity/permissions.d.ts +0 -35
- package/dist/identity/permissions.js +0 -603
- package/dist/identity/ports.d.ts +0 -201
- package/dist/identity/repo.memory.d.ts +0 -164
- package/dist/identity/repo.memory.js +0 -224
- package/dist/identity/seed.d.ts +0 -54
- package/dist/identity/seed.js +0 -429
- package/dist/identity/tool-registrations.d.ts +0 -50
- package/dist/identity/tool-registrations.js +0 -419
- package/dist/identity/types.d.ts +0 -185
- package/dist/identity/types.js +0 -64
- package/dist/identity/username.d.ts +0 -12
- package/dist/identity/username.js +0 -14
- package/dist/server/index.d.ts +0 -28
- package/dist/server/index.js +0 -28
- /package/dist/{identity → media/import}/ports.js +0 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file In-memory `TrashRepoPort` (rule-of-two adapter #1), mirroring the convention
|
|
3
|
+
* host in-memory repository conventions established: a real adapter used by tests, not a stub.
|
|
4
|
+
*
|
|
5
|
+
* Ordering, the lazy `purge_after` filter and the keyset cursor are implemented here the same way
|
|
6
|
+
* persistent adapters implement them, and both share this package's cursor codec, so a test that passes against
|
|
7
|
+
* this double is testing the behaviour the durable adapter has.
|
|
8
|
+
*/
|
|
9
|
+
import { decodeTrashCursor, encodeTrashCursor } from "./cursor.js";
|
|
10
|
+
function identityKey(workspaceId, entityType, entityId) {
|
|
11
|
+
return `${workspaceId}\u0000${entityType}\u0000${entityId}`;
|
|
12
|
+
}
|
|
13
|
+
/** Newest first, `id` breaking ties so the keyset cursor is total. @complexity O(1). */
|
|
14
|
+
function compareDesc(a, b) {
|
|
15
|
+
if (a.trashedAt !== b.trashedAt)
|
|
16
|
+
return a.trashedAt < b.trashedAt ? 1 : -1;
|
|
17
|
+
if (a.id !== b.id)
|
|
18
|
+
return a.id < b.id ? 1 : -1;
|
|
19
|
+
return 0;
|
|
20
|
+
}
|
|
21
|
+
export class InMemoryTrashRepo {
|
|
22
|
+
constructor(_required) { }
|
|
23
|
+
rows = new Map();
|
|
24
|
+
leases = new Map();
|
|
25
|
+
/** Test-only view of everything stored, including expired rows the list hides. */
|
|
26
|
+
all(_required) {
|
|
27
|
+
return [...this.rows.values()].sort(compareDesc);
|
|
28
|
+
}
|
|
29
|
+
/** @complexity O(n) over stored rows (the identity scan); n is a test fixture. */
|
|
30
|
+
async insert({ row }) {
|
|
31
|
+
for (const existing of this.rows.values()) {
|
|
32
|
+
if (identityKey(existing.workspaceId, existing.entityType, existing.entityId) ===
|
|
33
|
+
identityKey(row.workspaceId, row.entityType, row.entityId)) {
|
|
34
|
+
return; // INSERT OR IGNORE against `trashed_items_identity_unique`
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
this.rows.set(row.id, { ...row });
|
|
38
|
+
}
|
|
39
|
+
async findByEntity(required) {
|
|
40
|
+
const wanted = identityKey(required.workspaceId, required.entityType, required.entityId);
|
|
41
|
+
for (const row of this.rows.values()) {
|
|
42
|
+
if (identityKey(row.workspaceId, row.entityType, row.entityId) === wanted)
|
|
43
|
+
return { ...row };
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
async findByIds(required) {
|
|
48
|
+
return required.ids
|
|
49
|
+
.map((id) => this.rows.get(id))
|
|
50
|
+
.filter((row) => row !== undefined && row.workspaceId === required.workspaceId)
|
|
51
|
+
.map((row) => ({ ...row }));
|
|
52
|
+
}
|
|
53
|
+
async deleteById(required) {
|
|
54
|
+
const row = this.rows.get(required.id);
|
|
55
|
+
if (row && row.workspaceId === required.workspaceId) {
|
|
56
|
+
this.rows.delete(required.id);
|
|
57
|
+
this.leases.delete(required.id);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async deleteByEntity(required) {
|
|
61
|
+
const found = await this.findByEntity(required);
|
|
62
|
+
if (found) {
|
|
63
|
+
this.rows.delete(found.id);
|
|
64
|
+
this.leases.delete(found.id);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** @complexity O(n log n) — sorts the workspace's rows; fixture-sized by construction. */
|
|
68
|
+
async list(required, optional = {}) {
|
|
69
|
+
const after = decodeTrashCursor({ cursor: optional.cursor });
|
|
70
|
+
const types = optional.entityTypes && optional.entityTypes.length > 0 ? new Set(optional.entityTypes) : null;
|
|
71
|
+
const matched = [...this.rows.values()]
|
|
72
|
+
.filter((row) => row.workspaceId === required.workspaceId)
|
|
73
|
+
// Expired rows vanish immediately on list, even if a sweeper has never run in this host.
|
|
74
|
+
.filter((row) => row.purgeAfter > required.now)
|
|
75
|
+
.filter((row) => (types ? types.has(row.entityType) : true))
|
|
76
|
+
.sort(compareDesc)
|
|
77
|
+
.filter((row) => {
|
|
78
|
+
if (!after)
|
|
79
|
+
return true;
|
|
80
|
+
if (row.trashedAt !== after.trashedAt)
|
|
81
|
+
return row.trashedAt < after.trashedAt;
|
|
82
|
+
return row.id < after.id;
|
|
83
|
+
});
|
|
84
|
+
const items = matched.slice(0, required.limit).map((row) => ({ ...row }));
|
|
85
|
+
const last = items.at(-1);
|
|
86
|
+
const nextCursor = matched.length > required.limit && last ? encodeTrashCursor(last) : null;
|
|
87
|
+
return { items, nextCursor };
|
|
88
|
+
}
|
|
89
|
+
/** @complexity O(n log n). */
|
|
90
|
+
async claimDue(required) {
|
|
91
|
+
const due = [...this.rows.values()]
|
|
92
|
+
.filter((row) => row.purgeAfter <= required.now)
|
|
93
|
+
.filter((row) => {
|
|
94
|
+
const lease = this.leases.get(row.id);
|
|
95
|
+
return !lease || lease.expiresAt <= required.now;
|
|
96
|
+
})
|
|
97
|
+
.sort((a, b) => (a.purgeAfter < b.purgeAfter ? -1 : a.purgeAfter > b.purgeAfter ? 1 : 0))
|
|
98
|
+
.slice(0, required.limit);
|
|
99
|
+
for (const row of due) {
|
|
100
|
+
this.leases.set(row.id, { owner: required.leaseOwner, expiresAt: required.leaseUntil });
|
|
101
|
+
}
|
|
102
|
+
return due.map((row) => ({
|
|
103
|
+
id: row.id,
|
|
104
|
+
workspaceId: row.workspaceId,
|
|
105
|
+
entityType: row.entityType,
|
|
106
|
+
entityId: row.entityId,
|
|
107
|
+
entityVersion: row.entityVersion,
|
|
108
|
+
}));
|
|
109
|
+
}
|
|
110
|
+
async releaseLease(required) {
|
|
111
|
+
this.leases.delete(required.id);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
//# sourceMappingURL=repo.memory.js.map
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { Clock } from "@jini-ai/core/primitives";
|
|
2
|
+
/** Claim then re-read inside the purge transaction; a concurrent restore always wins. */
|
|
3
|
+
import type { PurgeItemOutcome, TrashAdapter, TrashEntityType, TrashEntityPolicy, TrashRepoPort, TransactionRunner } from "./ports.js";
|
|
4
|
+
/** Idle wait between sweeps. Retention is measured in days, so an hour of lateness costs nothing,
|
|
5
|
+
* and a desktop site that is only open for minutes still sweeps once at boot. */
|
|
6
|
+
export declare const DEFAULT_TRASH_SWEEP_INTERVAL_MS: number;
|
|
7
|
+
/** Rows claimed per sweep. Each one costs a transaction, so this bounds a single sweep's hold on
|
|
8
|
+
* the write lock; a full batch sweeps again immediately rather than waiting out the interval. */
|
|
9
|
+
export declare const DEFAULT_TRASH_SWEEP_BATCH_SIZE = 50;
|
|
10
|
+
/** How long a claim hides a row from other sweeps. Also the retry backoff for a row that stood
|
|
11
|
+
* down: nothing releases it early, so it is re-tried at most once per lease. */
|
|
12
|
+
export declare const DEFAULT_TRASH_SWEEP_LEASE_MS: number;
|
|
13
|
+
export interface TrashSweepDeps {
|
|
14
|
+
repo: TrashRepoPort;
|
|
15
|
+
/** The same call-time `Map` the write service resolves against — never a module-level registry. */
|
|
16
|
+
adapters: ReadonlyMap<TrashEntityType, TrashAdapter>;
|
|
17
|
+
/** Reentrant; see {@link TransactionRunner}. */
|
|
18
|
+
transaction: TransactionRunner;
|
|
19
|
+
entityPolicy: TrashEntityPolicy;
|
|
20
|
+
}
|
|
21
|
+
export interface TrashSweepReport {
|
|
22
|
+
/** Rows this sweep leased. `claimed === limit` means there is probably more due right now. */
|
|
23
|
+
claimed: number;
|
|
24
|
+
purged: number;
|
|
25
|
+
results: {
|
|
26
|
+
id: string;
|
|
27
|
+
outcome: PurgeItemOutcome;
|
|
28
|
+
}[];
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* One pass of the backstop. Time, identity and batch size are arguments rather than dependencies so
|
|
32
|
+
* the decision logic stays deterministic.
|
|
33
|
+
*/
|
|
34
|
+
export type TrashSweepOnce = (required: {
|
|
35
|
+
now: string;
|
|
36
|
+
leaseOwner: string;
|
|
37
|
+
leaseUntil: string;
|
|
38
|
+
limit: number;
|
|
39
|
+
}) => Promise<TrashSweepReport>;
|
|
40
|
+
export interface TrashSweeper {
|
|
41
|
+
/** Stops scheduling sweeps, then resolves once a sweep already running has settled. Idempotent. */
|
|
42
|
+
stop(required: Record<string, never>): Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Builds the sweep pass.
|
|
46
|
+
*
|
|
47
|
+
* @param deps the trash repo, the call-time adapter map and a reentrant transaction runner.
|
|
48
|
+
* @returns a {@link TrashSweepOnce}. It never throws for a single bad row — one missing adapter or
|
|
49
|
+
* one stale version is recorded as that row's outcome and the rest of the batch continues.
|
|
50
|
+
* A repo-level failure (a locked database) does propagate; the caller's `onError` owns it.
|
|
51
|
+
* @complexity O(limit) transactions per pass, each O(1); one indexed claim query.
|
|
52
|
+
*/
|
|
53
|
+
export declare function createTrashSweep(deps: TrashSweepDeps): TrashSweepOnce;
|
|
54
|
+
/** Host scheduler owns timers, unref and cancellation. schedule must defer run to a later turn. */
|
|
55
|
+
export interface TrashSchedulerPort {
|
|
56
|
+
schedule(required: {
|
|
57
|
+
delayMs: number;
|
|
58
|
+
run: () => void;
|
|
59
|
+
}): unknown;
|
|
60
|
+
cancel(required: {
|
|
61
|
+
handle: unknown;
|
|
62
|
+
}): void;
|
|
63
|
+
}
|
|
64
|
+
export declare function startTrashSweeper(required: {
|
|
65
|
+
sweep: TrashSweepOnce;
|
|
66
|
+
clock: Clock;
|
|
67
|
+
scheduler: TrashSchedulerPort;
|
|
68
|
+
leaseOwner: string;
|
|
69
|
+
}, optional?: {
|
|
70
|
+
intervalMs?: number;
|
|
71
|
+
batchSize?: number;
|
|
72
|
+
leaseMs?: number;
|
|
73
|
+
onError?: (required: {
|
|
74
|
+
error: unknown;
|
|
75
|
+
}) => void;
|
|
76
|
+
}): TrashSweeper;
|
|
77
|
+
//# sourceMappingURL=sweeper.d.ts.map
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The retention auto-purge backstop (60 days by default) — claim, compare, delete, in that order.
|
|
3
|
+
*
|
|
4
|
+
* Two halves on purpose, because they fail in completely different ways:
|
|
5
|
+
*
|
|
6
|
+
* - {@link createTrashSweep} is the decision half. It has no clock, no timer and no randomness, so
|
|
7
|
+
* every branch below (the restore race, a stale version, a missing adapter) is a plain function
|
|
8
|
+
* call in a test rather than something you have to wait for.
|
|
9
|
+
* - {@link startTrashSweeper} is the effect half: a non-overlapping loop over an injected scheduler. The host owns timer cancellation and
|
|
10
|
+
* `unref`, so the loop stays portable without changing its first-turn and shutdown behavior.
|
|
11
|
+
*
|
|
12
|
+
* **A concurrent restore always wins.** That is the whole safety property, and it is bought twice
|
|
13
|
+
* over, because either guard alone has a hole:
|
|
14
|
+
*
|
|
15
|
+
* 1. Inside the purge transaction the index row is re-read by id. A restore deletes that row in
|
|
16
|
+
* its own transaction, so a sweep whose claim is already in hand finds nothing and never calls
|
|
17
|
+
* `purge` at all. Version alone would not cover a domain whose `entity_version` is `null`
|
|
18
|
+
* (comments and redirects both are) — there is no version there to have changed.
|
|
19
|
+
* 2. `adapter.purge` is still a compare-and-delete on the version captured at trash time, so a
|
|
20
|
+
* domain that does carry a version stands down even if the row somehow survived step 1.
|
|
21
|
+
*
|
|
22
|
+
* The lease is the third leg: a claimed row is invisible to other sweeps until `leaseUntil`, so two
|
|
23
|
+
* processes serving the same file cannot both purge it, and a sweep that dies mid-batch releases
|
|
24
|
+
* its rows by expiry rather than wedging them forever.
|
|
25
|
+
*/
|
|
26
|
+
import { nowIso as kernelNowIso } from "@jini-ai/core/primitives";
|
|
27
|
+
/** Idle wait between sweeps. Retention is measured in days, so an hour of lateness costs nothing,
|
|
28
|
+
* and a desktop site that is only open for minutes still sweeps once at boot. */
|
|
29
|
+
export const DEFAULT_TRASH_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
|
|
30
|
+
/** Rows claimed per sweep. Each one costs a transaction, so this bounds a single sweep's hold on
|
|
31
|
+
* the write lock; a full batch sweeps again immediately rather than waiting out the interval. */
|
|
32
|
+
export const DEFAULT_TRASH_SWEEP_BATCH_SIZE = 50;
|
|
33
|
+
/** How long a claim hides a row from other sweeps. Also the retry backoff for a row that stood
|
|
34
|
+
* down: nothing releases it early, so it is re-tried at most once per lease. */
|
|
35
|
+
export const DEFAULT_TRASH_SWEEP_LEASE_MS = 5 * 60 * 1000;
|
|
36
|
+
/**
|
|
37
|
+
* Builds the sweep pass.
|
|
38
|
+
*
|
|
39
|
+
* @param deps the trash repo, the call-time adapter map and a reentrant transaction runner.
|
|
40
|
+
* @returns a {@link TrashSweepOnce}. It never throws for a single bad row — one missing adapter or
|
|
41
|
+
* one stale version is recorded as that row's outcome and the rest of the batch continues.
|
|
42
|
+
* A repo-level failure (a locked database) does propagate; the caller's `onError` owns it.
|
|
43
|
+
* @complexity O(limit) transactions per pass, each O(1); one indexed claim query.
|
|
44
|
+
*/
|
|
45
|
+
export function createTrashSweep(deps) {
|
|
46
|
+
return async function sweepTrashOnce(required) {
|
|
47
|
+
const claims = await deps.repo.claimDue(required);
|
|
48
|
+
const results = [];
|
|
49
|
+
for (const claim of claims) {
|
|
50
|
+
const adapter = deps.adapters.get(claim.entityType);
|
|
51
|
+
if (!adapter) {
|
|
52
|
+
// Keep the lease: immediately retrying an uninstalled domain would starve the batch.
|
|
53
|
+
// Its snapshot stays listed until expiry, and removal can resume when the domain returns.
|
|
54
|
+
results.push({ id: claim.id, outcome: "adapter-unavailable" });
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (!deps.entityPolicy({ entityType: claim.entityType })) {
|
|
58
|
+
results.push({ id: claim.id, outcome: "forbidden" });
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const outcome = await deps.transaction({
|
|
62
|
+
work: async () => {
|
|
63
|
+
// Restore removes the index in its own transaction, including unversioned domains.
|
|
64
|
+
const [row] = await deps.repo.findByIds({ workspaceId: claim.workspaceId, ids: [claim.id] });
|
|
65
|
+
if (!row)
|
|
66
|
+
return "not-found";
|
|
67
|
+
// Versioned domains also stand down on a compare-and-delete mismatch.
|
|
68
|
+
const result = await adapter.purge({
|
|
69
|
+
workspaceId: row.workspaceId,
|
|
70
|
+
entityId: row.entityId,
|
|
71
|
+
expectedVersion: row.entityVersion,
|
|
72
|
+
});
|
|
73
|
+
if (result === "purged" || result === "already-gone") {
|
|
74
|
+
await deps.repo.deleteById({ workspaceId: row.workspaceId, id: row.id });
|
|
75
|
+
}
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
results.push({ id: claim.id, outcome });
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
claimed: claims.length,
|
|
83
|
+
purged: results.filter((r) => r.outcome === "purged").length,
|
|
84
|
+
results,
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
export function startTrashSweeper(required, optional = {}) {
|
|
89
|
+
const intervalMs = optional.intervalMs ?? DEFAULT_TRASH_SWEEP_INTERVAL_MS;
|
|
90
|
+
const batchSize = optional.batchSize ?? DEFAULT_TRASH_SWEEP_BATCH_SIZE;
|
|
91
|
+
const leaseMs = optional.leaseMs ?? DEFAULT_TRASH_SWEEP_LEASE_MS;
|
|
92
|
+
for (const value of [intervalMs, batchSize, leaseMs]) {
|
|
93
|
+
if (!Number.isSafeInteger(value) || value <= 0)
|
|
94
|
+
throw new RangeError("trash sweep bounds must be positive integers");
|
|
95
|
+
}
|
|
96
|
+
if (!required.leaseOwner)
|
|
97
|
+
throw new RangeError("trash sweep leaseOwner must not be empty");
|
|
98
|
+
let stopped = false;
|
|
99
|
+
let timer;
|
|
100
|
+
let inFlight = Promise.resolve();
|
|
101
|
+
const schedule = (delayMs) => {
|
|
102
|
+
if (stopped)
|
|
103
|
+
return;
|
|
104
|
+
timer = required.scheduler.schedule({ delayMs, run: () => { inFlight = sweep(); } });
|
|
105
|
+
};
|
|
106
|
+
const sweep = async () => {
|
|
107
|
+
let claimed = 0;
|
|
108
|
+
try {
|
|
109
|
+
const now = kernelNowIso({ clock: required.clock });
|
|
110
|
+
const report = await required.sweep({
|
|
111
|
+
now, leaseOwner: required.leaseOwner,
|
|
112
|
+
leaseUntil: new Date(Date.parse(now) + leaseMs).toISOString(), limit: batchSize
|
|
113
|
+
});
|
|
114
|
+
claimed = report.claimed;
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
try {
|
|
118
|
+
optional.onError?.({ error });
|
|
119
|
+
}
|
|
120
|
+
catch { /* reporter cannot stop the loop */ }
|
|
121
|
+
}
|
|
122
|
+
schedule(claimed >= batchSize ? 0 : intervalMs);
|
|
123
|
+
};
|
|
124
|
+
schedule(0);
|
|
125
|
+
return { async stop(_required) { stopped = true; required.scheduler.cancel({ handle: timer }); await inFlight; } };
|
|
126
|
+
}
|
|
127
|
+
//# sourceMappingURL=sweeper.js.map
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The Trash write chokepoint (design §2.2).
|
|
3
|
+
*
|
|
4
|
+
* The one thing to understand before editing: **`trash` performs BOTH writes — the domain's marker
|
|
5
|
+
* flip and the `trashed_items` index insert — inside a single transaction.** The design as first
|
|
6
|
+
* written had the caller do two writes (`port.trash()` then `adapter.hide()`), and nothing made
|
|
7
|
+
* them atomic; a failure between them leaves an item that is in the Trash *and still live on the
|
|
8
|
+
* site*, or hidden with no Trash row, which is unrecoverable through the UI. Removing that seam is
|
|
9
|
+
* the point of this module, so there is deliberately no exported way to perform half of it.
|
|
10
|
+
*
|
|
11
|
+
* Domains do not import this file. They receive {@link RemoveEntity} through their own dependency
|
|
12
|
+
* object, pre-bound at the composition root with their `entityType` — see `bindRemoveEntity`.
|
|
13
|
+
*/
|
|
14
|
+
import type { IdGenerator } from "@jini-ai/core/primitives";
|
|
15
|
+
import type { ForgetRemovedEntity, RemoveEntity, TrashAdapter, TrashEntityType, TrashEntityPolicy, TrashPort, TrashRepoPort, TransactionRunner } from "./ports.js";
|
|
16
|
+
/** What {@link TrashServiceOptional.onChanged} receives. One event per state change — a no-op outcome
|
|
17
|
+
* (`not-found`, `version-changed`, `already-gone`, `forbidden`, `adapter-unavailable`) never fires
|
|
18
|
+
* it, because nothing changed. */
|
|
19
|
+
export interface TrashChangeEvent {
|
|
20
|
+
workspaceId: string;
|
|
21
|
+
entityType: TrashEntityType;
|
|
22
|
+
entityId: string;
|
|
23
|
+
change: "trash" | "restore" | "purge";
|
|
24
|
+
}
|
|
25
|
+
export interface TrashServiceDeps {
|
|
26
|
+
repo: TrashRepoPort;
|
|
27
|
+
/**
|
|
28
|
+
* Plain `Map`, built at the composition root, resolved on EVERY call — never a module-level
|
|
29
|
+
* registry. This codebase's registries are append-only with no unregister, so any filtering done
|
|
30
|
+
* at registration time runs exactly once (two real bugs already).
|
|
31
|
+
*/
|
|
32
|
+
adapters: ReadonlyMap<TrashEntityType, TrashAdapter>;
|
|
33
|
+
idGen: IdGenerator;
|
|
34
|
+
entityPolicy: TrashEntityPolicy;
|
|
35
|
+
transaction: TransactionRunner;
|
|
36
|
+
}
|
|
37
|
+
/** Notifications are best effort inside the operation; transactional follow-ups use withFollowUps. */
|
|
38
|
+
export interface TrashServiceOptional {
|
|
39
|
+
/** Retention is stamped once when removed; defaults to 60 days. */
|
|
40
|
+
retentionDays?: number;
|
|
41
|
+
/**
|
|
42
|
+
* Fires inside the same transaction after successful marker/index changes. Hosts may stamp a
|
|
43
|
+
* recovery watermark and enqueue an outbox event here; per-type revisions/events use follow-ups.
|
|
44
|
+
* Failures are reported and swallowed so notification failure cannot undo the completed writes.
|
|
45
|
+
*/
|
|
46
|
+
onChanged?: (required: TrashChangeEvent) => void | Promise<void>;
|
|
47
|
+
onError?: (required: {
|
|
48
|
+
error: unknown;
|
|
49
|
+
}) => void;
|
|
50
|
+
}
|
|
51
|
+
/** A host composition error: fail fast rather than hide an entity that can never be restored. */
|
|
52
|
+
export declare class TrashAdapterMissingError extends Error {
|
|
53
|
+
constructor({ message }: {
|
|
54
|
+
message: string;
|
|
55
|
+
}, optional?: {
|
|
56
|
+
cause?: unknown;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
/** Stamp retention once at removal; later policy changes cannot shorten an existing promise. */
|
|
60
|
+
export declare const DEFAULT_TRASH_RETENTION_DAYS = 60;
|
|
61
|
+
/**
|
|
62
|
+
* `at` + {@link DEFAULT_TRASH_RETENTION_DAYS}, as an ISO timestamp.
|
|
63
|
+
*
|
|
64
|
+
* Stamped at trash time, not computed at read time, so changing the constant later never
|
|
65
|
+
* retroactively purges what a user was already promised.
|
|
66
|
+
*
|
|
67
|
+
* @complexity O(1).
|
|
68
|
+
*/
|
|
69
|
+
export declare function computePurgeAfter({ at }: {
|
|
70
|
+
at: string;
|
|
71
|
+
}, optional?: {
|
|
72
|
+
retentionDays?: number;
|
|
73
|
+
}): string;
|
|
74
|
+
/**
|
|
75
|
+
* Builds the Trash service.
|
|
76
|
+
*
|
|
77
|
+
* @param deps repo, the call-time adapter map, an id generator and a reentrant transaction runner.
|
|
78
|
+
* @returns the {@link TrashPort} surface. `purgeSelected` requires host-owned human confirmation and per-row authorization.
|
|
79
|
+
* @complexity O(1) to build.
|
|
80
|
+
*/
|
|
81
|
+
export declare function createTrashService(deps: TrashServiceDeps, optional?: TrashServiceOptional): TrashPort;
|
|
82
|
+
/**
|
|
83
|
+
* Pre-binds an index-row drop to one `entityType`, producing the function a domain receives as
|
|
84
|
+
* `deps.forgetRemoved` (see {@link ForgetRemovedEntity} for when a domain needs one).
|
|
85
|
+
*
|
|
86
|
+
* Binds against the repo rather than the service on purpose: there is no marker to move here, and
|
|
87
|
+
* routing it through `TrashPort` would put a method on that port which does half of a `trash`.
|
|
88
|
+
*
|
|
89
|
+
* @complexity O(1).
|
|
90
|
+
*/
|
|
91
|
+
export declare function bindForgetRemovedEntity({ repo, entityType }: {
|
|
92
|
+
repo: TrashRepoPort;
|
|
93
|
+
entityType: TrashEntityType;
|
|
94
|
+
}): ForgetRemovedEntity;
|
|
95
|
+
/** Bind a domain without making its callers depend on trash vocabulary. */
|
|
96
|
+
export declare function bindRemoveEntity({ trash, entityType }: {
|
|
97
|
+
trash: TrashPort;
|
|
98
|
+
entityType: TrashEntityType;
|
|
99
|
+
}): RemoveEntity;
|
|
100
|
+
//# sourceMappingURL=write-service.d.ts.map
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calls `optional.onChanged`, if present, and never lets it throw. See
|
|
3
|
+
* {@link TrashServiceOptional.onChanged} for why a failure here must not propagate.
|
|
4
|
+
*
|
|
5
|
+
* @complexity O(1) beyond the hook itself.
|
|
6
|
+
*/
|
|
7
|
+
async function notifyChanged(optional, event) {
|
|
8
|
+
if (!optional.onChanged)
|
|
9
|
+
return;
|
|
10
|
+
try {
|
|
11
|
+
await optional.onChanged(event);
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
try {
|
|
15
|
+
optional.onError?.({ error });
|
|
16
|
+
}
|
|
17
|
+
catch { /* reporting never undoes a state transition */ }
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** A host composition error: fail fast rather than hide an entity that can never be restored. */
|
|
21
|
+
export class TrashAdapterMissingError extends Error {
|
|
22
|
+
constructor({ message }, optional = {}) {
|
|
23
|
+
super(message, optional);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Stamp retention once at removal; later policy changes cannot shorten an existing promise. */
|
|
27
|
+
export const DEFAULT_TRASH_RETENTION_DAYS = 60;
|
|
28
|
+
/**
|
|
29
|
+
* `at` + {@link DEFAULT_TRASH_RETENTION_DAYS}, as an ISO timestamp.
|
|
30
|
+
*
|
|
31
|
+
* Stamped at trash time, not computed at read time, so changing the constant later never
|
|
32
|
+
* retroactively purges what a user was already promised.
|
|
33
|
+
*
|
|
34
|
+
* @complexity O(1).
|
|
35
|
+
*/
|
|
36
|
+
export function computePurgeAfter({ at }, optional = {}) {
|
|
37
|
+
const retentionDays = optional.retentionDays ?? DEFAULT_TRASH_RETENTION_DAYS;
|
|
38
|
+
if (!Number.isFinite(retentionDays) || retentionDays < 0)
|
|
39
|
+
throw new RangeError("trash retentionDays must be non-negative and finite");
|
|
40
|
+
const base = new Date(at);
|
|
41
|
+
if (Number.isNaN(base.getTime())) {
|
|
42
|
+
throw new RangeError(`trash: 'at' is not a parseable timestamp (received ${JSON.stringify(at)})`);
|
|
43
|
+
}
|
|
44
|
+
return new Date(base.getTime() + retentionDays * 24 * 60 * 60 * 1000).toISOString();
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Builds the Trash service.
|
|
48
|
+
*
|
|
49
|
+
* @param deps repo, the call-time adapter map, an id generator and a reentrant transaction runner.
|
|
50
|
+
* @returns the {@link TrashPort} surface. `purgeSelected` requires host-owned human confirmation and per-row authorization.
|
|
51
|
+
* @complexity O(1) to build.
|
|
52
|
+
*/
|
|
53
|
+
export function createTrashService(deps, optional = {}) {
|
|
54
|
+
const retentionDays = optional.retentionDays ?? DEFAULT_TRASH_RETENTION_DAYS;
|
|
55
|
+
computePurgeAfter({ at: "2000-01-01T00:00:00.000Z" }, { retentionDays });
|
|
56
|
+
/** Resolved on every call — see {@link TrashServiceDeps.adapters}. @complexity O(1). */
|
|
57
|
+
function requireAdapter(entityType) {
|
|
58
|
+
const adapter = deps.adapters.get(entityType);
|
|
59
|
+
if (!adapter || !deps.entityPolicy({ entityType })) {
|
|
60
|
+
throw new TrashAdapterMissingError({ message: `trash: no adapter is registered for entity type '${entityType}'` });
|
|
61
|
+
}
|
|
62
|
+
return adapter;
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
/**
|
|
66
|
+
* Hides the entity and indexes it as one unit.
|
|
67
|
+
*
|
|
68
|
+
* @complexity O(1) — one marker UPDATE plus one index INSERT, both inside one transaction.
|
|
69
|
+
*/
|
|
70
|
+
async trash(required, trashOptional = {}) {
|
|
71
|
+
const adapter = requireAdapter(required.entityType);
|
|
72
|
+
return deps.transaction({
|
|
73
|
+
work: async () => {
|
|
74
|
+
const marker = await adapter.hide({
|
|
75
|
+
workspaceId: required.workspaceId,
|
|
76
|
+
entityId: required.entityId,
|
|
77
|
+
at: required.at,
|
|
78
|
+
expectedVersion: required.expectedVersion,
|
|
79
|
+
}, { actor: required.actor });
|
|
80
|
+
if (!marker.ok)
|
|
81
|
+
return marker;
|
|
82
|
+
const row = {
|
|
83
|
+
id: deps.idGen.newId(),
|
|
84
|
+
workspaceId: required.workspaceId,
|
|
85
|
+
entityType: required.entityType,
|
|
86
|
+
entityId: required.entityId,
|
|
87
|
+
trashedAt: required.at,
|
|
88
|
+
purgeAfter: computePurgeAfter({ at: required.at }, { retentionDays }),
|
|
89
|
+
actorPrincipalId: required.actor.principalId,
|
|
90
|
+
actorPluginId: required.actor.pluginId ?? null,
|
|
91
|
+
displayTitle: required.display.title,
|
|
92
|
+
displaySubtitle: required.display.subtitle ?? null,
|
|
93
|
+
entityVersion: marker.version,
|
|
94
|
+
// The adapter's marker reading wins; caller state only fills in for adapters without one.
|
|
95
|
+
priorMarker: marker.priorMarker ?? trashOptional.priorMarker ?? null,
|
|
96
|
+
};
|
|
97
|
+
await deps.repo.insert({ row });
|
|
98
|
+
await notifyChanged(optional, {
|
|
99
|
+
workspaceId: required.workspaceId,
|
|
100
|
+
entityType: required.entityType,
|
|
101
|
+
entityId: required.entityId,
|
|
102
|
+
change: "trash",
|
|
103
|
+
});
|
|
104
|
+
return marker;
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
},
|
|
108
|
+
/**
|
|
109
|
+
* Clears the domain marker and removes the index row, as one unit.
|
|
110
|
+
*
|
|
111
|
+
* An entity whose row was deleted out from under the index returns `"not-found"` and the index
|
|
112
|
+
* row is LEFT IN PLACE — purge is the only path that removes an index row, and the user can
|
|
113
|
+
* still select it there. One deletion path, not two.
|
|
114
|
+
*
|
|
115
|
+
* @complexity O(1).
|
|
116
|
+
*/
|
|
117
|
+
async restore(required, restoreOptional = {}) {
|
|
118
|
+
const row = await deps.repo.findByEntity(required);
|
|
119
|
+
if (!row)
|
|
120
|
+
return "not-found";
|
|
121
|
+
const adapter = deps.adapters.get(required.entityType);
|
|
122
|
+
if (!adapter || !deps.entityPolicy({ entityType: required.entityType }))
|
|
123
|
+
return "adapter-unavailable";
|
|
124
|
+
return deps.transaction({
|
|
125
|
+
work: async () => {
|
|
126
|
+
const marker = await adapter.unhide({
|
|
127
|
+
workspaceId: required.workspaceId,
|
|
128
|
+
entityId: required.entityId,
|
|
129
|
+
at: required.at,
|
|
130
|
+
expectedVersion: row.entityVersion,
|
|
131
|
+
}, {
|
|
132
|
+
priorMarker: row.priorMarker,
|
|
133
|
+
...(restoreOptional.actor !== undefined ? { actor: restoreOptional.actor } : {}),
|
|
134
|
+
});
|
|
135
|
+
if (!marker.ok)
|
|
136
|
+
return marker.reason === "not-found" ? "not-found" : "version-changed";
|
|
137
|
+
await deps.repo.deleteById({ workspaceId: required.workspaceId, id: row.id });
|
|
138
|
+
await notifyChanged(optional, {
|
|
139
|
+
workspaceId: required.workspaceId,
|
|
140
|
+
entityType: required.entityType,
|
|
141
|
+
entityId: required.entityId,
|
|
142
|
+
change: "restore",
|
|
143
|
+
});
|
|
144
|
+
return "restored";
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
},
|
|
148
|
+
/** @complexity one indexed keyset read; see `TrashRepoPort.list`. */
|
|
149
|
+
async list(required, listOptional = {}) {
|
|
150
|
+
return deps.repo.list(required, listOptional);
|
|
151
|
+
},
|
|
152
|
+
/**
|
|
153
|
+
* Permanent deletion of selected rows. Called by the admin Trash screen or the assistant's
|
|
154
|
+
* human-confirmed permanent-delete tools; callers own confirmation, this service owns row auth.
|
|
155
|
+
*
|
|
156
|
+
* Per item, and per item only: one unavailable adapter, one stale version or one row the caller
|
|
157
|
+
* may not touch must not abort the rest of the selection, so each row gets its own permission
|
|
158
|
+
* check, its own transaction and its own recorded outcome.
|
|
159
|
+
*
|
|
160
|
+
* The gate runs on the row this method looked up, BEFORE the adapter is resolved: a caller who
|
|
161
|
+
* may not destroy a row must not learn from the outcome whether its domain is still installed.
|
|
162
|
+
*
|
|
163
|
+
* @complexity O(k) authorization calls and at most O(k) transactions for k selected ids.
|
|
164
|
+
*/
|
|
165
|
+
async purgeSelected(required) {
|
|
166
|
+
const rows = await deps.repo.findByIds({ workspaceId: required.workspaceId, ids: required.ids });
|
|
167
|
+
const byId = new Map(rows.map((row) => [row.id, row]));
|
|
168
|
+
const results = [];
|
|
169
|
+
for (const id of required.ids) {
|
|
170
|
+
const row = byId.get(id);
|
|
171
|
+
if (!row) {
|
|
172
|
+
results.push({ id, outcome: "not-found" });
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (!(await required.authorizeItem({ item: row }))) {
|
|
176
|
+
results.push({ id, outcome: "forbidden" });
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const adapter = deps.adapters.get(row.entityType);
|
|
180
|
+
if (!adapter) {
|
|
181
|
+
results.push({ id, outcome: "adapter-unavailable" });
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (!deps.entityPolicy({ entityType: row.entityType })) {
|
|
185
|
+
results.push({ id, outcome: "forbidden" });
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
const outcome = await deps.transaction({
|
|
189
|
+
work: async () => {
|
|
190
|
+
const result = await adapter.purge({
|
|
191
|
+
workspaceId: row.workspaceId,
|
|
192
|
+
entityId: row.entityId,
|
|
193
|
+
expectedVersion: row.entityVersion,
|
|
194
|
+
}, { actor: required.actor });
|
|
195
|
+
// A changed version leaves the index in place: the safe outcome of a restore/edit race
|
|
196
|
+
// is that the item survives. Only completed or already-completed removals drop the index.
|
|
197
|
+
if (result === "purged" || result === "already-gone") {
|
|
198
|
+
await deps.repo.deleteById({ workspaceId: row.workspaceId, id: row.id });
|
|
199
|
+
}
|
|
200
|
+
// Already-gone is a no-op: nothing changed here, so no notification fires.
|
|
201
|
+
if (result === "purged") {
|
|
202
|
+
await notifyChanged(optional, {
|
|
203
|
+
workspaceId: row.workspaceId,
|
|
204
|
+
entityType: row.entityType,
|
|
205
|
+
entityId: row.entityId,
|
|
206
|
+
change: "purge",
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
return result;
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
results.push({ id, outcome });
|
|
213
|
+
}
|
|
214
|
+
return { purged: results.filter((r) => r.outcome === "purged").length, results };
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Pre-binds an index-row drop to one `entityType`, producing the function a domain receives as
|
|
220
|
+
* `deps.forgetRemoved` (see {@link ForgetRemovedEntity} for when a domain needs one).
|
|
221
|
+
*
|
|
222
|
+
* Binds against the repo rather than the service on purpose: there is no marker to move here, and
|
|
223
|
+
* routing it through `TrashPort` would put a method on that port which does half of a `trash`.
|
|
224
|
+
*
|
|
225
|
+
* @complexity O(1).
|
|
226
|
+
*/
|
|
227
|
+
export function bindForgetRemovedEntity({ repo, entityType }) {
|
|
228
|
+
return (required) => repo.deleteByEntity({ workspaceId: required.workspaceId, entityType, entityId: required.id });
|
|
229
|
+
}
|
|
230
|
+
/** Bind a domain without making its callers depend on trash vocabulary. */
|
|
231
|
+
export function bindRemoveEntity({ trash, entityType }) {
|
|
232
|
+
return (required, optional = {}) => trash.trash({
|
|
233
|
+
workspaceId: required.workspaceId,
|
|
234
|
+
entityType,
|
|
235
|
+
entityId: required.id,
|
|
236
|
+
actor: required.actor,
|
|
237
|
+
display: required.display,
|
|
238
|
+
at: required.at,
|
|
239
|
+
expectedVersion: required.expectedVersion,
|
|
240
|
+
}, optional);
|
|
241
|
+
}
|
|
242
|
+
//# sourceMappingURL=write-service.js.map
|