@zhin.js/plugin-runtime 1.1.16 → 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.
@@ -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
+ }
@@ -36,6 +36,10 @@ function setValue(document, path, value) {
36
36
  for (const [index, segment] of path.slice(0, -1).entries()) {
37
37
  const existing = readChild(target, segment, path.slice(0, index + 1));
38
38
  if (existing === undefined) {
39
+ // Inline guard (in addition to assertPath) so the write is visibly safe.
40
+ if (segment === '__proto__' || segment === 'constructor' || segment === 'prototype') {
41
+ throw unsafeSegment(segment);
42
+ }
39
43
  const created = {};
40
44
  target[segment] = created;
41
45
  target = created;
@@ -59,7 +63,11 @@ function removeValue(document, path) {
59
63
  target.splice(arrayIndex(segment, target, path), 1);
60
64
  return;
61
65
  }
62
- delete target[segment];
66
+ if (segment === '__proto__' || segment === 'constructor' || segment === 'prototype') {
67
+ throw unsafeSegment(segment);
68
+ }
69
+ if (Object.hasOwn(target, segment))
70
+ delete target[segment];
63
71
  }
64
72
  function cloneDocument(value) {
65
73
  const document = structuredClone(value);
@@ -71,13 +79,18 @@ function cloneDocument(value) {
71
79
  function readChild(container, segment, path) {
72
80
  if (Array.isArray(container))
73
81
  return container[arrayIndex(segment, container, path)];
74
- return container[segment];
82
+ // Only own properties count: inherited members (toString, hasOwnProperty, ...)
83
+ // must never be traversed into or treated as existing config values.
84
+ return Object.hasOwn(container, segment) ? container[segment] : undefined;
75
85
  }
76
86
  function writeChild(container, segment, value, path) {
77
87
  if (Array.isArray(container)) {
78
88
  container[arrayIndex(segment, container, path)] = value;
79
89
  return;
80
90
  }
91
+ if (segment === '__proto__' || segment === 'constructor' || segment === 'prototype') {
92
+ throw unsafeSegment(segment);
93
+ }
81
94
  container[segment] = value;
82
95
  }
83
96
  function arrayIndex(segment, container, path) {
@@ -99,10 +112,13 @@ function requireContainer(value, path) {
99
112
  function assertPath(path) {
100
113
  for (const segment of path) {
101
114
  if (!segment || segment === '__proto__' || segment === 'prototype' || segment === 'constructor') {
102
- throw new ConfigPatchPathError(`Unsafe config path segment: ${segment || '<empty>'}`);
115
+ throw unsafeSegment(segment);
103
116
  }
104
117
  }
105
118
  }
119
+ function unsafeSegment(segment) {
120
+ return new ConfigPatchPathError(`Unsafe config path segment: ${segment || '<empty>'}`);
121
+ }
106
122
  function lastSegment(path) {
107
123
  const segment = path[path.length - 1];
108
124
  if (!segment)
@@ -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;
@@ -28,6 +28,8 @@ export class RootController {
28
28
  return store.acquire();
29
29
  },
30
30
  owns: (lease) => store.owns(lease),
31
+ retain: (lease) => store.retain(lease),
32
+ rootResource: (lease, token) => store.rootResource(lease, token),
31
33
  });
32
34
  }
33
35
  get state() {