@volter/world-core 3.0.141 → 3.0.142
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/dist/src/head.js +56 -31
- package/package.json +1 -1
- package/src/head.ts +43 -20
package/dist/src/head.js
CHANGED
|
@@ -233,6 +233,9 @@ export class RefusedWriteError extends Error {
|
|
|
233
233
|
export class HeadError extends Error {
|
|
234
234
|
constructor(message) { super(message); this.name = 'HeadError'; }
|
|
235
235
|
}
|
|
236
|
+
/** A selected live request can retry after the earlier write finishes; its local group must roll back. */
|
|
237
|
+
class DeploymentOrderError extends HeadError {
|
|
238
|
+
}
|
|
236
239
|
/** The vendor refused or failed the write: the entry is in the log as failed, with the vendor's words. */
|
|
237
240
|
export class VendorWriteError extends Error {
|
|
238
241
|
actionId;
|
|
@@ -326,11 +329,47 @@ async function performNow(opts) {
|
|
|
326
329
|
const settling = settlingEntries(service, root);
|
|
327
330
|
const selected = opts.actionIds === undefined ? undefined : new Set(opts.actionIds);
|
|
328
331
|
const pick = (list) => selected === undefined ? list : list.filter((action) => selected.has(action.id));
|
|
332
|
+
// Classify the World's own writes before enforcing vendor order, using the same rules as settlement below.
|
|
333
|
+
const parent = parentEntries(service, root);
|
|
334
|
+
const taken = new Set();
|
|
335
|
+
const refusedIds = new Set();
|
|
336
|
+
for (const e of parent) {
|
|
337
|
+
if (e.receipt?.status === 'deployed')
|
|
338
|
+
taken.add(e.aliasOf ?? e.subject.id);
|
|
339
|
+
else if (e.receipt?.status === 'refused')
|
|
340
|
+
refusedIds.add(e.subject.id);
|
|
341
|
+
}
|
|
342
|
+
for (const id of taken)
|
|
343
|
+
refusedIds.delete(id);
|
|
344
|
+
const named = (action) => { const out = []; const walk = (v) => { if (typeof v === 'string')
|
|
345
|
+
out.push(v);
|
|
346
|
+
else if (v && typeof v === 'object')
|
|
347
|
+
for (const x of Object.values(v))
|
|
348
|
+
walk(x); }; const i = (action.input ?? {}); walk(i.path); walk(i.query); walk(i.body); return out; };
|
|
349
|
+
const ownWrite = (group, refused) => {
|
|
350
|
+
const unsent = adapters.unsent?.(group[0].input);
|
|
351
|
+
if (unsent)
|
|
352
|
+
return { reason: unsent, refusedSubject: false };
|
|
353
|
+
return named(group[0]).some((v) => refused.has(v)) ? { reason: 'names a subject the vendor refused: the World\'s alone', refusedSubject: true } : undefined;
|
|
354
|
+
};
|
|
355
|
+
const classifiedOwn = new Map();
|
|
329
356
|
if (selected !== undefined) {
|
|
330
|
-
const
|
|
331
|
-
const
|
|
357
|
+
const orderingRefused = new Set(refusedIds);
|
|
358
|
+
const offered = groupsOf(settling.offered).filter((group) => {
|
|
359
|
+
const own = ownWrite(group, orderingRefused);
|
|
360
|
+
if (!own)
|
|
361
|
+
return true;
|
|
362
|
+
for (const action of group)
|
|
363
|
+
classifiedOwn.set(action.id, own);
|
|
364
|
+
if (own.refusedSubject)
|
|
365
|
+
for (const action of group)
|
|
366
|
+
orderingRefused.add(action.subject.id);
|
|
367
|
+
return false;
|
|
368
|
+
}).flat();
|
|
369
|
+
const last = offered.reduce((index, action, at) => selected.has(action.id) ? at : index, -1);
|
|
370
|
+
const earlier = offered.slice(0, last + 1).find((action) => !selected.has(action.id));
|
|
332
371
|
if (earlier)
|
|
333
|
-
throw new
|
|
372
|
+
throw new DeploymentOrderError(`the ${service} twin has an earlier undeployed entry (${earlier.operation ?? earlier.subject.type}, ${earlier.id}); deploy its changeset first. Selected deployment cannot skip earlier writes on the same vendor.`);
|
|
334
373
|
}
|
|
335
374
|
// the caller's executor, or the one built over the sealed credential; an unsealed root has no other
|
|
336
375
|
const execute = opts.execute ?? buildRemoteExecute(validateRemoteOrigin(bound.sealed.url, { allowInsecureLoopback: true }), await openRootCredential(bound.sealed, vendor), authStrategyFor(vendor), getPack(vendor)?.hosts, { custody: rootCustody(bound.sealed, vendor), vendor });
|
|
@@ -348,42 +387,19 @@ async function performNow(opts) {
|
|
|
348
387
|
};
|
|
349
388
|
// the subjects the vendor refused and never took: a request naming one is the World's alone, never sent (the
|
|
350
389
|
// real-system adapters, "Perform" 3), settled as it landed here
|
|
351
|
-
const parent = parentEntries(service, root);
|
|
352
|
-
const taken = new Set();
|
|
353
|
-
const refusedIds = new Set();
|
|
354
|
-
for (const e of parent) {
|
|
355
|
-
if (e.receipt?.status === 'deployed')
|
|
356
|
-
taken.add(e.aliasOf ?? e.subject.id);
|
|
357
|
-
else if (e.receipt?.status === 'refused')
|
|
358
|
-
refusedIds.add(e.subject.id);
|
|
359
|
-
}
|
|
360
|
-
for (const id of taken)
|
|
361
|
-
refusedIds.delete(id);
|
|
362
|
-
const named = (action) => { const out = []; const walk = (v) => { if (typeof v === 'string')
|
|
363
|
-
out.push(v);
|
|
364
|
-
else if (v && typeof v === 'object')
|
|
365
|
-
for (const x of Object.values(v))
|
|
366
|
-
walk(x); }; const i = (action.input ?? {}); walk(i.path); walk(i.query); walk(i.body); return out; };
|
|
367
390
|
let stopped = false;
|
|
368
391
|
for (const group of groupsOf(pick(settling.offered))) {
|
|
369
392
|
if (stopped) {
|
|
370
393
|
report.skipped.push(...group.map((a) => a.id));
|
|
371
394
|
continue;
|
|
372
395
|
}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
if (unsent) {
|
|
376
|
-
const at = opts.at ?? worldNow();
|
|
377
|
-
for (const action of group)
|
|
378
|
-
settle(action, at, { status: 'landed', reason: unsent });
|
|
379
|
-
report.worldOnly.push(...group.map((a) => a.id));
|
|
380
|
-
continue;
|
|
381
|
-
}
|
|
382
|
-
if (named(group[0]).some((v) => refusedIds.has(v))) {
|
|
396
|
+
const own = classifiedOwn.get(group[0].id) ?? ownWrite(group, refusedIds);
|
|
397
|
+
if (own) {
|
|
383
398
|
const at = opts.at ?? worldNow();
|
|
384
399
|
for (const action of group) {
|
|
385
|
-
settle(action, at, { status: 'landed', reason:
|
|
386
|
-
|
|
400
|
+
settle(action, at, { status: 'landed', reason: own.reason });
|
|
401
|
+
if (own.refusedSubject)
|
|
402
|
+
refusedIds.add(action.subject.id);
|
|
387
403
|
}
|
|
388
404
|
report.worldOnly.push(...group.map((a) => a.id));
|
|
389
405
|
continue;
|
|
@@ -464,6 +480,10 @@ export async function performAtHead(service, action, root) {
|
|
|
464
480
|
report = await performEntries({ service, actionIds: [action.id], ...(root !== undefined ? { root } : {}) });
|
|
465
481
|
}
|
|
466
482
|
catch (error) {
|
|
483
|
+
if (error instanceof DeploymentOrderError) {
|
|
484
|
+
revertAction({ service, actionId: action.id, occurredAt: worldNow(), ...(root !== undefined ? { root } : {}) });
|
|
485
|
+
throw new VendorWriteError(`An earlier ${service} write is still pending; this request was rolled back before reaching the vendor. Retry after that write finishes.`, action.id);
|
|
486
|
+
}
|
|
467
487
|
throw error instanceof HeadError ? error : new HeadError(error instanceof Error ? error.message : String(error));
|
|
468
488
|
}
|
|
469
489
|
// under live use an entry the vendor did not take does not stay in the tree: the app was told no, the
|
|
@@ -509,6 +529,11 @@ export async function performRequestGroup(service, request, root) {
|
|
|
509
529
|
report = await performEntries({ service, actionIds: group.map((e) => e.id), ...(root !== undefined ? { root } : {}) });
|
|
510
530
|
}
|
|
511
531
|
catch (error) {
|
|
532
|
+
if (error instanceof DeploymentOrderError) {
|
|
533
|
+
for (const e of [...group].reverse())
|
|
534
|
+
revertAction({ service, actionId: e.id, occurredAt: worldNow(), ...(root !== undefined ? { root } : {}) });
|
|
535
|
+
return new VendorWriteError(`An earlier ${service} write is still pending; this request was rolled back before reaching the vendor. Retry after that write finishes.`, group[0].id);
|
|
536
|
+
}
|
|
512
537
|
throw error instanceof HeadError ? error : new HeadError(error instanceof Error ? error.message : String(error));
|
|
513
538
|
}
|
|
514
539
|
if (report.refused || report.failed) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world-core",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.142",
|
|
4
4
|
"description": "The kernel of Volter World: one log per twin, branches as pointers, checkpoints, the fold that keeps a twin current, the head that performs a write against the vendor, references, and the git plane. A twin package builds on it; the runtime serves it.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"twin",
|
package/src/head.ts
CHANGED
|
@@ -247,6 +247,9 @@ export class RefusedWriteError extends Error {
|
|
|
247
247
|
export class HeadError extends Error {
|
|
248
248
|
constructor(message: string) { super(message); this.name = 'HeadError'; }
|
|
249
249
|
}
|
|
250
|
+
|
|
251
|
+
/** A selected live request can retry after the earlier write finishes; its local group must roll back. */
|
|
252
|
+
class DeploymentOrderError extends HeadError {}
|
|
250
253
|
/** The vendor refused or failed the write: the entry is in the log as failed, with the vendor's words. */
|
|
251
254
|
export class VendorWriteError extends Error {
|
|
252
255
|
constructor(message: string, readonly actionId: string) { super(message); this.name = 'VendorWriteError'; }
|
|
@@ -358,10 +361,30 @@ async function performNow(opts: Parameters<typeof performEntries>[0]): Promise<D
|
|
|
358
361
|
const settling = settlingEntries(service, root);
|
|
359
362
|
const selected = opts.actionIds === undefined ? undefined : new Set(opts.actionIds);
|
|
360
363
|
const pick = (list: TwinAction[]): TwinAction[] => selected === undefined ? list : list.filter((action) => selected.has(action.id));
|
|
364
|
+
// Classify the World's own writes before enforcing vendor order, using the same rules as settlement below.
|
|
365
|
+
const parent = parentEntries(service, root);
|
|
366
|
+
const taken = new Set<string>(); const refusedIds = new Set<string>();
|
|
367
|
+
for (const e of parent) { if (e.receipt?.status === 'deployed') taken.add(e.aliasOf ?? e.subject.id); else if (e.receipt?.status === 'refused') refusedIds.add(e.subject.id); }
|
|
368
|
+
for (const id of taken) refusedIds.delete(id);
|
|
369
|
+
const named = (action: TwinAction): string[] => { const out: string[] = []; const walk = (v: unknown): void => { if (typeof v === 'string') out.push(v); else if (v && typeof v === 'object') for (const x of Object.values(v)) walk(x); }; const i = (action.input ?? {}) as Record<string, unknown>; walk(i.path); walk(i.query); walk(i.body); return out; };
|
|
370
|
+
const ownWrite = (group: TwinAction[], refused: Set<string>): { reason: string; refusedSubject: boolean } | undefined => {
|
|
371
|
+
const unsent = adapters.unsent?.(group[0]!.input as Record<string, unknown> | undefined);
|
|
372
|
+
if (unsent) return { reason: unsent, refusedSubject: false };
|
|
373
|
+
return named(group[0]!).some((v) => refused.has(v)) ? { reason: 'names a subject the vendor refused: the World\'s alone', refusedSubject: true } : undefined;
|
|
374
|
+
};
|
|
375
|
+
const classifiedOwn = new Map<string, { reason: string; refusedSubject: boolean }>();
|
|
361
376
|
if (selected !== undefined) {
|
|
362
|
-
const
|
|
363
|
-
const
|
|
364
|
-
|
|
377
|
+
const orderingRefused = new Set(refusedIds);
|
|
378
|
+
const offered = groupsOf(settling.offered).filter((group) => {
|
|
379
|
+
const own = ownWrite(group, orderingRefused);
|
|
380
|
+
if (!own) return true;
|
|
381
|
+
for (const action of group) classifiedOwn.set(action.id, own);
|
|
382
|
+
if (own.refusedSubject) for (const action of group) orderingRefused.add(action.subject.id);
|
|
383
|
+
return false;
|
|
384
|
+
}).flat();
|
|
385
|
+
const last = offered.reduce((index, action, at) => selected.has(action.id) ? at : index, -1);
|
|
386
|
+
const earlier = offered.slice(0, last + 1).find((action) => !selected.has(action.id));
|
|
387
|
+
if (earlier) throw new DeploymentOrderError(`the ${service} twin has an earlier undeployed entry (${earlier.operation ?? earlier.subject.type}, ${earlier.id}); deploy its changeset first. Selected deployment cannot skip earlier writes on the same vendor.`);
|
|
365
388
|
}
|
|
366
389
|
// the caller's executor, or the one built over the sealed credential; an unsealed root has no other
|
|
367
390
|
const execute = opts.execute ?? buildRemoteExecute(validateRemoteOrigin(bound.sealed!.url, { allowInsecureLoopback: true }), await openRootCredential(bound.sealed!, vendor), authStrategyFor(vendor), getPack(vendor)?.hosts, { custody: rootCustody(bound.sealed!, vendor), vendor });
|
|
@@ -377,25 +400,13 @@ async function performNow(opts: Parameters<typeof performEntries>[0]): Promise<D
|
|
|
377
400
|
};
|
|
378
401
|
// the subjects the vendor refused and never took: a request naming one is the World's alone, never sent (the
|
|
379
402
|
// real-system adapters, "Perform" 3), settled as it landed here
|
|
380
|
-
const parent = parentEntries(service, root);
|
|
381
|
-
const taken = new Set<string>(); const refusedIds = new Set<string>();
|
|
382
|
-
for (const e of parent) { if (e.receipt?.status === 'deployed') taken.add(e.aliasOf ?? e.subject.id); else if (e.receipt?.status === 'refused') refusedIds.add(e.subject.id); }
|
|
383
|
-
for (const id of taken) refusedIds.delete(id);
|
|
384
|
-
const named = (action: TwinAction): string[] => { const out: string[] = []; const walk = (v: unknown): void => { if (typeof v === 'string') out.push(v); else if (v && typeof v === 'object') for (const x of Object.values(v)) walk(x); }; const i = (action.input ?? {}) as Record<string, unknown>; walk(i.path); walk(i.query); walk(i.body); return out; };
|
|
385
403
|
let stopped = false;
|
|
386
404
|
for (const group of groupsOf(pick(settling.offered))) {
|
|
387
405
|
if (stopped) { report.skipped.push(...group.map((a) => a.id)); continue; }
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
if (unsent) {
|
|
406
|
+
const own = classifiedOwn.get(group[0]!.id) ?? ownWrite(group, refusedIds);
|
|
407
|
+
if (own) {
|
|
391
408
|
const at = opts.at ?? worldNow();
|
|
392
|
-
for (const action of group) settle(action, at, { status: 'landed', reason:
|
|
393
|
-
report.worldOnly.push(...group.map((a) => a.id));
|
|
394
|
-
continue;
|
|
395
|
-
}
|
|
396
|
-
if (named(group[0]!).some((v) => refusedIds.has(v))) {
|
|
397
|
-
const at = opts.at ?? worldNow();
|
|
398
|
-
for (const action of group) { settle(action, at, { status: 'landed', reason: 'names a subject the vendor refused: the World\'s alone' }); refusedIds.add(action.subject.id); }
|
|
409
|
+
for (const action of group) { settle(action, at, { status: 'landed', reason: own.reason }); if (own.refusedSubject) refusedIds.add(action.subject.id); }
|
|
399
410
|
report.worldOnly.push(...group.map((a) => a.id));
|
|
400
411
|
continue;
|
|
401
412
|
}
|
|
@@ -459,7 +470,13 @@ export async function performAtHead(service: string, action: Pick<TwinAction, 'i
|
|
|
459
470
|
if (head.kind !== 'real' || head.deploy !== 'auto') return { performed: false };
|
|
460
471
|
let report: DeployReport;
|
|
461
472
|
try { report = await performEntries({ service, actionIds: [action.id], ...(root !== undefined ? { root } : {}) }); }
|
|
462
|
-
catch (error) {
|
|
473
|
+
catch (error) {
|
|
474
|
+
if (error instanceof DeploymentOrderError) {
|
|
475
|
+
revertAction({ service, actionId: action.id, occurredAt: worldNow(), ...(root !== undefined ? { root } : {}) });
|
|
476
|
+
throw new VendorWriteError(`An earlier ${service} write is still pending; this request was rolled back before reaching the vendor. Retry after that write finishes.`, action.id);
|
|
477
|
+
}
|
|
478
|
+
throw error instanceof HeadError ? error : new HeadError(error instanceof Error ? error.message : String(error));
|
|
479
|
+
}
|
|
463
480
|
// under live use an entry the vendor did not take does not stay in the tree: the app was told no, the
|
|
464
481
|
// world says no — a revert lands (the receipt stays on the original, so the log shows what happened)
|
|
465
482
|
const undone = report.refused?.actionId === action.id || report.failed?.actionId === action.id;
|
|
@@ -491,7 +508,13 @@ export async function performRequestGroup(service: string, request: string, root
|
|
|
491
508
|
if (!group.length) return { ids };
|
|
492
509
|
let report: DeployReport;
|
|
493
510
|
try { report = await performEntries({ service, actionIds: group.map((e) => e.id), ...(root !== undefined ? { root } : {}) }); }
|
|
494
|
-
catch (error) {
|
|
511
|
+
catch (error) {
|
|
512
|
+
if (error instanceof DeploymentOrderError) {
|
|
513
|
+
for (const e of [...group].reverse()) revertAction({ service, actionId: e.id, occurredAt: worldNow(), ...(root !== undefined ? { root } : {}) });
|
|
514
|
+
return new VendorWriteError(`An earlier ${service} write is still pending; this request was rolled back before reaching the vendor. Retry after that write finishes.`, group[0]!.id);
|
|
515
|
+
}
|
|
516
|
+
throw error instanceof HeadError ? error : new HeadError(error instanceof Error ? error.message : String(error));
|
|
517
|
+
}
|
|
495
518
|
if (report.refused || report.failed) {
|
|
496
519
|
for (const e of [...group].reverse()) revertAction({ service, actionId: e.id, occurredAt: worldNow(), ...(root !== undefined ? { root } : {}) });
|
|
497
520
|
return report.refused ? new RefusedWriteError(report.refused.check, report.refused.reason, report.refused.actionId) : new VendorWriteError(report.failed!.error, report.failed!.actionId);
|