@zhin.js/plugin-runtime 1.1.17 → 1.1.18
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/lib/admission.d.ts +15 -3
- package/lib/admission.js +10 -5
- package/lib/data-migration.d.ts +105 -0
- package/lib/data-migration.js +16 -0
- package/lib/index.d.ts +6 -0
- package/lib/index.js +6 -0
- package/lib/outbound-host.d.ts +32 -9
- package/lib/outbound-host.js +3 -0
- package/lib/plugin-console-rpc.d.ts +11 -1
- package/lib/plugin-events.d.ts +47 -0
- package/lib/plugin-events.js +30 -0
- package/lib/plugin.d.ts +6 -1
- package/lib/plugin.js +2 -0
- package/lib/relational-target-migration.d.ts +68 -0
- package/lib/relational-target-migration.js +571 -0
- package/lib/root-controller.d.ts +2 -2
- package/lib/root-controller.js +2 -0
- package/lib/snapshot.d.ts +22 -5
- package/lib/snapshot.js +60 -14
- package/lib/token.d.ts +5 -1
- package/lib/token.js +13 -0
- package/package.json +1 -1
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
3
|
+
import { generationAdmissionSource } from './admission.js';
|
|
4
|
+
import { normalizeDeliveryTarget } from './delivery-target.js';
|
|
5
|
+
import { DataMigrationError, } from './data-migration.js';
|
|
6
|
+
const JOURNAL_TABLE = 'target_migration_journal';
|
|
7
|
+
/** Stable, lossless digest; this does not impose the small-object StateStore size limit. */
|
|
8
|
+
export function targetMigrationDigest(value) {
|
|
9
|
+
const json = JSON.stringify(value, (_key, item) => {
|
|
10
|
+
if (item === undefined || typeof item === 'bigint' || typeof item === 'function' || typeof item === 'symbol') {
|
|
11
|
+
throw new DataMigrationError('unsupported', 'Migration records must contain lossless JSON values');
|
|
12
|
+
}
|
|
13
|
+
if (typeof item === 'number' && (!Number.isFinite(item) || Object.is(item, -0))) {
|
|
14
|
+
throw new DataMigrationError('unsupported', 'Migration records contain an unsupported numeric value');
|
|
15
|
+
}
|
|
16
|
+
if (item && typeof item === 'object' && !Array.isArray(item)) {
|
|
17
|
+
if (Object.getPrototypeOf(item) !== Object.prototype && Object.getPrototypeOf(item) !== null) {
|
|
18
|
+
throw new DataMigrationError('unsupported', 'Migration records must be plain database rows');
|
|
19
|
+
}
|
|
20
|
+
return Object.fromEntries(Object.entries(item).sort(([a], [b]) => a.localeCompare(b)));
|
|
21
|
+
}
|
|
22
|
+
return item;
|
|
23
|
+
});
|
|
24
|
+
return createHash('sha256').update(json).digest('hex');
|
|
25
|
+
}
|
|
26
|
+
export function orderedMigrationRows(rows) {
|
|
27
|
+
return rows
|
|
28
|
+
.map((row) => ({ row, digest: targetMigrationDigest(row) }))
|
|
29
|
+
.sort((a, b) => a.digest.localeCompare(b.digest))
|
|
30
|
+
.map((item) => item.row);
|
|
31
|
+
}
|
|
32
|
+
function unitDigest(unit) {
|
|
33
|
+
return targetMigrationDigest(unit.records.map((set) => ({ ...set, rows: orderedMigrationRows(set.rows) })));
|
|
34
|
+
}
|
|
35
|
+
/** Per-instance scope admission. Existing work drains; unrelated scopes continue. */
|
|
36
|
+
export class TargetMigrationScopes {
|
|
37
|
+
#pending;
|
|
38
|
+
#paused = new Map();
|
|
39
|
+
#running = new Map();
|
|
40
|
+
#context = new AsyncLocalStorage();
|
|
41
|
+
#initialize = async () => undefined;
|
|
42
|
+
#ready;
|
|
43
|
+
constructor(input) {
|
|
44
|
+
this.#pending = input.pending;
|
|
45
|
+
}
|
|
46
|
+
bindInitialization(initialize) {
|
|
47
|
+
this.#initialize = initialize;
|
|
48
|
+
}
|
|
49
|
+
async run(input) {
|
|
50
|
+
const inherited = this.#context.getStore();
|
|
51
|
+
// Accepted work may continue inside its scope, but every nested call owns its actual wait.
|
|
52
|
+
if (!inherited?.get(input.scope)?.active) {
|
|
53
|
+
await this.#ensureReady();
|
|
54
|
+
if (this.#paused.has(input.scope) || this.#pending(input.scope)) {
|
|
55
|
+
throw new DataMigrationError('unavailable', 'This historical scope is awaiting target binding or maintenance resumption');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const lease = { active: true }, context = new Map(inherited);
|
|
59
|
+
context.set(input.scope, lease);
|
|
60
|
+
const jobs = this.#running.get(input.scope) ?? new Set();
|
|
61
|
+
this.#running.set(input.scope, jobs);
|
|
62
|
+
const work = Promise.resolve().then(() => this.#context.run(context, input.run));
|
|
63
|
+
jobs.add(work);
|
|
64
|
+
try {
|
|
65
|
+
return await work;
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
lease.active = false;
|
|
69
|
+
jobs.delete(work);
|
|
70
|
+
if (!jobs.size)
|
|
71
|
+
this.#running.delete(input.scope);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async pause(input) {
|
|
75
|
+
await this.#ensureReady();
|
|
76
|
+
for (const scope of input.scopes) {
|
|
77
|
+
const owner = this.#paused.get(scope);
|
|
78
|
+
if (owner && owner !== input.planId)
|
|
79
|
+
throw new DataMigrationError('conflict', 'Scope belongs to another maintenance plan');
|
|
80
|
+
}
|
|
81
|
+
for (const scope of input.scopes)
|
|
82
|
+
this.#paused.set(scope, input.planId);
|
|
83
|
+
await input.persist();
|
|
84
|
+
// A running operation can admit a nested continuation while this pause is awaiting it.
|
|
85
|
+
while (!this.drained(input.scopes)) {
|
|
86
|
+
await Promise.allSettled(input.scopes.flatMap((scope) => [...(this.#running.get(scope) ?? [])]));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
drained(scopes) {
|
|
90
|
+
return scopes.every(scope => !this.#running.get(scope)?.size);
|
|
91
|
+
}
|
|
92
|
+
maintenance(planId, scopes) {
|
|
93
|
+
const owners = scopes.map((scope) => this.#paused.get(scope)).filter((owner) => owner !== undefined);
|
|
94
|
+
if (owners.some((owner) => owner !== planId))
|
|
95
|
+
return 'unknown';
|
|
96
|
+
return owners.length ? 'paused' : 'resumed';
|
|
97
|
+
}
|
|
98
|
+
restorePause(planId, scopes) {
|
|
99
|
+
for (const scope of scopes)
|
|
100
|
+
this.#paused.set(scope, planId);
|
|
101
|
+
}
|
|
102
|
+
resume(planId, scopes) {
|
|
103
|
+
for (const scope of scopes)
|
|
104
|
+
if (this.#paused.get(scope) === planId)
|
|
105
|
+
this.#paused.delete(scope);
|
|
106
|
+
}
|
|
107
|
+
#ensureReady() {
|
|
108
|
+
if (this.#ready)
|
|
109
|
+
return this.#ready;
|
|
110
|
+
const ready = Promise.resolve().then(this.#initialize).catch(error => {
|
|
111
|
+
if (this.#ready === ready)
|
|
112
|
+
this.#ready = undefined;
|
|
113
|
+
throw error;
|
|
114
|
+
});
|
|
115
|
+
this.#ready = ready;
|
|
116
|
+
return ready;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function planData(plan) {
|
|
120
|
+
const value = plan.data;
|
|
121
|
+
if (!value || value.format !== 'relational-target-v1')
|
|
122
|
+
throw new DataMigrationError('invalid_input', 'Unexpected domain migration plan format');
|
|
123
|
+
return value;
|
|
124
|
+
}
|
|
125
|
+
function jsonValue(value) {
|
|
126
|
+
targetMigrationDigest(value);
|
|
127
|
+
return JSON.parse(JSON.stringify(value));
|
|
128
|
+
}
|
|
129
|
+
function journalReceipt(row) {
|
|
130
|
+
if (row.last_operation !== 'apply' && row.last_operation !== 'restore')
|
|
131
|
+
return undefined;
|
|
132
|
+
return {
|
|
133
|
+
operation: row.last_operation,
|
|
134
|
+
planId: String(row.plan_id),
|
|
135
|
+
candidateId: String(row.candidate_id),
|
|
136
|
+
beforeDigest: String(row.before_digest),
|
|
137
|
+
afterDigest: String(row.after_digest),
|
|
138
|
+
recordCount: Number(row.record_count),
|
|
139
|
+
status: 'applied',
|
|
140
|
+
maintenance: 'paused',
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
/** Shared only by the concrete game and subscription target migrations. No raw-SQL or generic schema discovery. */
|
|
144
|
+
export function createRelationalTargetMigration(options) {
|
|
145
|
+
const { database, scopes } = options;
|
|
146
|
+
database.define(JOURNAL_TABLE, {
|
|
147
|
+
plan_id: { type: 'text', primary: true },
|
|
148
|
+
candidate_id: { type: 'text', nullable: false },
|
|
149
|
+
plan_digest: { type: 'text', nullable: false },
|
|
150
|
+
before_digest: { type: 'text', nullable: false },
|
|
151
|
+
after_digest: { type: 'text', default: '' },
|
|
152
|
+
record_count: { type: 'integer', default: 0 },
|
|
153
|
+
scopes_json: { type: 'text', nullable: false },
|
|
154
|
+
phase: { type: 'text', nullable: false },
|
|
155
|
+
last_operation: { type: 'text', default: '' },
|
|
156
|
+
rejected_digest: { type: 'text', default: '' },
|
|
157
|
+
});
|
|
158
|
+
const executable = ['sqlite', 'mysql', 'pg'].includes(database.dialect);
|
|
159
|
+
const model = (table) => {
|
|
160
|
+
if (table !== JOURNAL_TABLE && !options.tables.includes(table))
|
|
161
|
+
throw new DataMigrationError('invalid_input', `Unowned migration table: ${table}`);
|
|
162
|
+
const result = database.models.get(table);
|
|
163
|
+
if (!result)
|
|
164
|
+
throw new DataMigrationError('unavailable', `Migration table unavailable: ${table}`);
|
|
165
|
+
return result;
|
|
166
|
+
};
|
|
167
|
+
const reader = { model };
|
|
168
|
+
scopes.bindInitialization(async () => {
|
|
169
|
+
const rows = await model(JOURNAL_TABLE).select();
|
|
170
|
+
for (const row of rows)
|
|
171
|
+
if (row.phase !== 'resumed')
|
|
172
|
+
scopes.restorePause(String(row.plan_id), JSON.parse(String(row.scopes_json)));
|
|
173
|
+
});
|
|
174
|
+
const readJournal = async (planId, tx = reader) => (await tx.model(JOURNAL_TABLE).select().where({ plan_id: planId }))[0];
|
|
175
|
+
const assertPlan = (plan) => {
|
|
176
|
+
if (plan.domain !== options.domain || plan.sourceVersion !== options.sourceVersion)
|
|
177
|
+
throw new DataMigrationError('invalid_input', 'Migration plan belongs to another domain/version');
|
|
178
|
+
const data = planData(plan);
|
|
179
|
+
for (const set of [...data.writes.flatMap((write) => [write.before, write.after]), ...data.observed]) {
|
|
180
|
+
if (!options.tables.includes(set.table))
|
|
181
|
+
throw new DataMigrationError('invalid_input', 'Migration plan contains an unowned table');
|
|
182
|
+
}
|
|
183
|
+
return data;
|
|
184
|
+
};
|
|
185
|
+
const verify = async (sets, tx) => {
|
|
186
|
+
for (const set of sets) {
|
|
187
|
+
const rows = await tx
|
|
188
|
+
.model(set.table)
|
|
189
|
+
.select()
|
|
190
|
+
.where({ ...set.query });
|
|
191
|
+
if (targetMigrationDigest(orderedMigrationRows(rows)) !== targetMigrationDigest(orderedMigrationRows(set.rows))) {
|
|
192
|
+
throw new DataMigrationError('conflict', `Migration records changed in ${set.table}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
const inFlight = new Set();
|
|
197
|
+
let mutationRevision = 0;
|
|
198
|
+
const mutate = async (planId, run) => {
|
|
199
|
+
if (inFlight.has(planId))
|
|
200
|
+
throw new DataMigrationError('conflict', 'This migration already has an operation in progress');
|
|
201
|
+
mutationRevision += 1;
|
|
202
|
+
inFlight.add(planId);
|
|
203
|
+
try {
|
|
204
|
+
return await run();
|
|
205
|
+
}
|
|
206
|
+
finally {
|
|
207
|
+
inFlight.delete(planId);
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
const matches = async (sets, tx) => {
|
|
211
|
+
for (const set of sets) {
|
|
212
|
+
const rows = await tx
|
|
213
|
+
.model(set.table)
|
|
214
|
+
.select()
|
|
215
|
+
.where({ ...set.query });
|
|
216
|
+
if (targetMigrationDigest(orderedMigrationRows(rows)) !== targetMigrationDigest(orderedMigrationRows(set.rows)))
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
return true;
|
|
220
|
+
};
|
|
221
|
+
const readBaseline = async (data, tx) => {
|
|
222
|
+
const sets = [...data.writes.flatMap((write) => [write.before, write.after]), ...data.observed];
|
|
223
|
+
const actual = [];
|
|
224
|
+
for (const set of sets)
|
|
225
|
+
actual.push({
|
|
226
|
+
table: set.table,
|
|
227
|
+
query: set.query,
|
|
228
|
+
rows: orderedMigrationRows(await tx
|
|
229
|
+
.model(set.table)
|
|
230
|
+
.select()
|
|
231
|
+
.where({ ...set.query })),
|
|
232
|
+
});
|
|
233
|
+
return targetMigrationDigest(actual);
|
|
234
|
+
};
|
|
235
|
+
const inspect = async (plan, tx) => {
|
|
236
|
+
const data = assertPlan(plan), journal = await readJournal(plan.id, tx);
|
|
237
|
+
if (journal && journal.plan_digest !== targetMigrationDigest(plan))
|
|
238
|
+
return { data: 'conflict', maintenance: 'unknown', resumable: false };
|
|
239
|
+
const local = scopes.maintenance(plan.id, data.scopes);
|
|
240
|
+
const maintenance = local === 'unknown'
|
|
241
|
+
? 'unknown'
|
|
242
|
+
: local === 'paused' || (journal && journal.phase !== 'resumed')
|
|
243
|
+
? 'paused'
|
|
244
|
+
: 'resumed';
|
|
245
|
+
if (!scopes.drained(data.scopes))
|
|
246
|
+
return { data: 'unknown', maintenance, resumable: false };
|
|
247
|
+
if (journal?.rejected_digest) {
|
|
248
|
+
const verified = (await readBaseline(data, tx)) === journal.rejected_digest;
|
|
249
|
+
return {
|
|
250
|
+
data: verified && !journal.last_operation ? 'not_applied' : 'conflict',
|
|
251
|
+
maintenance,
|
|
252
|
+
resumable: verified && maintenance === 'paused',
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
if (!(await matches(data.observed, tx)))
|
|
256
|
+
return { data: 'conflict', maintenance, resumable: false };
|
|
257
|
+
const before = await matches(data.writes.map((write) => write.before), tx), after = await matches(data.writes.map((write) => write.after), tx);
|
|
258
|
+
const emptyOtherSide = async (side) => {
|
|
259
|
+
for (const write of data.writes)
|
|
260
|
+
if (targetMigrationDigest(write.before.query) !== targetMigrationDigest(write.after.query)) {
|
|
261
|
+
if ((await tx
|
|
262
|
+
.model(write[side].table)
|
|
263
|
+
.select()
|
|
264
|
+
.where({ ...write[side].query })).length)
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
return true;
|
|
268
|
+
};
|
|
269
|
+
const receipt = journal ? journalReceipt(journal) : undefined;
|
|
270
|
+
if (receipt?.operation === 'apply' && after && (await emptyOtherSide('before')))
|
|
271
|
+
return { data: 'applied', maintenance, receipt, resumable: maintenance === 'paused' };
|
|
272
|
+
if (receipt?.operation === 'restore' && before && (await emptyOtherSide('after')))
|
|
273
|
+
return { data: 'restored', maintenance, receipt, resumable: maintenance === 'paused' };
|
|
274
|
+
if (!receipt && before && (await emptyOtherSide('after')))
|
|
275
|
+
return { data: 'not_applied', maintenance, resumable: maintenance === 'paused' };
|
|
276
|
+
return { data: 'conflict', maintenance, resumable: false };
|
|
277
|
+
};
|
|
278
|
+
const provider = {
|
|
279
|
+
title: options.title,
|
|
280
|
+
get [generationAdmissionSource]() {
|
|
281
|
+
return database[generationAdmissionSource];
|
|
282
|
+
},
|
|
283
|
+
async preview(request) {
|
|
284
|
+
request?.signal?.throwIfAborted();
|
|
285
|
+
const units = await options.readUnits(reader);
|
|
286
|
+
const candidates = units.map((unit) => ({
|
|
287
|
+
id: unit.id,
|
|
288
|
+
domain: options.domain,
|
|
289
|
+
title: unit.title,
|
|
290
|
+
sourceVersion: options.sourceVersion,
|
|
291
|
+
recordCount: unit.records.reduce((n, set) => n + set.rows.length, 0),
|
|
292
|
+
beforeDigest: unitDigest(unit),
|
|
293
|
+
status: unit.status,
|
|
294
|
+
summary: unit.summary,
|
|
295
|
+
executable: executable && unit.status === 'unresolved',
|
|
296
|
+
}));
|
|
297
|
+
return { revision: targetMigrationDigest(candidates), candidates };
|
|
298
|
+
},
|
|
299
|
+
async prepare(request) {
|
|
300
|
+
request.signal?.throwIfAborted();
|
|
301
|
+
const target = normalizeDeliveryTarget(request.target);
|
|
302
|
+
const unit = (await options.readUnits(reader)).find((item) => item.id === request.candidateId);
|
|
303
|
+
if (!unit)
|
|
304
|
+
throw new DataMigrationError('not_found', 'Historical migration unit no longer exists');
|
|
305
|
+
if (!executable || unit.status !== 'unresolved')
|
|
306
|
+
throw new DataMigrationError('unsupported', 'This migration unit cannot be changed by this provider');
|
|
307
|
+
if (unitDigest(unit) !== request.expectedDigest)
|
|
308
|
+
throw new DataMigrationError('conflict', 'Historical migration unit changed since preview');
|
|
309
|
+
const rewrite = options.rewrite(unit, target), writes = rewrite.writes;
|
|
310
|
+
const observed = unit.records.filter((set) => !writes.some((write) => write.before === set));
|
|
311
|
+
return {
|
|
312
|
+
id: request.planId,
|
|
313
|
+
candidateId: unit.id,
|
|
314
|
+
domain: options.domain,
|
|
315
|
+
sourceVersion: options.sourceVersion,
|
|
316
|
+
beforeDigest: request.expectedDigest,
|
|
317
|
+
target,
|
|
318
|
+
recordCount: unit.records.reduce((n, set) => n + set.rows.length, 0),
|
|
319
|
+
data: jsonValue({
|
|
320
|
+
format: 'relational-target-v1',
|
|
321
|
+
scopes: [...new Set([unit.scope, rewrite.destinationScope])],
|
|
322
|
+
writes,
|
|
323
|
+
observed,
|
|
324
|
+
}),
|
|
325
|
+
description: [
|
|
326
|
+
'Bind only the selected historical scope to the administrator-selected exact target.',
|
|
327
|
+
'Preserve domain state, IDs, deadlines and delivery cursors; leave the scope paused after commit.',
|
|
328
|
+
],
|
|
329
|
+
authorizationCandidates: unit.authorizationCandidates ?? [],
|
|
330
|
+
};
|
|
331
|
+
},
|
|
332
|
+
async inspect({ plan, signal }) {
|
|
333
|
+
signal?.throwIfAborted();
|
|
334
|
+
if (inFlight.has(plan.id))
|
|
335
|
+
return { data: 'unknown', maintenance: 'unknown', resumable: false };
|
|
336
|
+
const revision = mutationRevision;
|
|
337
|
+
const result = await database.transaction((tx) => inspect(plan, tx));
|
|
338
|
+
return revision === mutationRevision && !inFlight.has(plan.id)
|
|
339
|
+
? result
|
|
340
|
+
: { data: 'unknown', maintenance: 'unknown', resumable: false };
|
|
341
|
+
},
|
|
342
|
+
async apply({ plan, signal }) {
|
|
343
|
+
return mutate(plan.id, async () => {
|
|
344
|
+
const data = assertPlan(plan);
|
|
345
|
+
signal?.throwIfAborted();
|
|
346
|
+
const digest = targetMigrationDigest(plan);
|
|
347
|
+
await scopes.pause({
|
|
348
|
+
planId: plan.id,
|
|
349
|
+
scopes: data.scopes,
|
|
350
|
+
persist: async () => {
|
|
351
|
+
await database.transaction(async (tx) => {
|
|
352
|
+
const existing = await readJournal(plan.id, tx);
|
|
353
|
+
if (existing) {
|
|
354
|
+
if (existing.plan_digest !== digest)
|
|
355
|
+
throw new DataMigrationError('conflict', 'Plan ID already refers to different content');
|
|
356
|
+
}
|
|
357
|
+
else
|
|
358
|
+
await tx
|
|
359
|
+
.model(JOURNAL_TABLE)
|
|
360
|
+
.insert({
|
|
361
|
+
plan_id: plan.id,
|
|
362
|
+
candidate_id: plan.candidateId,
|
|
363
|
+
plan_digest: digest,
|
|
364
|
+
before_digest: plan.beforeDigest,
|
|
365
|
+
scopes_json: JSON.stringify(data.scopes),
|
|
366
|
+
phase: 'pausing',
|
|
367
|
+
last_operation: '',
|
|
368
|
+
after_digest: '',
|
|
369
|
+
record_count: plan.recordCount,
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
},
|
|
373
|
+
});
|
|
374
|
+
let callbackFailure;
|
|
375
|
+
try {
|
|
376
|
+
return await database.transaction(async (tx) => {
|
|
377
|
+
try {
|
|
378
|
+
signal?.throwIfAborted();
|
|
379
|
+
const existing = await readJournal(plan.id, tx);
|
|
380
|
+
if (!existing || existing.plan_digest !== digest)
|
|
381
|
+
throw new DataMigrationError('conflict', 'Maintenance intent changed');
|
|
382
|
+
if (existing.phase === 'applied') {
|
|
383
|
+
await verify(data.writes.map((write) => write.after), tx);
|
|
384
|
+
const committed = journalReceipt(existing);
|
|
385
|
+
if (!committed || committed.operation !== 'apply')
|
|
386
|
+
throw new DataMigrationError('conflict', 'Applied journal lacks matching commit evidence');
|
|
387
|
+
return { ...committed, status: 'already_applied' };
|
|
388
|
+
}
|
|
389
|
+
if (existing.phase !== 'pausing')
|
|
390
|
+
throw new DataMigrationError('conflict', 'Plan is not awaiting application');
|
|
391
|
+
const fresh = await options.readUnits(tx), unit = fresh.find((item) => item.id === plan.candidateId);
|
|
392
|
+
if (!unit || unitDigest(unit) !== plan.beforeDigest)
|
|
393
|
+
throw new DataMigrationError('conflict', 'Historical unit changed before commit');
|
|
394
|
+
const rewrite = options.rewrite(unit, plan.target);
|
|
395
|
+
if (targetMigrationDigest(jsonValue(rewrite.writes)) !== targetMigrationDigest(jsonValue(data.writes)))
|
|
396
|
+
throw new DataMigrationError('conflict', 'Plan write set differs from current domain rules');
|
|
397
|
+
await verify([...data.writes.map((write) => write.before), ...data.observed], tx);
|
|
398
|
+
for (const write of data.writes) {
|
|
399
|
+
const source = targetMigrationDigest(write.before.query), destination = targetMigrationDigest(write.after.query);
|
|
400
|
+
if (source !== destination &&
|
|
401
|
+
(await tx
|
|
402
|
+
.model(write.after.table)
|
|
403
|
+
.select()
|
|
404
|
+
.where({ ...write.after.query })).length) {
|
|
405
|
+
throw new DataMigrationError('conflict', 'Selected destination already contains records in this domain');
|
|
406
|
+
}
|
|
407
|
+
await tx
|
|
408
|
+
.model(write.before.table)
|
|
409
|
+
.update({ ...write.patch })
|
|
410
|
+
.where({ ...write.before.query });
|
|
411
|
+
}
|
|
412
|
+
await verify([...data.writes.map((write) => write.after), ...data.observed], tx);
|
|
413
|
+
const afterDigest = targetMigrationDigest(data.writes.map((write) => write.after));
|
|
414
|
+
await tx
|
|
415
|
+
.model(JOURNAL_TABLE)
|
|
416
|
+
.update({ phase: 'applied', last_operation: 'apply', after_digest: afterDigest })
|
|
417
|
+
.where({ plan_id: plan.id, phase: 'pausing' });
|
|
418
|
+
return {
|
|
419
|
+
operation: 'apply',
|
|
420
|
+
planId: plan.id,
|
|
421
|
+
candidateId: plan.candidateId,
|
|
422
|
+
beforeDigest: plan.beforeDigest,
|
|
423
|
+
afterDigest,
|
|
424
|
+
recordCount: plan.recordCount,
|
|
425
|
+
status: 'applied',
|
|
426
|
+
maintenance: 'paused',
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
catch (error) {
|
|
430
|
+
callbackFailure = error;
|
|
431
|
+
throw error;
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
catch (error) {
|
|
436
|
+
// The database propagates a typed callback error only after rollback succeeds.
|
|
437
|
+
// Commit/rollback transport failures remain unknown and never create this evidence.
|
|
438
|
+
if (error === callbackFailure &&
|
|
439
|
+
error instanceof DataMigrationError &&
|
|
440
|
+
['conflict', 'unsupported', 'invalid_input', 'not_found'].includes(error.code)) {
|
|
441
|
+
await database.transaction(async (tx) => {
|
|
442
|
+
const row = await readJournal(plan.id, tx);
|
|
443
|
+
if (!row || row.phase !== 'pausing' || row.plan_digest !== digest)
|
|
444
|
+
throw new DataMigrationError('commit_unknown', 'Cannot confirm rejected migration maintenance');
|
|
445
|
+
await tx
|
|
446
|
+
.model(JOURNAL_TABLE)
|
|
447
|
+
.update({ phase: 'rejected', rejected_digest: await readBaseline(data, tx) })
|
|
448
|
+
.where({ plan_id: plan.id, phase: 'pausing' });
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
throw error;
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
},
|
|
455
|
+
async resume({ plan, signal }) {
|
|
456
|
+
return mutate(plan.id, async () => {
|
|
457
|
+
const data = assertPlan(plan);
|
|
458
|
+
signal?.throwIfAborted();
|
|
459
|
+
await database.transaction(async (tx) => {
|
|
460
|
+
const state = await inspect(plan, tx);
|
|
461
|
+
if (!state.resumable && state.maintenance !== 'resumed')
|
|
462
|
+
throw new DataMigrationError('conflict', 'Actual migration records cannot be safely resumed');
|
|
463
|
+
if (state.maintenance === 'paused') {
|
|
464
|
+
const row = await readJournal(plan.id, tx);
|
|
465
|
+
if (row)
|
|
466
|
+
await tx.model(JOURNAL_TABLE).update({ phase: 'resumed' }).where({ plan_id: plan.id });
|
|
467
|
+
else
|
|
468
|
+
await tx
|
|
469
|
+
.model(JOURNAL_TABLE)
|
|
470
|
+
.insert({
|
|
471
|
+
plan_id: plan.id,
|
|
472
|
+
candidate_id: plan.candidateId,
|
|
473
|
+
plan_digest: targetMigrationDigest(plan),
|
|
474
|
+
before_digest: plan.beforeDigest,
|
|
475
|
+
after_digest: '',
|
|
476
|
+
record_count: plan.recordCount,
|
|
477
|
+
scopes_json: JSON.stringify(data.scopes),
|
|
478
|
+
phase: 'resumed',
|
|
479
|
+
last_operation: '',
|
|
480
|
+
rejected_digest: '',
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
scopes.resume(plan.id, data.scopes);
|
|
485
|
+
});
|
|
486
|
+
},
|
|
487
|
+
async restore({ plan, receipt, signal }) {
|
|
488
|
+
return mutate(plan.id, async () => {
|
|
489
|
+
const data = assertPlan(plan);
|
|
490
|
+
signal?.throwIfAborted();
|
|
491
|
+
await scopes.pause({
|
|
492
|
+
planId: plan.id,
|
|
493
|
+
scopes: data.scopes,
|
|
494
|
+
persist: async () => {
|
|
495
|
+
await database.transaction(async (tx) => {
|
|
496
|
+
const row = await readJournal(plan.id, tx);
|
|
497
|
+
if (!row ||
|
|
498
|
+
row.plan_digest !== targetMigrationDigest(plan) ||
|
|
499
|
+
row.after_digest !== receipt.afterDigest ||
|
|
500
|
+
(row.phase !== 'applied' && row.phase !== 'resumed') ||
|
|
501
|
+
row.last_operation !== 'apply')
|
|
502
|
+
throw new DataMigrationError('conflict', 'Only the unchanged confirmed migration can be restored');
|
|
503
|
+
await tx.model(JOURNAL_TABLE).update({ phase: 'restoring' }).where({ plan_id: plan.id });
|
|
504
|
+
});
|
|
505
|
+
},
|
|
506
|
+
});
|
|
507
|
+
let callbackFailure;
|
|
508
|
+
try {
|
|
509
|
+
return await database.transaction(async (tx) => {
|
|
510
|
+
try {
|
|
511
|
+
signal?.throwIfAborted();
|
|
512
|
+
await verify([...data.writes.map((write) => write.after), ...data.observed], tx);
|
|
513
|
+
for (const write of data.writes) {
|
|
514
|
+
// All rows in a keyless unit must share the same routing patch; other values remain individual.
|
|
515
|
+
const keys = Object.keys(write.patch), original = write.before.rows[0];
|
|
516
|
+
if (!original)
|
|
517
|
+
continue;
|
|
518
|
+
const patch = Object.fromEntries(keys.map((key) => [key, original[key] ?? null]));
|
|
519
|
+
if (write.before.rows.some((row) => targetMigrationDigest(Object.fromEntries(keys.map((key) => [key, row[key] ?? null]))) !==
|
|
520
|
+
targetMigrationDigest(patch))) {
|
|
521
|
+
throw new DataMigrationError('unsupported', 'Indistinguishable rows require different restoration patches');
|
|
522
|
+
}
|
|
523
|
+
await tx
|
|
524
|
+
.model(write.after.table)
|
|
525
|
+
.update(patch)
|
|
526
|
+
.where({ ...write.after.query });
|
|
527
|
+
}
|
|
528
|
+
await verify([...data.writes.map((write) => write.before), ...data.observed], tx);
|
|
529
|
+
const afterDigest = targetMigrationDigest(data.writes.map((write) => write.before));
|
|
530
|
+
await tx
|
|
531
|
+
.model(JOURNAL_TABLE)
|
|
532
|
+
.update({ phase: 'restored', last_operation: 'restore', after_digest: afterDigest })
|
|
533
|
+
.where({ plan_id: plan.id, phase: 'restoring' });
|
|
534
|
+
return {
|
|
535
|
+
operation: 'restore',
|
|
536
|
+
planId: plan.id,
|
|
537
|
+
candidateId: plan.candidateId,
|
|
538
|
+
beforeDigest: plan.beforeDigest,
|
|
539
|
+
afterDigest,
|
|
540
|
+
recordCount: plan.recordCount,
|
|
541
|
+
status: 'applied',
|
|
542
|
+
maintenance: 'paused',
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
catch (error) {
|
|
546
|
+
callbackFailure = error;
|
|
547
|
+
throw error;
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
catch (error) {
|
|
552
|
+
if (error === callbackFailure &&
|
|
553
|
+
error instanceof DataMigrationError &&
|
|
554
|
+
['conflict', 'unsupported', 'invalid_input', 'not_found'].includes(error.code)) {
|
|
555
|
+
await database.transaction(async (tx) => {
|
|
556
|
+
const row = await readJournal(plan.id, tx);
|
|
557
|
+
if (!row || row.phase !== 'restoring' || row.plan_digest !== targetMigrationDigest(plan))
|
|
558
|
+
throw new DataMigrationError('commit_unknown', 'Cannot confirm rejected restoration maintenance');
|
|
559
|
+
await tx
|
|
560
|
+
.model(JOURNAL_TABLE)
|
|
561
|
+
.update({ phase: 'rejected', rejected_digest: await readBaseline(data, tx) })
|
|
562
|
+
.where({ plan_id: plan.id, phase: 'restoring' });
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
throw error;
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
},
|
|
569
|
+
};
|
|
570
|
+
return provider;
|
|
571
|
+
}
|
package/lib/root-controller.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { PluginId } from './identity.js';
|
|
2
|
-
import { type CommittedGeneration, type PreparedGeneration, type RuntimeSnapshot, type SnapshotReader, type SnapshotState } from './snapshot.js';
|
|
2
|
+
import { type RootResourceResolver, type CommittedGeneration, type PreparedGeneration, type RuntimeSnapshot, type SnapshotReader, type SnapshotState } from './snapshot.js';
|
|
3
3
|
export type RootState = 'idle' | 'running' | 'stopping' | 'stopped' | 'failed';
|
|
4
4
|
export type PrepareGeneration<TState = undefined> = (current: RuntimeSnapshot, signal: AbortSignal) => PreparedGeneration<TState> | Promise<PreparedGeneration<TState>>;
|
|
5
5
|
export type PrepareTransaction<TState = undefined> = (current: RuntimeSnapshot, signal: AbortSignal) => PreparedGeneration<TState> | undefined | Promise<PreparedGeneration<TState> | undefined>;
|
|
@@ -16,7 +16,7 @@ export declare class RootController<TState = undefined> {
|
|
|
16
16
|
#private;
|
|
17
17
|
private readonly onControlError;
|
|
18
18
|
readonly snapshots: SnapshotReader;
|
|
19
|
-
constructor(initial: SnapshotState, onControlError?: ControlErrorHandler, ...state: [TState] extends [undefined] ? [initialState?: undefined] : [initialState: TState]);
|
|
19
|
+
constructor(initial: SnapshotState, onControlError?: ControlErrorHandler, ...state: [TState] extends [undefined] ? [initialState?: undefined, rootResource?: RootResourceResolver<TState>] : [initialState: TState, rootResource?: RootResourceResolver<TState>]);
|
|
20
20
|
get state(): RootState;
|
|
21
21
|
get generation(): number;
|
|
22
22
|
get snapshot(): RuntimeSnapshot;
|
package/lib/root-controller.js
CHANGED
package/lib/snapshot.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Token } from './token.js';
|
|
1
2
|
import type { CapabilitySlot } from './capability.js';
|
|
2
3
|
import type { Dispose } from './dispose.js';
|
|
3
4
|
import type { GenerationHandoff } from './handoff.js';
|
|
@@ -46,34 +47,50 @@ export declare class GenerationConflictError extends Error {
|
|
|
46
47
|
constructor(expected: number, actual: number);
|
|
47
48
|
}
|
|
48
49
|
declare const snapshotLeaseAuthority: unique symbol;
|
|
50
|
+
/** One exact retained record and its two independent lifecycle facts. */
|
|
51
|
+
interface SnapshotLeaseConstruction {
|
|
52
|
+
readonly value: RuntimeSnapshot;
|
|
53
|
+
readonly releaseRecord: () => void;
|
|
54
|
+
readonly retirementSignal: AbortSignal;
|
|
55
|
+
readonly stopSignal: AbortSignal;
|
|
56
|
+
}
|
|
49
57
|
export declare class SnapshotLease {
|
|
50
58
|
#private;
|
|
51
59
|
readonly value: RuntimeSnapshot;
|
|
52
60
|
private readonly releaseRecord;
|
|
53
61
|
/** Aborted on publication of a replacement or Root close, before lease disposal. */
|
|
54
62
|
readonly retirementSignal: AbortSignal;
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
63
|
+
/** Root closure only; shared by leases from every generation and never aborted by HMR. */
|
|
64
|
+
readonly stopSignal: AbortSignal;
|
|
65
|
+
constructor(input: SnapshotLeaseConstruction, authority: typeof snapshotLeaseAuthority);
|
|
58
66
|
get active(): boolean;
|
|
59
67
|
release(): void;
|
|
60
68
|
}
|
|
61
|
-
|
|
69
|
+
export type RootResourceResolver<TState> = (state: TState, root: PluginId, token: Token<unknown>) => unknown;
|
|
70
|
+
/** Read-only operation seam exposed only to trusted Host integrations, never Plugin setup. */
|
|
62
71
|
export interface SnapshotReader {
|
|
63
72
|
acquire(): SnapshotLease;
|
|
73
|
+
/** Independently retain this exact owned generation, including after replacement. */
|
|
74
|
+
retain(lease: SnapshotLease): SnapshotLease;
|
|
64
75
|
owns(lease: SnapshotLease): boolean;
|
|
76
|
+
/** Exact leased generation, including private Root resources excluded from public snapshots. */
|
|
77
|
+
rootResource?<T>(lease: SnapshotLease, token: Token<T>): T | undefined;
|
|
65
78
|
}
|
|
66
79
|
export declare class SnapshotStore<TState = undefined> implements SnapshotReader {
|
|
67
80
|
#private;
|
|
68
81
|
private readonly onDisposalError;
|
|
69
|
-
constructor(initial: SnapshotState, onDisposalError?: (error: unknown) => void, ...state: [TState] extends [undefined] ? [initialState?: undefined] : [initialState: TState]);
|
|
82
|
+
constructor(initial: SnapshotState, onDisposalError?: (error: unknown) => void, ...state: [TState] extends [undefined] ? [initialState?: undefined, rootResource?: RootResourceResolver<TState>] : [initialState: TState, rootResource?: RootResourceResolver<TState>]);
|
|
70
83
|
get current(): RuntimeSnapshot;
|
|
71
84
|
/** Complete active record; snapshot and state always belong to one generation. */
|
|
72
85
|
get committed(): CommittedGeneration<TState>;
|
|
73
86
|
acquire(): SnapshotLease;
|
|
87
|
+
retain(lease: SnapshotLease): SnapshotLease;
|
|
74
88
|
owns(lease: SnapshotLease): boolean;
|
|
89
|
+
rootResource<T>(lease: SnapshotLease, token: Token<T>): T | undefined;
|
|
75
90
|
commit(expectedGeneration: number, prepared: PreparedGeneration<TState>): CommittedGeneration<TState>;
|
|
76
91
|
close(): Promise<void>;
|
|
77
92
|
}
|
|
78
93
|
export declare function createSnapshotView(generation: number, state: SnapshotState): RuntimeSnapshot;
|
|
94
|
+
/** Compatibility for Host embedders that implement the original minimal SnapshotReader. */
|
|
95
|
+
export declare function readLeasedRootResource<T>(reader: SnapshotReader, lease: SnapshotLease, token: Token<T>): T | undefined;
|
|
79
96
|
export {};
|