@x12i/memorix-service 3.12.3 → 3.13.0
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/README.md +2 -2
- package/dist/activity-ui.js +1 -1
- package/dist/agent-catalog.d.ts +32 -0
- package/dist/agent-catalog.d.ts.map +1 -0
- package/dist/agent-catalog.js +80 -0
- package/dist/agent-catalog.js.map +1 -0
- package/dist/analytics/engine.d.ts.map +1 -1
- package/dist/analytics/engine.js +2 -1
- package/dist/analytics/engine.js.map +1 -1
- package/dist/analytics/state.d.ts +1 -1
- package/dist/analytics/tracker.d.ts +2 -0
- package/dist/analytics/tracker.d.ts.map +1 -1
- package/dist/analytics/tracker.js +15 -8
- package/dist/analytics/tracker.js.map +1 -1
- package/dist/anomaly/catalog.d.ts +4 -0
- package/dist/anomaly/catalog.d.ts.map +1 -0
- package/dist/anomaly/catalog.js +315 -0
- package/dist/anomaly/catalog.js.map +1 -0
- package/dist/anomaly/descriptors.d.ts +11 -0
- package/dist/anomaly/descriptors.d.ts.map +1 -0
- package/dist/anomaly/descriptors.js +113 -0
- package/dist/anomaly/descriptors.js.map +1 -0
- package/dist/anomaly/index.d.ts +6 -0
- package/dist/anomaly/index.d.ts.map +1 -0
- package/dist/anomaly/index.js +5 -0
- package/dist/anomaly/index.js.map +1 -0
- package/dist/anomaly/records.d.ts +28 -0
- package/dist/anomaly/records.d.ts.map +1 -0
- package/dist/anomaly/records.js +384 -0
- package/dist/anomaly/records.js.map +1 -0
- package/dist/anomaly/types.d.ts +116 -0
- package/dist/anomaly/types.d.ts.map +1 -0
- package/dist/anomaly/types.js +24 -0
- package/dist/anomaly/types.js.map +1 -0
- package/dist/anomaly/values.d.ts +31 -0
- package/dist/anomaly/values.d.ts.map +1 -0
- package/dist/anomaly/values.js +171 -0
- package/dist/anomaly/values.js.map +1 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +34 -1
- package/dist/app.js.map +1 -1
- package/dist/arrivals-backfill.d.ts +23 -0
- package/dist/arrivals-backfill.d.ts.map +1 -0
- package/dist/arrivals-backfill.js +132 -0
- package/dist/arrivals-backfill.js.map +1 -0
- package/dist/arrivals-routes.d.ts +8 -0
- package/dist/arrivals-routes.d.ts.map +1 -0
- package/dist/arrivals-routes.js +17 -0
- package/dist/arrivals-routes.js.map +1 -0
- package/dist/arrivals-ui.d.ts +3 -0
- package/dist/arrivals-ui.d.ts.map +1 -0
- package/dist/arrivals-ui.js +277 -0
- package/dist/arrivals-ui.js.map +1 -0
- package/dist/catalog-bridge.d.ts +18 -0
- package/dist/catalog-bridge.d.ts.map +1 -1
- package/dist/catalog-bridge.js +115 -7
- package/dist/catalog-bridge.js.map +1 -1
- package/dist/catalog-live-ui.js +1 -1
- package/dist/content-arrivals-countex.d.ts +90 -0
- package/dist/content-arrivals-countex.d.ts.map +1 -0
- package/dist/content-arrivals-countex.js +252 -0
- package/dist/content-arrivals-countex.js.map +1 -0
- package/dist/data-changes-ui.js +1 -1
- package/dist/data-changes.d.ts +3 -0
- package/dist/data-changes.d.ts.map +1 -1
- package/dist/data-changes.js +22 -21
- package/dist/data-changes.js.map +1 -1
- package/dist/errors.d.ts +6 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +22 -2
- package/dist/errors.js.map +1 -1
- package/dist/hooks-live-ui.js +1 -1
- package/dist/knowledge-upstream.d.ts +32 -0
- package/dist/knowledge-upstream.d.ts.map +1 -1
- package/dist/knowledge-upstream.js +86 -2
- package/dist/knowledge-upstream.js.map +1 -1
- package/dist/land-v2-guards.d.ts +5 -12
- package/dist/land-v2-guards.d.ts.map +1 -1
- package/dist/land-v2-guards.js +10 -33
- package/dist/land-v2-guards.js.map +1 -1
- package/dist/live-ops-nav.d.ts.map +1 -1
- package/dist/live-ops-nav.js +4 -0
- package/dist/live-ops-nav.js.map +1 -1
- package/dist/mcp/nexum-tools.d.ts +1 -1
- package/dist/mcp/nexum-tools.d.ts.map +1 -1
- package/dist/mcp/nexum-tools.js +39 -22
- package/dist/mcp/nexum-tools.js.map +1 -1
- package/dist/nexum-analysis/enqueue.d.ts.map +1 -1
- package/dist/nexum-analysis/enqueue.js +2 -1
- package/dist/nexum-analysis/enqueue.js.map +1 -1
- package/dist/nexum-analysis/knowledge-read.d.ts +45 -0
- package/dist/nexum-analysis/knowledge-read.d.ts.map +1 -0
- package/dist/nexum-analysis/knowledge-read.js +26 -0
- package/dist/nexum-analysis/knowledge-read.js.map +1 -0
- package/dist/nexum-analysis/outbox.d.ts +1 -1
- package/dist/nexum-analysis/reconcile.d.ts.map +1 -1
- package/dist/nexum-analysis/reconcile.js +3 -2
- package/dist/nexum-analysis/reconcile.js.map +1 -1
- package/dist/openapi/openapi.json +18354 -17673
- package/dist/operational-db.d.ts +2 -2
- package/dist/operational-db.d.ts.map +1 -1
- package/dist/operational-db.js +8 -11
- package/dist/operational-db.js.map +1 -1
- package/dist/patches/access.d.ts +15 -0
- package/dist/patches/access.d.ts.map +1 -0
- package/dist/patches/access.js +27 -0
- package/dist/patches/access.js.map +1 -0
- package/dist/patches/engine.d.ts +67 -0
- package/dist/patches/engine.d.ts.map +1 -0
- package/dist/patches/engine.js +966 -0
- package/dist/patches/engine.js.map +1 -0
- package/dist/patches/guards.d.ts +23 -0
- package/dist/patches/guards.d.ts.map +1 -0
- package/dist/patches/guards.js +77 -0
- package/dist/patches/guards.js.map +1 -0
- package/dist/patches/legacy-rows.d.ts +56 -0
- package/dist/patches/legacy-rows.d.ts.map +1 -0
- package/dist/patches/legacy-rows.js +162 -0
- package/dist/patches/legacy-rows.js.map +1 -0
- package/dist/patches/ops.d.ts +24 -0
- package/dist/patches/ops.d.ts.map +1 -0
- package/dist/patches/ops.js +204 -0
- package/dist/patches/ops.js.map +1 -0
- package/dist/patches/parse.d.ts +5 -0
- package/dist/patches/parse.d.ts.map +1 -0
- package/dist/patches/parse.js +323 -0
- package/dist/patches/parse.js.map +1 -0
- package/dist/patches/paths.d.ts +38 -0
- package/dist/patches/paths.d.ts.map +1 -0
- package/dist/patches/paths.js +204 -0
- package/dist/patches/paths.js.map +1 -0
- package/dist/patches/service.d.ts +16 -0
- package/dist/patches/service.d.ts.map +1 -0
- package/dist/patches/service.js +128 -0
- package/dist/patches/service.js.map +1 -0
- package/dist/patches/store.d.ts +87 -0
- package/dist/patches/store.d.ts.map +1 -0
- package/dist/patches/store.js +27 -0
- package/dist/patches/store.js.map +1 -0
- package/dist/patches/types.d.ts +112 -0
- package/dist/patches/types.d.ts.map +1 -0
- package/dist/patches/types.js +5 -0
- package/dist/patches/types.js.map +1 -0
- package/dist/platform.d.ts +17 -3
- package/dist/platform.d.ts.map +1 -1
- package/dist/platform.js +114 -11
- package/dist/platform.js.map +1 -1
- package/dist/routes/anomaly.d.ts +5 -0
- package/dist/routes/anomaly.d.ts.map +1 -0
- package/dist/routes/anomaly.js +308 -0
- package/dist/routes/anomaly.js.map +1 -0
- package/dist/routes/data.d.ts.map +1 -1
- package/dist/routes/data.js +252 -210
- package/dist/routes/data.js.map +1 -1
- package/dist/routes/knowledge.d.ts.map +1 -1
- package/dist/routes/knowledge.js +6 -22
- package/dist/routes/knowledge.js.map +1 -1
- package/dist/routes/memory.d.ts.map +1 -1
- package/dist/routes/memory.js +2 -4
- package/dist/routes/memory.js.map +1 -1
- package/dist/routes/metadata.d.ts.map +1 -1
- package/dist/routes/metadata.js +40 -1
- package/dist/routes/metadata.js.map +1 -1
- package/dist/routes/mgc-landing.d.ts.map +1 -1
- package/dist/routes/mgc-landing.js +64 -94
- package/dist/routes/mgc-landing.js.map +1 -1
- package/dist/routes/patches.d.ts +7 -0
- package/dist/routes/patches.d.ts.map +1 -0
- package/dist/routes/patches.js +111 -0
- package/dist/routes/patches.js.map +1 -0
- package/dist/routes/subject-roots-backfill.d.ts +4 -0
- package/dist/routes/subject-roots-backfill.d.ts.map +1 -0
- package/dist/routes/subject-roots-backfill.js +15 -0
- package/dist/routes/subject-roots-backfill.js.map +1 -0
- package/dist/routes/workflow.d.ts.map +1 -1
- package/dist/routes/workflow.js +4 -10
- package/dist/routes/workflow.js.map +1 -1
- package/dist/single-read-discriminator.d.ts +17 -0
- package/dist/single-read-discriminator.d.ts.map +1 -0
- package/dist/single-read-discriminator.js +67 -0
- package/dist/single-read-discriminator.js.map +1 -0
- package/dist/subject-roots-backfill.d.ts +24 -0
- package/dist/subject-roots-backfill.d.ts.map +1 -0
- package/dist/subject-roots-backfill.js +133 -0
- package/dist/subject-roots-backfill.js.map +1 -0
- package/dist/write-precondition.d.ts +5 -1
- package/dist/write-precondition.d.ts.map +1 -1
- package/dist/write-precondition.js +11 -3
- package/dist/write-precondition.js.map +1 -1
- package/dist/writes/content-write.d.ts +170 -0
- package/dist/writes/content-write.d.ts.map +1 -0
- package/dist/writes/content-write.js +569 -0
- package/dist/writes/content-write.js.map +1 -0
- package/dist/writes/effects.d.ts +13 -0
- package/dist/writes/effects.d.ts.map +1 -0
- package/dist/writes/effects.js +28 -0
- package/dist/writes/effects.js.map +1 -0
- package/dist/writes/error-shape.d.ts +11 -0
- package/dist/writes/error-shape.d.ts.map +1 -0
- package/dist/writes/error-shape.js +100 -0
- package/dist/writes/error-shape.js.map +1 -0
- package/dist/writes/receipt-store.d.ts +81 -0
- package/dist/writes/receipt-store.d.ts.map +1 -0
- package/dist/writes/receipt-store.js +171 -0
- package/dist/writes/receipt-store.js.map +1 -0
- package/dist/writes/worker.d.ts +9 -0
- package/dist/writes/worker.d.ts.map +1 -0
- package/dist/writes/worker.js +20 -0
- package/dist/writes/worker.js.map +1 -0
- package/openapi/openapi.json +18354 -17673
- package/package.json +4 -4
|
@@ -0,0 +1,966 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MX-PATCH-01 — plan, apply, and revert a patch on stored content rows.
|
|
3
|
+
*
|
|
4
|
+
* Rows are read and written directly so a row the writer cannot address (a legacy row
|
|
5
|
+
* without a discriminator) can still be fixed. Each write keeps the content-write rules:
|
|
6
|
+
* a revision precondition, a new revision, `_history`, `_system.modifiedAt`, and JSON
|
|
7
|
+
* markdown rebuilt from `data`.
|
|
8
|
+
*/
|
|
9
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
10
|
+
import { canonicalJson, computeDiscriminatedStorageKey, createCollectionRouter, readStoredMarkdown, resolveRecordMarkdown, } from "@x12i/memorix-data";
|
|
11
|
+
import { ServiceError } from "../errors.js";
|
|
12
|
+
import { applyOps, bodyChanged, bodyDiff } from "./ops.js";
|
|
13
|
+
import { invalid } from "./parse.js";
|
|
14
|
+
import { evaluatePredicate, isPlainObject, PatchRowError, readPlain } from "./paths.js";
|
|
15
|
+
import { invalidatePatchGuards } from "./guards.js";
|
|
16
|
+
import { patchCollections, } from "./store.js";
|
|
17
|
+
const PAGE = 500;
|
|
18
|
+
const WORK_BATCH = 50;
|
|
19
|
+
const SAMPLE_LIMIT = 20;
|
|
20
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
21
|
+
const PROCESS_OWNER = randomBytes(6).toString("hex");
|
|
22
|
+
const running = new Set();
|
|
23
|
+
const router = createCollectionRouter();
|
|
24
|
+
function envInt(name, fallback) {
|
|
25
|
+
const raw = Number(process.env[name]);
|
|
26
|
+
return Number.isInteger(raw) && raw > 0 ? raw : fallback;
|
|
27
|
+
}
|
|
28
|
+
export function patchLimits(overrides) {
|
|
29
|
+
return {
|
|
30
|
+
maxRows: envInt("MEMORIX_PATCH_MAX_ROWS", 100_000),
|
|
31
|
+
maxRowsPerHour: envInt("MEMORIX_PATCH_MAX_ROWS_PER_HOUR", 250_000),
|
|
32
|
+
syncRows: envInt("MEMORIX_PATCH_SYNC_ROWS", 200),
|
|
33
|
+
planTtlMs: DAY_MS,
|
|
34
|
+
retainMs: 15 * DAY_MS,
|
|
35
|
+
leaseMs: 120_000,
|
|
36
|
+
...overrides,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function nowOf(deps) {
|
|
40
|
+
return (deps.now ?? (() => new Date().toISOString()))();
|
|
41
|
+
}
|
|
42
|
+
function plusMs(iso, ms) {
|
|
43
|
+
return new Date(Date.parse(iso) + ms).toISOString();
|
|
44
|
+
}
|
|
45
|
+
function newPatchId() {
|
|
46
|
+
return `mxp_${Date.now().toString(36).toUpperCase().padStart(9, "0")}${randomBytes(8).toString("hex").toUpperCase()}`;
|
|
47
|
+
}
|
|
48
|
+
function message(err) {
|
|
49
|
+
return err instanceof Error ? err.message : String(err);
|
|
50
|
+
}
|
|
51
|
+
function isInfrastructureError(err) {
|
|
52
|
+
const name = err instanceof Error ? err.name : "";
|
|
53
|
+
return /^Mongo(Network|ServerSelection|NotConnected|Topology)/.test(name);
|
|
54
|
+
}
|
|
55
|
+
function resolveTarget(deps, objectType, contentType) {
|
|
56
|
+
let routed;
|
|
57
|
+
try {
|
|
58
|
+
routed = router.contentCollection(deps.scope, deps.catalog, objectType, contentType);
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
invalid(`${objectType}/${contentType}: ${message(err)}`, "select.contentTypes");
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
objectType,
|
|
65
|
+
contentType,
|
|
66
|
+
coll: deps.client.db(routed.database.databaseName).collection(routed.collectionName),
|
|
67
|
+
discriminatorPath: deps.catalog.getContentProfile?.(contentType)?.discriminator?.path,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function escapeRegex(value) {
|
|
71
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
72
|
+
}
|
|
73
|
+
function selectFilter(select, discriminatorPath) {
|
|
74
|
+
const filter = {};
|
|
75
|
+
const and = [];
|
|
76
|
+
if (select.recordIds)
|
|
77
|
+
filter.recordId = { $in: select.recordIds };
|
|
78
|
+
if (select.idPrefix)
|
|
79
|
+
filter.recordId = { $regex: `^${escapeRegex(select.idPrefix)}` };
|
|
80
|
+
if (select.state === "active")
|
|
81
|
+
filter["_system.state"] = { $in: ["active", null] };
|
|
82
|
+
else if (select.state === "tombstoned")
|
|
83
|
+
filter["_system.state"] = "archived";
|
|
84
|
+
if (select.discriminator && "value" in select.discriminator && discriminatorPath) {
|
|
85
|
+
filter[discriminatorPath] = select.discriminator.value;
|
|
86
|
+
and.push({ $expr: { $ne: ["$_id", "$recordId"] } });
|
|
87
|
+
}
|
|
88
|
+
if (select.discriminator && "absent" in select.discriminator && discriminatorPath) {
|
|
89
|
+
and.push({
|
|
90
|
+
$or: [{ [discriminatorPath]: { $in: [null, ""] } }, { $expr: { $eq: ["$_id", "$recordId"] } }],
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
if (and.length)
|
|
94
|
+
filter.$and = and;
|
|
95
|
+
return filter;
|
|
96
|
+
}
|
|
97
|
+
function withoutId(doc) {
|
|
98
|
+
const { _id: _drop, ...rest } = doc;
|
|
99
|
+
void _drop;
|
|
100
|
+
return rest;
|
|
101
|
+
}
|
|
102
|
+
function recordIdOf(doc) {
|
|
103
|
+
return typeof doc.recordId === "string" ? doc.recordId : doc._id;
|
|
104
|
+
}
|
|
105
|
+
function revisionOf(doc) {
|
|
106
|
+
return Number(doc.revision);
|
|
107
|
+
}
|
|
108
|
+
function stateOf(doc) {
|
|
109
|
+
const system = doc._system;
|
|
110
|
+
return isPlainObject(system) && typeof system.state === "string" ? system.state : "active";
|
|
111
|
+
}
|
|
112
|
+
function setDiscriminatorOp(ops) {
|
|
113
|
+
return ops.find((op) => op.op === "setDiscriminator");
|
|
114
|
+
}
|
|
115
|
+
async function evaluate(target, doc, ops) {
|
|
116
|
+
const stored = withoutId(doc);
|
|
117
|
+
let result;
|
|
118
|
+
try {
|
|
119
|
+
result = applyOps(stored, ops, { discriminatorPath: target.discriminatorPath });
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
if (err instanceof PatchRowError)
|
|
123
|
+
return { kind: "fail", error: err.message };
|
|
124
|
+
throw err;
|
|
125
|
+
}
|
|
126
|
+
if (result.conflict)
|
|
127
|
+
return { kind: "conflict", reason: result.conflict };
|
|
128
|
+
const diff = bodyDiff(stored, result.record);
|
|
129
|
+
const sample = () => ({
|
|
130
|
+
recordId: recordIdOf(doc),
|
|
131
|
+
contentType: target.contentType,
|
|
132
|
+
revision: revisionOf(doc),
|
|
133
|
+
before: diff.before,
|
|
134
|
+
after: diff.after,
|
|
135
|
+
});
|
|
136
|
+
if (result.deleteRecord === "purge") {
|
|
137
|
+
const s = sample();
|
|
138
|
+
s.before.deleted = false;
|
|
139
|
+
s.after.deleted = true;
|
|
140
|
+
return { kind: "change", result, sample: s };
|
|
141
|
+
}
|
|
142
|
+
if (result.deleteRecord === "tombstone") {
|
|
143
|
+
if (stateOf(stored) === "archived" && !bodyChanged(stored, result.record))
|
|
144
|
+
return { kind: "unchanged" };
|
|
145
|
+
const s = sample();
|
|
146
|
+
s.before.state = stateOf(stored);
|
|
147
|
+
s.after.state = "archived";
|
|
148
|
+
return { kind: "change", result, sample: s };
|
|
149
|
+
}
|
|
150
|
+
if (result.discriminator && target.discriminatorPath) {
|
|
151
|
+
const recordId = recordIdOf(doc);
|
|
152
|
+
const newKey = computeDiscriminatedStorageKey({
|
|
153
|
+
recordId,
|
|
154
|
+
contentType: target.contentType,
|
|
155
|
+
discriminatorPath: target.discriminatorPath,
|
|
156
|
+
discriminatorValue: result.discriminator.value,
|
|
157
|
+
});
|
|
158
|
+
if (newKey !== doc._id) {
|
|
159
|
+
const existing = await target.coll.findOne({ _id: newKey });
|
|
160
|
+
if (existing && result.discriminator.onExisting === "skip") {
|
|
161
|
+
return {
|
|
162
|
+
kind: "conflict",
|
|
163
|
+
reason: `instance ${result.discriminator.value} already exists for ${recordId}`,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
const s = sample();
|
|
167
|
+
const current = doc._id === recordId ? null : (readPlain(stored, target.discriminatorPath) ?? null);
|
|
168
|
+
s.before.discriminator = current;
|
|
169
|
+
if (existing && result.discriminator.onExisting === "deleteLegacy") {
|
|
170
|
+
s.after = { deleted: true, keptInstance: result.discriminator.value };
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
s.after.discriminator = result.discriminator.value;
|
|
174
|
+
if (existing)
|
|
175
|
+
s.after.replacesInstanceRevision = revisionOf(existing);
|
|
176
|
+
}
|
|
177
|
+
return { kind: "change", result, newKey, existingTarget: existing, sample: s };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (!bodyChanged(stored, result.record))
|
|
181
|
+
return { kind: "unchanged" };
|
|
182
|
+
return { kind: "change", result, sample: sample() };
|
|
183
|
+
}
|
|
184
|
+
function collectWarnings(deps, request, targets) {
|
|
185
|
+
const warnings = new Set();
|
|
186
|
+
const meta = deps.catalog.getObjectType(request.select.objectType);
|
|
187
|
+
const dataPaths = [];
|
|
188
|
+
for (const op of request.ops) {
|
|
189
|
+
const paths = op.op === "move" || op.op === "copy"
|
|
190
|
+
? [op.from, op.to]
|
|
191
|
+
: op.op === "set" || op.op === "mapValues" || op.op === "dropValues" || op.op === "rename"
|
|
192
|
+
? [op.path]
|
|
193
|
+
: op.op === "delete" && "path" in op
|
|
194
|
+
? [op.path]
|
|
195
|
+
: [];
|
|
196
|
+
for (const path of paths) {
|
|
197
|
+
if (path.startsWith("data."))
|
|
198
|
+
dataPaths.push(path.replace(/\[\]/g, ""));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const disc = setDiscriminatorOp(request.ops);
|
|
202
|
+
const selected = request.select.discriminator && "value" in request.select.discriminator
|
|
203
|
+
? request.select.discriminator.value
|
|
204
|
+
: undefined;
|
|
205
|
+
for (const target of targets) {
|
|
206
|
+
const fields = (meta?.fields ?? []).filter((f) => !f.contentType || f.contentType === target.contentType);
|
|
207
|
+
if (fields.length) {
|
|
208
|
+
for (const path of dataPaths) {
|
|
209
|
+
const declared = fields.some((f) => f.path === path || path.startsWith(`${f.path}.`) || f.path.startsWith(`${path}.`));
|
|
210
|
+
if (!declared) {
|
|
211
|
+
warnings.add(`${path} is not declared for ${target.objectType}/${target.contentType}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (target.discriminatorPath && deps.declaredDiscriminatorValues) {
|
|
216
|
+
const values = deps.declaredDiscriminatorValues(target.objectType, target.contentType, target.discriminatorPath);
|
|
217
|
+
for (const value of [disc?.value, selected]) {
|
|
218
|
+
if (value !== undefined && values && !values.includes(value)) {
|
|
219
|
+
warnings.add(`${target.discriminatorPath}='${value}' is not one of ${values.join(", ")} for ${target.contentType}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return [...warnings];
|
|
225
|
+
}
|
|
226
|
+
async function loadPatch(deps, patchId) {
|
|
227
|
+
const { patches } = await patchCollections(deps.operationalDb);
|
|
228
|
+
const doc = await patches.findOne({ _id: patchId, orgId: deps.scope.orgId });
|
|
229
|
+
if (!doc)
|
|
230
|
+
throw new ServiceError("PATCH_NOT_FOUND", `patch ${patchId} not found`, 404, { patchId });
|
|
231
|
+
if (doc.state === "planned" && doc.planExpiresAt < nowOf(deps)) {
|
|
232
|
+
throw new ServiceError("PATCH_NOT_FOUND", `the plan for ${patchId} expired; plan the patch again`, 404, {
|
|
233
|
+
patchId,
|
|
234
|
+
expiredAt: doc.planExpiresAt,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
return doc;
|
|
238
|
+
}
|
|
239
|
+
export async function planPatch(deps, request) {
|
|
240
|
+
const limits = patchLimits(deps.limits);
|
|
241
|
+
const now = nowOf(deps);
|
|
242
|
+
const { patches, rows } = await patchCollections(deps.operationalDb);
|
|
243
|
+
const targets = request.select.contentTypes.map((ct) => resolveTarget(deps, request.select.objectType, ct));
|
|
244
|
+
const disc = setDiscriminatorOp(request.ops);
|
|
245
|
+
for (const target of targets) {
|
|
246
|
+
if (!target.discriminatorPath && request.select.discriminator && "value" in request.select.discriminator) {
|
|
247
|
+
invalid(`${target.contentType} has no discriminator; select.discriminator.value does not apply`, "select.discriminator");
|
|
248
|
+
}
|
|
249
|
+
if (!target.discriminatorPath && disc) {
|
|
250
|
+
invalid(`${target.contentType} has no discriminator; setDiscriminator does not apply`, "ops");
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const warnings = collectWarnings(deps, request, targets);
|
|
254
|
+
const patchId = newPatchId();
|
|
255
|
+
const planExpiresAt = plusMs(now, limits.planTtlMs);
|
|
256
|
+
const expireAt = new Date(planExpiresAt);
|
|
257
|
+
const hash = createHash("sha256").update(canonicalJson({ orgId: deps.scope.orgId, request }));
|
|
258
|
+
const totals = { matched: 0, wouldChange: 0, unchanged: 0, conflicts: 0, failed: 0 };
|
|
259
|
+
const samples = [];
|
|
260
|
+
let limitHit = false;
|
|
261
|
+
let seq = 0;
|
|
262
|
+
let pending = [];
|
|
263
|
+
const flush = async () => {
|
|
264
|
+
if (!pending.length)
|
|
265
|
+
return;
|
|
266
|
+
await rows.insertMany(pending, { ordered: false });
|
|
267
|
+
pending = [];
|
|
268
|
+
};
|
|
269
|
+
try {
|
|
270
|
+
scan: for (const target of targets) {
|
|
271
|
+
const base = selectFilter(request.select, target.discriminatorPath);
|
|
272
|
+
let last;
|
|
273
|
+
for (;;) {
|
|
274
|
+
const filter = last === undefined ? base : { ...base, _id: { $gt: last } };
|
|
275
|
+
const page = await target.coll.find(filter).sort({ _id: 1 }).limit(PAGE).toArray();
|
|
276
|
+
for (const doc of page) {
|
|
277
|
+
last = doc._id;
|
|
278
|
+
if (request.select.where && !request.select.where.every((p) => evaluatePredicate(doc, p)))
|
|
279
|
+
continue;
|
|
280
|
+
if (request.select.limit !== undefined && totals.matched >= request.select.limit) {
|
|
281
|
+
limitHit = true;
|
|
282
|
+
break scan;
|
|
283
|
+
}
|
|
284
|
+
totals.matched += 1;
|
|
285
|
+
if (totals.matched > limits.maxRows) {
|
|
286
|
+
throw new ServiceError("PATCH_TOO_LARGE", `the patch matches more than ${limits.maxRows} rows; narrow select or set select.limit`, 413, { cap: limits.maxRows, field: "select", retryable: false });
|
|
287
|
+
}
|
|
288
|
+
const evaluation = await evaluate(target, doc, request.ops);
|
|
289
|
+
const planned = evaluation.kind === "change" ? "change" : evaluation.kind === "fail" ? "fail" : evaluation.kind;
|
|
290
|
+
if (evaluation.kind === "change") {
|
|
291
|
+
totals.wouldChange += 1;
|
|
292
|
+
if (samples.length < SAMPLE_LIMIT)
|
|
293
|
+
samples.push(evaluation.sample);
|
|
294
|
+
}
|
|
295
|
+
else if (evaluation.kind === "unchanged")
|
|
296
|
+
totals.unchanged += 1;
|
|
297
|
+
else if (evaluation.kind === "conflict")
|
|
298
|
+
totals.conflicts += 1;
|
|
299
|
+
else
|
|
300
|
+
totals.failed += 1;
|
|
301
|
+
const row = {
|
|
302
|
+
_id: `${patchId}:${seq}`,
|
|
303
|
+
patchId,
|
|
304
|
+
seq,
|
|
305
|
+
objectType: target.objectType,
|
|
306
|
+
contentType: target.contentType,
|
|
307
|
+
recordId: recordIdOf(doc),
|
|
308
|
+
storageKey: doc._id,
|
|
309
|
+
plannedRevision: revisionOf(doc),
|
|
310
|
+
planned,
|
|
311
|
+
...(evaluation.kind === "conflict" ? { plannedError: evaluation.reason } : {}),
|
|
312
|
+
...(evaluation.kind === "fail" ? { plannedError: evaluation.error } : {}),
|
|
313
|
+
expireAt,
|
|
314
|
+
};
|
|
315
|
+
seq += 1;
|
|
316
|
+
hash.update(`${target.contentType}\u0000${row.storageKey}\u0000${row.plannedRevision}\u0000${planned}\n`);
|
|
317
|
+
pending.push(row);
|
|
318
|
+
if (pending.length >= PAGE)
|
|
319
|
+
await flush();
|
|
320
|
+
}
|
|
321
|
+
if (page.length < PAGE)
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
await flush();
|
|
326
|
+
}
|
|
327
|
+
catch (err) {
|
|
328
|
+
await rows.deleteMany({ patchId }).catch(() => undefined);
|
|
329
|
+
throw err;
|
|
330
|
+
}
|
|
331
|
+
const planChecksum = `sha256:${hash.digest("hex")}`;
|
|
332
|
+
const doc = {
|
|
333
|
+
_id: patchId,
|
|
334
|
+
patchId,
|
|
335
|
+
orgId: deps.scope.orgId,
|
|
336
|
+
agentIds: [...deps.scope.agentIds],
|
|
337
|
+
caller: deps.caller,
|
|
338
|
+
objectType: request.select.objectType,
|
|
339
|
+
contentTypes: [...request.select.contentTypes],
|
|
340
|
+
request,
|
|
341
|
+
state: "planned",
|
|
342
|
+
planChecksum,
|
|
343
|
+
plan: { ...totals, limitHit, warnings, samples },
|
|
344
|
+
counts: { changed: 0, unchanged: 0, drifted: 0, conflicts: 0, failed: 0 },
|
|
345
|
+
guards: request.guards,
|
|
346
|
+
guardsActive: false,
|
|
347
|
+
createdAt: now,
|
|
348
|
+
planExpiresAt,
|
|
349
|
+
expireAt,
|
|
350
|
+
};
|
|
351
|
+
await patches.insertOne(doc);
|
|
352
|
+
return {
|
|
353
|
+
patchId,
|
|
354
|
+
state: "planned",
|
|
355
|
+
planChecksum,
|
|
356
|
+
...totals,
|
|
357
|
+
limitHit,
|
|
358
|
+
warnings,
|
|
359
|
+
samples,
|
|
360
|
+
guards: request.guards,
|
|
361
|
+
planExpiresAt,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
async function liveCounts(rows, patchId) {
|
|
365
|
+
const grouped = await rows
|
|
366
|
+
.aggregate([
|
|
367
|
+
{ $match: { patchId } },
|
|
368
|
+
{ $group: { _id: { outcome: "$outcome", revert: "$revert" }, n: { $sum: 1 } } },
|
|
369
|
+
])
|
|
370
|
+
.toArray();
|
|
371
|
+
const counts = { changed: 0, unchanged: 0, drifted: 0, conflicts: 0, failed: 0 };
|
|
372
|
+
const revert = { reverted: 0, drifted: 0, failed: 0 };
|
|
373
|
+
for (const { _id, n } of grouped) {
|
|
374
|
+
if (_id.outcome === "changed")
|
|
375
|
+
counts.changed += n;
|
|
376
|
+
else if (_id.outcome === "unchanged")
|
|
377
|
+
counts.unchanged += n;
|
|
378
|
+
else if (_id.outcome === "drifted")
|
|
379
|
+
counts.drifted += n;
|
|
380
|
+
else if (_id.outcome === "conflict")
|
|
381
|
+
counts.conflicts += n;
|
|
382
|
+
else if (_id.outcome === "failed")
|
|
383
|
+
counts.failed += n;
|
|
384
|
+
if (_id.revert === "reverted")
|
|
385
|
+
revert.reverted += n;
|
|
386
|
+
else if (_id.revert === "drifted")
|
|
387
|
+
revert.drifted += n;
|
|
388
|
+
else if (_id.revert === "failed")
|
|
389
|
+
revert.failed += n;
|
|
390
|
+
}
|
|
391
|
+
return { counts, revert };
|
|
392
|
+
}
|
|
393
|
+
function statusBody(doc, live) {
|
|
394
|
+
const counts = live?.counts ?? doc.counts;
|
|
395
|
+
const revertCounts = live && (doc.state === "reverting" || doc.state === "reverted") ? live.revert : doc.revertCounts;
|
|
396
|
+
return {
|
|
397
|
+
patchId: doc.patchId,
|
|
398
|
+
state: doc.state,
|
|
399
|
+
objectType: doc.objectType,
|
|
400
|
+
contentTypes: doc.contentTypes,
|
|
401
|
+
...(doc.request.reason ? { reason: doc.request.reason } : {}),
|
|
402
|
+
caller: doc.caller,
|
|
403
|
+
createdAt: doc.createdAt,
|
|
404
|
+
...(doc.state === "planned" ? { planExpiresAt: doc.planExpiresAt } : {}),
|
|
405
|
+
...(doc.startedAt ? { startedAt: doc.startedAt } : {}),
|
|
406
|
+
...(doc.finishedAt ? { finishedAt: doc.finishedAt } : {}),
|
|
407
|
+
...(doc.revertStartedAt ? { revertStartedAt: doc.revertStartedAt } : {}),
|
|
408
|
+
...(doc.revertedAt ? { revertedAt: doc.revertedAt } : {}),
|
|
409
|
+
...(doc.revertRetainUntil ? { revertRetainUntil: doc.revertRetainUntil } : {}),
|
|
410
|
+
planChecksum: doc.planChecksum,
|
|
411
|
+
plan: doc.plan,
|
|
412
|
+
counts,
|
|
413
|
+
...(revertCounts ? { revertCounts } : {}),
|
|
414
|
+
request: doc.request,
|
|
415
|
+
guards: doc.guards,
|
|
416
|
+
guardsActive: doc.guardsActive,
|
|
417
|
+
...(doc.error ? { error: doc.error } : {}),
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
export async function getPatchStatus(deps, patchId) {
|
|
421
|
+
const doc = await loadPatch(deps, patchId);
|
|
422
|
+
if (doc.state === "applying" || doc.state === "reverting") {
|
|
423
|
+
const { rows } = await patchCollections(deps.operationalDb);
|
|
424
|
+
return statusBody(doc, await liveCounts(rows, patchId));
|
|
425
|
+
}
|
|
426
|
+
return statusBody(doc);
|
|
427
|
+
}
|
|
428
|
+
export async function listPatches(deps, query) {
|
|
429
|
+
const { patches } = await patchCollections(deps.operationalDb);
|
|
430
|
+
const limit = Math.min(Math.max(query.limit ?? 50, 1), 200);
|
|
431
|
+
const filter = {
|
|
432
|
+
orgId: deps.scope.orgId,
|
|
433
|
+
$or: [{ state: { $ne: "planned" } }, { planExpiresAt: { $gt: nowOf(deps) } }],
|
|
434
|
+
};
|
|
435
|
+
if (query.objectType)
|
|
436
|
+
filter.objectType = query.objectType;
|
|
437
|
+
if (query.state)
|
|
438
|
+
filter.state = query.state;
|
|
439
|
+
const docs = await patches.find(filter).sort({ createdAt: -1 }).limit(limit).toArray();
|
|
440
|
+
return { items: docs.map((doc) => statusBody(doc)) };
|
|
441
|
+
}
|
|
442
|
+
const ROW_OUTCOMES = new Set(["changed", "unchanged", "drifted", "conflict", "failed"]);
|
|
443
|
+
export async function listPatchRecords(deps, patchId, query) {
|
|
444
|
+
await loadPatch(deps, patchId);
|
|
445
|
+
const { rows } = await patchCollections(deps.operationalDb);
|
|
446
|
+
const limit = Math.min(Math.max(query.limit ?? 100, 1), 500);
|
|
447
|
+
const filter = { patchId };
|
|
448
|
+
if (query.outcome !== undefined) {
|
|
449
|
+
if (query.outcome === "pending")
|
|
450
|
+
filter.outcome = { $exists: false };
|
|
451
|
+
else if (ROW_OUTCOMES.has(query.outcome))
|
|
452
|
+
filter.outcome = query.outcome;
|
|
453
|
+
else {
|
|
454
|
+
throw new ServiceError("VALIDATION", "outcome must be changed, unchanged, drifted, conflict, failed, or pending", 400, {
|
|
455
|
+
field: "outcome",
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
if (query.cursor !== undefined) {
|
|
460
|
+
const after = Number(query.cursor);
|
|
461
|
+
if (!Number.isInteger(after) || after < 0) {
|
|
462
|
+
throw new ServiceError("VALIDATION", "cursor is invalid", 400, { field: "cursor" });
|
|
463
|
+
}
|
|
464
|
+
filter.seq = { $gt: after };
|
|
465
|
+
}
|
|
466
|
+
const page = await rows
|
|
467
|
+
.find(filter, { projection: { before: 0, beforeTarget: 0 } })
|
|
468
|
+
.sort({ seq: 1 })
|
|
469
|
+
.limit(limit)
|
|
470
|
+
.toArray();
|
|
471
|
+
return {
|
|
472
|
+
items: page.map((row) => ({
|
|
473
|
+
recordId: row.recordId,
|
|
474
|
+
objectType: row.objectType,
|
|
475
|
+
contentType: row.contentType,
|
|
476
|
+
storageKey: row.storageKey,
|
|
477
|
+
plannedRevision: row.plannedRevision,
|
|
478
|
+
planned: row.planned,
|
|
479
|
+
...(row.plannedError ? { plannedError: row.plannedError } : {}),
|
|
480
|
+
...(row.outcome ? { outcome: row.outcome } : {}),
|
|
481
|
+
...(row.error ? { error: row.error } : {}),
|
|
482
|
+
...(row.after ? { after: row.after } : {}),
|
|
483
|
+
...(row.revert ? { revert: row.revert } : {}),
|
|
484
|
+
...(row.revertError ? { revertError: row.revertError } : {}),
|
|
485
|
+
})),
|
|
486
|
+
nextCursor: page.length === limit ? String(page.at(-1).seq) : null,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
function historyOf(doc) {
|
|
490
|
+
return doc && Array.isArray(doc._history) ? doc._history : [];
|
|
491
|
+
}
|
|
492
|
+
function nextDoc(current, patched, now, entry, tombstone) {
|
|
493
|
+
const system = isPlainObject(current._system) ? { ...current._system } : {};
|
|
494
|
+
system.modifiedAt = now;
|
|
495
|
+
system.writtenAt = now;
|
|
496
|
+
if (tombstone) {
|
|
497
|
+
system.state = "archived";
|
|
498
|
+
const lifecycle = Array.isArray(system.lifecycle) ? system.lifecycle : [];
|
|
499
|
+
system.lifecycle = [...lifecycle, { process: "patch", processedAt: now }];
|
|
500
|
+
}
|
|
501
|
+
const next = {
|
|
502
|
+
...patched,
|
|
503
|
+
revision: revisionOf(current) + 1,
|
|
504
|
+
_system: system,
|
|
505
|
+
_history: [...historyOf(current), entry],
|
|
506
|
+
};
|
|
507
|
+
next._markdown = resolveRecordMarkdown({
|
|
508
|
+
specified: false,
|
|
509
|
+
previous: readStoredMarkdown(current),
|
|
510
|
+
data: patched.data,
|
|
511
|
+
previousData: current.data,
|
|
512
|
+
now,
|
|
513
|
+
});
|
|
514
|
+
return next;
|
|
515
|
+
}
|
|
516
|
+
function restoredDoc(body, revision, history, now) {
|
|
517
|
+
const rest = withoutId(body);
|
|
518
|
+
const system = isPlainObject(rest._system) ? { ...rest._system } : {};
|
|
519
|
+
system.modifiedAt = now;
|
|
520
|
+
system.writtenAt = now;
|
|
521
|
+
return { ...rest, revision, _system: system, _history: history };
|
|
522
|
+
}
|
|
523
|
+
async function notify(deps, event) {
|
|
524
|
+
try {
|
|
525
|
+
await deps.onRowWritten?.(event);
|
|
526
|
+
}
|
|
527
|
+
catch (err) {
|
|
528
|
+
deps.log?.("patch: row effects failed", { recordId: event.recordId, error: message(err) });
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
function writtenByPatch(doc, patchId) {
|
|
532
|
+
const last = historyOf(doc ?? undefined).at(-1);
|
|
533
|
+
return Boolean(doc && last && last.patchId === patchId && last.action === "patch");
|
|
534
|
+
}
|
|
535
|
+
/** A row whose prior body was saved but whose outcome was not: the write may have landed before a restart. */
|
|
536
|
+
async function recoverApplied(target, patch, row) {
|
|
537
|
+
const disc = setDiscriminatorOp(patch.request.ops);
|
|
538
|
+
const current = await target.coll.findOne({ _id: row.storageKey });
|
|
539
|
+
if (disc && target.discriminatorPath) {
|
|
540
|
+
const newKey = computeDiscriminatedStorageKey({
|
|
541
|
+
recordId: row.recordId,
|
|
542
|
+
contentType: row.contentType,
|
|
543
|
+
discriminatorPath: target.discriminatorPath,
|
|
544
|
+
discriminatorValue: disc.value,
|
|
545
|
+
});
|
|
546
|
+
if (newKey !== row.storageKey) {
|
|
547
|
+
const moved = await target.coll.findOne({ _id: newKey });
|
|
548
|
+
if (writtenByPatch(moved, patch.patchId)) {
|
|
549
|
+
if (current && revisionOf(current) === row.plannedRevision) {
|
|
550
|
+
await target.coll.deleteOne({ _id: row.storageKey, revision: row.plannedRevision });
|
|
551
|
+
}
|
|
552
|
+
return { outcome: "changed", after: { storageKey: newKey, revision: revisionOf(moved) } };
|
|
553
|
+
}
|
|
554
|
+
if (!current && disc.onExisting === "deleteLegacy" && moved) {
|
|
555
|
+
return { outcome: "changed", after: { deleted: true, mode: "legacy", keptStorageKey: newKey } };
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
if (current && revisionOf(current) === row.plannedRevision)
|
|
560
|
+
return null;
|
|
561
|
+
if (writtenByPatch(current, patch.patchId)) {
|
|
562
|
+
return { outcome: "changed", after: { storageKey: row.storageKey, revision: revisionOf(current) } };
|
|
563
|
+
}
|
|
564
|
+
const purge = patch.request.ops.some((op) => op.op === "deleteRecord" && op.mode === "purge");
|
|
565
|
+
if (!current && purge)
|
|
566
|
+
return { outcome: "changed", after: { deleted: true, mode: "purge" } };
|
|
567
|
+
return { outcome: "drifted" };
|
|
568
|
+
}
|
|
569
|
+
async function commitChange(deps, patch, target, current, evaluation) {
|
|
570
|
+
const now = nowOf(deps);
|
|
571
|
+
const recordId = recordIdOf(current);
|
|
572
|
+
const revision = revisionOf(current);
|
|
573
|
+
const entry = { action: "patch", patchId: patch.patchId, caller: deps.caller, modifiedAt: now };
|
|
574
|
+
const { result } = evaluation;
|
|
575
|
+
const event = (op, next) => ({
|
|
576
|
+
objectType: target.objectType,
|
|
577
|
+
contentType: target.contentType,
|
|
578
|
+
recordId,
|
|
579
|
+
op,
|
|
580
|
+
...(next ? { revision: Number(next.revision), data: next.data } : {}),
|
|
581
|
+
});
|
|
582
|
+
if (result.deleteRecord === "purge") {
|
|
583
|
+
const deleted = await target.coll.deleteOne({ _id: current._id, revision });
|
|
584
|
+
if (!deleted.deletedCount)
|
|
585
|
+
return { outcome: "drifted" };
|
|
586
|
+
await deps.reconcileDirectory?.(recordId);
|
|
587
|
+
await notify(deps, event("removed"));
|
|
588
|
+
return { outcome: "changed", after: { deleted: true, mode: "purge" } };
|
|
589
|
+
}
|
|
590
|
+
const next = nextDoc(current, result.record, now, entry, result.deleteRecord === "tombstone");
|
|
591
|
+
if (evaluation.newKey && result.discriminator) {
|
|
592
|
+
const newKey = evaluation.newKey;
|
|
593
|
+
const existing = evaluation.existingTarget ?? null;
|
|
594
|
+
if (existing && result.discriminator.onExisting === "deleteLegacy") {
|
|
595
|
+
const deleted = await target.coll.deleteOne({ _id: current._id, revision });
|
|
596
|
+
if (!deleted.deletedCount)
|
|
597
|
+
return { outcome: "drifted" };
|
|
598
|
+
return { outcome: "changed", after: { deleted: true, mode: "legacy", keptStorageKey: newKey } };
|
|
599
|
+
}
|
|
600
|
+
if (existing) {
|
|
601
|
+
next.revision = Math.max(revisionOf(existing), revision) + 1;
|
|
602
|
+
const replaced = await target.coll.replaceOne({ _id: newKey, revision: revisionOf(existing) }, { _id: newKey, ...next });
|
|
603
|
+
if (!replaced.matchedCount)
|
|
604
|
+
return { outcome: "drifted", error: "the existing instance moved after the plan" };
|
|
605
|
+
}
|
|
606
|
+
else {
|
|
607
|
+
try {
|
|
608
|
+
await target.coll.insertOne({ _id: newKey, ...next });
|
|
609
|
+
}
|
|
610
|
+
catch (err) {
|
|
611
|
+
if (/E11000|duplicate/i.test(message(err))) {
|
|
612
|
+
return { outcome: "drifted", error: "the target instance was written after the plan" };
|
|
613
|
+
}
|
|
614
|
+
throw err;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
const deleted = await target.coll.deleteOne({ _id: current._id, revision });
|
|
618
|
+
if (!deleted.deletedCount) {
|
|
619
|
+
if (existing) {
|
|
620
|
+
await target.coll.replaceOne({ _id: newKey, revision: Number(next.revision) }, existing);
|
|
621
|
+
}
|
|
622
|
+
else {
|
|
623
|
+
await target.coll.deleteOne({ _id: newKey, revision: Number(next.revision) });
|
|
624
|
+
}
|
|
625
|
+
return { outcome: "drifted" };
|
|
626
|
+
}
|
|
627
|
+
await notify(deps, event("updated", next));
|
|
628
|
+
return { outcome: "changed", after: { storageKey: newKey, revision: Number(next.revision) } };
|
|
629
|
+
}
|
|
630
|
+
const replaced = await target.coll.replaceOne({ _id: current._id, revision }, { _id: current._id, ...next });
|
|
631
|
+
if (!replaced.matchedCount)
|
|
632
|
+
return { outcome: "drifted" };
|
|
633
|
+
await notify(deps, event("updated", next));
|
|
634
|
+
return { outcome: "changed", after: { storageKey: current._id, revision: Number(next.revision) } };
|
|
635
|
+
}
|
|
636
|
+
async function applyRow(deps, patch, row) {
|
|
637
|
+
if (row.planned === "unchanged")
|
|
638
|
+
return { outcome: "unchanged" };
|
|
639
|
+
if (row.planned === "conflict")
|
|
640
|
+
return { outcome: "conflict", ...(row.plannedError ? { error: row.plannedError } : {}) };
|
|
641
|
+
if (row.planned === "fail")
|
|
642
|
+
return { outcome: "failed", ...(row.plannedError ? { error: row.plannedError } : {}) };
|
|
643
|
+
const target = resolveTarget(deps, row.objectType, row.contentType);
|
|
644
|
+
if (row.before) {
|
|
645
|
+
const recovered = await recoverApplied(target, patch, row);
|
|
646
|
+
if (recovered)
|
|
647
|
+
return recovered;
|
|
648
|
+
}
|
|
649
|
+
const current = await target.coll.findOne({ _id: row.storageKey });
|
|
650
|
+
if (!current || revisionOf(current) !== row.plannedRevision)
|
|
651
|
+
return { outcome: "drifted" };
|
|
652
|
+
const evaluation = await evaluate(target, current, patch.request.ops);
|
|
653
|
+
if (evaluation.kind === "unchanged")
|
|
654
|
+
return { outcome: "unchanged" };
|
|
655
|
+
if (evaluation.kind === "conflict")
|
|
656
|
+
return { outcome: "conflict", error: evaluation.reason };
|
|
657
|
+
if (evaluation.kind === "fail")
|
|
658
|
+
return { outcome: "failed", error: evaluation.error };
|
|
659
|
+
const { rows } = await patchCollections(deps.operationalDb);
|
|
660
|
+
const keepTarget = evaluation.existingTarget && evaluation.result.discriminator?.onExisting === "replace"
|
|
661
|
+
? { beforeTarget: evaluation.existingTarget }
|
|
662
|
+
: {};
|
|
663
|
+
await rows.updateOne({ _id: row._id }, { $set: { before: current, ...keepTarget } });
|
|
664
|
+
return commitChange(deps, patch, target, current, evaluation);
|
|
665
|
+
}
|
|
666
|
+
async function revertRow(deps, patch, row) {
|
|
667
|
+
const before = row.before;
|
|
668
|
+
const after = row.after;
|
|
669
|
+
if (!before || !after)
|
|
670
|
+
return { revert: "skipped" };
|
|
671
|
+
const target = resolveTarget(deps, row.objectType, row.contentType);
|
|
672
|
+
const now = nowOf(deps);
|
|
673
|
+
const entry = { action: "patch-revert", patchId: patch.patchId, caller: deps.caller, modifiedAt: now };
|
|
674
|
+
const event = (revision, data) => ({
|
|
675
|
+
objectType: row.objectType,
|
|
676
|
+
contentType: row.contentType,
|
|
677
|
+
recordId: row.recordId,
|
|
678
|
+
op: "updated",
|
|
679
|
+
revision,
|
|
680
|
+
data: data,
|
|
681
|
+
});
|
|
682
|
+
if ("deleted" in after) {
|
|
683
|
+
if (await target.coll.findOne({ _id: row.storageKey }))
|
|
684
|
+
return { revert: "drifted" };
|
|
685
|
+
const restored = restoredDoc(before, revisionOf(before) + 1, [...historyOf(before), entry], now);
|
|
686
|
+
try {
|
|
687
|
+
await target.coll.insertOne({ _id: row.storageKey, ...restored });
|
|
688
|
+
}
|
|
689
|
+
catch (err) {
|
|
690
|
+
if (/E11000|duplicate/i.test(message(err)))
|
|
691
|
+
return { revert: "drifted" };
|
|
692
|
+
throw err;
|
|
693
|
+
}
|
|
694
|
+
if (after.mode === "purge")
|
|
695
|
+
await deps.reconcileDirectory?.(row.recordId);
|
|
696
|
+
await notify(deps, event(Number(restored.revision), restored.data));
|
|
697
|
+
return { revert: "reverted" };
|
|
698
|
+
}
|
|
699
|
+
const current = await target.coll.findOne({ _id: after.storageKey });
|
|
700
|
+
if (!current || revisionOf(current) !== after.revision)
|
|
701
|
+
return { revert: "drifted" };
|
|
702
|
+
if (after.storageKey === row.storageKey) {
|
|
703
|
+
const restored = restoredDoc(before, revisionOf(current) + 1, [...historyOf(current), entry], now);
|
|
704
|
+
const replaced = await target.coll.replaceOne({ _id: row.storageKey, revision: revisionOf(current) }, { _id: row.storageKey, ...restored });
|
|
705
|
+
if (!replaced.matchedCount)
|
|
706
|
+
return { revert: "drifted" };
|
|
707
|
+
await notify(deps, event(Number(restored.revision), restored.data));
|
|
708
|
+
return { revert: "reverted" };
|
|
709
|
+
}
|
|
710
|
+
if (await target.coll.findOne({ _id: row.storageKey }))
|
|
711
|
+
return { revert: "drifted" };
|
|
712
|
+
const legacy = restoredDoc(before, Math.max(revisionOf(before), revisionOf(current)) + 1, [...historyOf(current), entry], now);
|
|
713
|
+
try {
|
|
714
|
+
await target.coll.insertOne({ _id: row.storageKey, ...legacy });
|
|
715
|
+
}
|
|
716
|
+
catch (err) {
|
|
717
|
+
if (/E11000|duplicate/i.test(message(err)))
|
|
718
|
+
return { revert: "drifted" };
|
|
719
|
+
throw err;
|
|
720
|
+
}
|
|
721
|
+
let undone;
|
|
722
|
+
if (row.beforeTarget) {
|
|
723
|
+
const restoredTarget = restoredDoc(row.beforeTarget, revisionOf(current) + 1, [...historyOf(row.beforeTarget), entry], now);
|
|
724
|
+
const replaced = await target.coll.replaceOne({ _id: after.storageKey, revision: revisionOf(current) }, { _id: after.storageKey, ...restoredTarget });
|
|
725
|
+
undone = replaced.matchedCount === 1;
|
|
726
|
+
}
|
|
727
|
+
else {
|
|
728
|
+
const deleted = await target.coll.deleteOne({ _id: after.storageKey, revision: revisionOf(current) });
|
|
729
|
+
undone = deleted.deletedCount === 1;
|
|
730
|
+
}
|
|
731
|
+
if (!undone) {
|
|
732
|
+
await target.coll.deleteOne({ _id: row.storageKey, revision: Number(legacy.revision) });
|
|
733
|
+
return { revert: "drifted" };
|
|
734
|
+
}
|
|
735
|
+
await notify(deps, event(Number(legacy.revision), legacy.data));
|
|
736
|
+
return { revert: "reverted" };
|
|
737
|
+
}
|
|
738
|
+
async function runWork(deps, patchId, mode) {
|
|
739
|
+
if (running.has(patchId))
|
|
740
|
+
return;
|
|
741
|
+
running.add(patchId);
|
|
742
|
+
const limits = patchLimits(deps.limits);
|
|
743
|
+
let collections;
|
|
744
|
+
try {
|
|
745
|
+
collections = await patchCollections(deps.operationalDb);
|
|
746
|
+
}
|
|
747
|
+
catch (err) {
|
|
748
|
+
running.delete(patchId);
|
|
749
|
+
deps.log?.("patch: work not started", { patchId, mode, error: message(err) });
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
const { patches, rows } = collections;
|
|
753
|
+
const activeState = mode === "apply" ? "applying" : "reverting";
|
|
754
|
+
try {
|
|
755
|
+
for (;;) {
|
|
756
|
+
const owned = await patches.findOneAndUpdate({ _id: patchId, state: activeState, "lease.owner": PROCESS_OWNER }, { $set: { "lease.until": plusMs(nowOf(deps), limits.leaseMs) } }, { returnDocument: "after" });
|
|
757
|
+
if (!owned)
|
|
758
|
+
return;
|
|
759
|
+
const filter = mode === "apply"
|
|
760
|
+
? { patchId, outcome: { $exists: false } }
|
|
761
|
+
: { patchId, outcome: "changed", revert: { $exists: false } };
|
|
762
|
+
const batch = await rows.find(filter).sort({ seq: 1 }).limit(WORK_BATCH).toArray();
|
|
763
|
+
if (!batch.length)
|
|
764
|
+
break;
|
|
765
|
+
for (const row of batch) {
|
|
766
|
+
let update;
|
|
767
|
+
try {
|
|
768
|
+
update = mode === "apply" ? await applyRow(deps, owned, row) : await revertRow(deps, owned, row);
|
|
769
|
+
}
|
|
770
|
+
catch (err) {
|
|
771
|
+
if (isInfrastructureError(err))
|
|
772
|
+
throw err;
|
|
773
|
+
update = mode === "apply" ? { outcome: "failed", error: message(err) } : { revert: "failed", revertError: message(err) };
|
|
774
|
+
}
|
|
775
|
+
await rows.updateOne({ _id: row._id }, { $set: update });
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
const live = await liveCounts(rows, patchId);
|
|
779
|
+
const finishedAt = nowOf(deps);
|
|
780
|
+
if (mode === "apply") {
|
|
781
|
+
await rows.updateMany({ patchId }, { $set: { expireAt: new Date(Date.parse(finishedAt) + limits.retainMs) } });
|
|
782
|
+
await patches.updateOne({ _id: patchId, "lease.owner": PROCESS_OWNER }, {
|
|
783
|
+
$set: {
|
|
784
|
+
state: "applied",
|
|
785
|
+
counts: live.counts,
|
|
786
|
+
finishedAt,
|
|
787
|
+
revertRetainUntil: plusMs(finishedAt, limits.retainMs - DAY_MS),
|
|
788
|
+
},
|
|
789
|
+
$unset: { lease: "", error: "" },
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
else {
|
|
793
|
+
await patches.updateOne({ _id: patchId, "lease.owner": PROCESS_OWNER }, { $set: { state: "reverted", revertCounts: live.revert, revertedAt: finishedAt }, $unset: { lease: "", error: "" } });
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
catch (err) {
|
|
797
|
+
deps.log?.("patch: work stopped", { patchId, mode, error: message(err) });
|
|
798
|
+
if (isInfrastructureError(err)) {
|
|
799
|
+
await patches
|
|
800
|
+
.updateOne({ _id: patchId, "lease.owner": PROCESS_OWNER }, { $set: { error: message(err) }, $unset: { lease: "" } })
|
|
801
|
+
.catch(() => undefined);
|
|
802
|
+
}
|
|
803
|
+
else {
|
|
804
|
+
const live = await liveCounts(rows, patchId).catch(() => undefined);
|
|
805
|
+
await patches
|
|
806
|
+
.updateOne({ _id: patchId, "lease.owner": PROCESS_OWNER }, {
|
|
807
|
+
$set: {
|
|
808
|
+
state: "failed",
|
|
809
|
+
error: message(err),
|
|
810
|
+
finishedAt: nowOf(deps),
|
|
811
|
+
...(live ? { counts: live.counts } : {}),
|
|
812
|
+
},
|
|
813
|
+
$unset: { lease: "" },
|
|
814
|
+
})
|
|
815
|
+
.catch(() => undefined);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
finally {
|
|
819
|
+
running.delete(patchId);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
async function assertHourlyCap(deps, doc, limits) {
|
|
823
|
+
const { patches } = await patchCollections(deps.operationalDb);
|
|
824
|
+
const since = plusMs(nowOf(deps), -60 * 60 * 1000);
|
|
825
|
+
const [used] = await patches
|
|
826
|
+
.aggregate([
|
|
827
|
+
{ $match: { orgId: deps.scope.orgId, startedAt: { $gte: since } } },
|
|
828
|
+
{ $group: { _id: null, n: { $sum: "$plan.wouldChange" } } },
|
|
829
|
+
])
|
|
830
|
+
.toArray();
|
|
831
|
+
const total = (used?.n ?? 0) + doc.plan.wouldChange;
|
|
832
|
+
if (total > limits.maxRowsPerHour) {
|
|
833
|
+
throw new ServiceError("PATCH_TOO_LARGE", `applying ${doc.plan.wouldChange} rows would pass the cap of ${limits.maxRowsPerHour} changed rows per hour`, 413, { cap: limits.maxRowsPerHour, window: "1h", used: used?.n ?? 0, retryable: true, retryAfterMs: 60 * 60 * 1000 });
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
function requireConfirm(body, action) {
|
|
837
|
+
const raw = isPlainObject(body) ? body : {};
|
|
838
|
+
if (raw.confirm !== true) {
|
|
839
|
+
throw new ServiceError("CONFIRMATION_REQUIRED", `confirm: true is required; nothing was ${action}`, 400, {
|
|
840
|
+
field: "confirm",
|
|
841
|
+
retryable: false,
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
return raw;
|
|
845
|
+
}
|
|
846
|
+
async function finishOrBackground(deps, patchId, mode, pending) {
|
|
847
|
+
const limits = patchLimits(deps.limits);
|
|
848
|
+
if (pending <= limits.syncRows) {
|
|
849
|
+
await runWork(deps, patchId, mode);
|
|
850
|
+
const body = await getPatchStatus(deps, patchId);
|
|
851
|
+
return { httpStatus: body.state === "applying" || body.state === "reverting" ? 202 : 200, body };
|
|
852
|
+
}
|
|
853
|
+
void runWork(deps, patchId, mode);
|
|
854
|
+
return { httpStatus: 202, body: await getPatchStatus(deps, patchId) };
|
|
855
|
+
}
|
|
856
|
+
export async function applyPatch(deps, patchId, body) {
|
|
857
|
+
const raw = requireConfirm(body, "applied");
|
|
858
|
+
const doc = await loadPatch(deps, patchId);
|
|
859
|
+
if (raw.planChecksum !== doc.planChecksum) {
|
|
860
|
+
throw new ServiceError("PATCH_PLAN_STALE", "planChecksum does not match the plan for this patch", 409, {
|
|
861
|
+
field: "planChecksum",
|
|
862
|
+
retryable: false,
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
if (doc.state === "applying" || doc.state === "applied") {
|
|
866
|
+
return { httpStatus: doc.state === "applied" ? 200 : 202, body: await getPatchStatus(deps, patchId) };
|
|
867
|
+
}
|
|
868
|
+
if (doc.state !== "planned") {
|
|
869
|
+
throw new ServiceError("PATCH_STATE_CONFLICT", `patch ${patchId} is ${doc.state}; it cannot be applied`, 409, {
|
|
870
|
+
state: doc.state,
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
const limits = patchLimits(deps.limits);
|
|
874
|
+
await assertHourlyCap(deps, doc, limits);
|
|
875
|
+
const now = nowOf(deps);
|
|
876
|
+
const { patches, rows } = await patchCollections(deps.operationalDb);
|
|
877
|
+
const claimed = await patches.findOneAndUpdate({ _id: patchId, state: "planned" }, {
|
|
878
|
+
$set: {
|
|
879
|
+
state: "applying",
|
|
880
|
+
startedAt: now,
|
|
881
|
+
appliedBy: deps.caller,
|
|
882
|
+
guardsActive: doc.guards.length > 0,
|
|
883
|
+
lease: { owner: PROCESS_OWNER, until: plusMs(now, limits.leaseMs) },
|
|
884
|
+
},
|
|
885
|
+
$unset: { expireAt: "" },
|
|
886
|
+
}, { returnDocument: "after" });
|
|
887
|
+
if (!claimed)
|
|
888
|
+
return { httpStatus: 202, body: await getPatchStatus(deps, patchId) };
|
|
889
|
+
if (claimed.guardsActive)
|
|
890
|
+
invalidatePatchGuards(deps.scope.orgId);
|
|
891
|
+
await rows.updateMany({ patchId }, { $set: { expireAt: new Date(Date.parse(now) + limits.retainMs) } });
|
|
892
|
+
await rows.updateMany({ patchId, planned: "unchanged" }, { $set: { outcome: "unchanged" } });
|
|
893
|
+
return finishOrBackground(deps, patchId, "apply", doc.plan.wouldChange + doc.plan.conflicts + doc.plan.failed);
|
|
894
|
+
}
|
|
895
|
+
export async function revertPatch(deps, patchId, body) {
|
|
896
|
+
requireConfirm(body, "reverted");
|
|
897
|
+
const doc = await loadPatch(deps, patchId);
|
|
898
|
+
if (doc.state === "reverting" || doc.state === "reverted") {
|
|
899
|
+
return { httpStatus: doc.state === "reverted" ? 200 : 202, body: await getPatchStatus(deps, patchId) };
|
|
900
|
+
}
|
|
901
|
+
if (doc.state !== "applied" && doc.state !== "failed") {
|
|
902
|
+
throw new ServiceError("PATCH_STATE_CONFLICT", `patch ${patchId} is ${doc.state}; only an applied patch can be reverted`, 409, {
|
|
903
|
+
state: doc.state,
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
const now = nowOf(deps);
|
|
907
|
+
if (doc.revertRetainUntil && doc.revertRetainUntil < now) {
|
|
908
|
+
throw new ServiceError("PATCH_REVERT_EXPIRED", `prior bodies for ${patchId} are no longer kept`, 409, {
|
|
909
|
+
revertRetainUntil: doc.revertRetainUntil,
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
const limits = patchLimits(deps.limits);
|
|
913
|
+
const { patches } = await patchCollections(deps.operationalDb);
|
|
914
|
+
const claimed = await patches.findOneAndUpdate({ _id: patchId, state: doc.state }, {
|
|
915
|
+
$set: {
|
|
916
|
+
state: "reverting",
|
|
917
|
+
revertStartedAt: now,
|
|
918
|
+
revertedBy: deps.caller,
|
|
919
|
+
guardsActive: false,
|
|
920
|
+
lease: { owner: PROCESS_OWNER, until: plusMs(now, limits.leaseMs) },
|
|
921
|
+
},
|
|
922
|
+
}, { returnDocument: "after" });
|
|
923
|
+
if (!claimed)
|
|
924
|
+
return { httpStatus: 202, body: await getPatchStatus(deps, patchId) };
|
|
925
|
+
invalidatePatchGuards(deps.scope.orgId);
|
|
926
|
+
return finishOrBackground(deps, patchId, "revert", doc.counts.changed);
|
|
927
|
+
}
|
|
928
|
+
export async function removePatchGuards(deps, patchId) {
|
|
929
|
+
const doc = await loadPatch(deps, patchId);
|
|
930
|
+
const { patches } = await patchCollections(deps.operationalDb);
|
|
931
|
+
await patches.updateOne({ _id: patchId }, { $set: { guardsActive: false } });
|
|
932
|
+
invalidatePatchGuards(deps.scope.orgId);
|
|
933
|
+
return { patchId, guards: doc.guards, guardsActive: false };
|
|
934
|
+
}
|
|
935
|
+
export async function loadPatchForAccess(deps, patchId) {
|
|
936
|
+
return loadPatch(deps, patchId);
|
|
937
|
+
}
|
|
938
|
+
/**
|
|
939
|
+
* Pick up apply or revert work whose lease ran out, for example after a restart.
|
|
940
|
+
* `depsFor` builds engine deps from the stored patch (org, agents, caller).
|
|
941
|
+
*/
|
|
942
|
+
export async function resumePatchWork(db, depsFor) {
|
|
943
|
+
const { patches } = await patchCollections(db);
|
|
944
|
+
const now = new Date().toISOString();
|
|
945
|
+
const stale = {
|
|
946
|
+
state: { $in: ["applying", "reverting"] },
|
|
947
|
+
$or: [{ lease: { $exists: false } }, { "lease.until": { $lt: now } }],
|
|
948
|
+
};
|
|
949
|
+
const candidates = await patches.find(stale).limit(5).toArray();
|
|
950
|
+
let resumed = 0;
|
|
951
|
+
for (const candidate of candidates) {
|
|
952
|
+
if (running.has(candidate._id))
|
|
953
|
+
continue;
|
|
954
|
+
const deps = depsFor(candidate);
|
|
955
|
+
if (!deps)
|
|
956
|
+
continue;
|
|
957
|
+
const limits = patchLimits(deps.limits);
|
|
958
|
+
const claimed = await patches.findOneAndUpdate({ _id: candidate._id, ...stale }, { $set: { lease: { owner: PROCESS_OWNER, until: plusMs(now, limits.leaseMs) } } }, { returnDocument: "after" });
|
|
959
|
+
if (!claimed)
|
|
960
|
+
continue;
|
|
961
|
+
resumed += 1;
|
|
962
|
+
void runWork(deps, claimed._id, claimed.state === "applying" ? "apply" : "revert");
|
|
963
|
+
}
|
|
964
|
+
return resumed;
|
|
965
|
+
}
|
|
966
|
+
//# sourceMappingURL=engine.js.map
|