@volter/world-core 3.0.140 → 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 +61 -29
- package/package.json +1 -1
- package/src/head.ts +48 -19
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;
|
|
@@ -323,24 +326,10 @@ async function performNow(opts) {
|
|
|
323
326
|
const adapters = opts.adapters ?? stateSystemFor(vendor) ?? stateSystemFor(service);
|
|
324
327
|
if (!adapters?.perform)
|
|
325
328
|
throw new Error(`the ${vendor} twin has no perform adapter — a pack names one in its descriptor and registers itself (registerPack)`);
|
|
326
|
-
// the caller's executor, or the one built over the sealed credential; an unsealed root has no other
|
|
327
|
-
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 });
|
|
328
|
-
// an unsealed root's World is where its state resolves (the root given, PROJECT_ROOT, or the working directory)
|
|
329
|
-
const checks = opts.checks ?? (await loadChecks(bound.sealed ? worldRootOf(bound.sealed) : worldPaths(service, root).root));
|
|
330
329
|
const settling = settlingEntries(service, root);
|
|
331
|
-
const
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
const settled = receiptsByEntry(parentEntries(service, root));
|
|
335
|
-
for (const id of opts.actionIds)
|
|
336
|
-
if (settled.get(id)?.status === 'deployed')
|
|
337
|
-
report.replayed.push(id);
|
|
338
|
-
}
|
|
339
|
-
const settle = (action, at, receipt, fields = action.fields ?? {}, vendorSubjectId) => {
|
|
340
|
-
confirmAction({ service, actionId: action.id, subject: action.subject, fields, occurredAt: at, ...(vendorSubjectId ? { vendorSubjectId } : {}), receipt, ...(root !== undefined ? { root } : {}) });
|
|
341
|
-
};
|
|
342
|
-
// the subjects the vendor refused and never took: a request naming one is the World's alone, never sent (the
|
|
343
|
-
// real-system adapters, "Perform" 3), settled as it landed here
|
|
330
|
+
const selected = opts.actionIds === undefined ? undefined : new Set(opts.actionIds);
|
|
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.
|
|
344
333
|
const parent = parentEntries(service, root);
|
|
345
334
|
const taken = new Set();
|
|
346
335
|
const refusedIds = new Set();
|
|
@@ -357,26 +346,60 @@ async function performNow(opts) {
|
|
|
357
346
|
else if (v && typeof v === 'object')
|
|
358
347
|
for (const x of Object.values(v))
|
|
359
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();
|
|
356
|
+
if (selected !== undefined) {
|
|
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));
|
|
371
|
+
if (earlier)
|
|
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.`);
|
|
373
|
+
}
|
|
374
|
+
// the caller's executor, or the one built over the sealed credential; an unsealed root has no other
|
|
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 });
|
|
376
|
+
// an unsealed root's World is where its state resolves (the root given, PROJECT_ROOT, or the working directory)
|
|
377
|
+
const checks = opts.checks ?? (await loadChecks(bound.sealed ? worldRootOf(bound.sealed) : worldPaths(service, root).root));
|
|
378
|
+
const report = { pushed: 0, deployed: [], skipped: [], replayed: [], worldOnly: pick(settling.worldOnly).map((e) => e.id) };
|
|
379
|
+
if (opts.actionIds) {
|
|
380
|
+
const settled = receiptsByEntry(parentEntries(service, root));
|
|
381
|
+
for (const id of opts.actionIds)
|
|
382
|
+
if (settled.get(id)?.status === 'deployed')
|
|
383
|
+
report.replayed.push(id);
|
|
384
|
+
}
|
|
385
|
+
const settle = (action, at, receipt, fields = action.fields ?? {}, vendorSubjectId) => {
|
|
386
|
+
confirmAction({ service, actionId: action.id, subject: action.subject, fields, occurredAt: at, ...(vendorSubjectId ? { vendorSubjectId } : {}), receipt, ...(root !== undefined ? { root } : {}) });
|
|
387
|
+
};
|
|
388
|
+
// the subjects the vendor refused and never took: a request naming one is the World's alone, never sent (the
|
|
389
|
+
// real-system adapters, "Perform" 3), settled as it landed here
|
|
360
390
|
let stopped = false;
|
|
361
391
|
for (const group of groupsOf(pick(settling.offered))) {
|
|
362
392
|
if (stopped) {
|
|
363
393
|
report.skipped.push(...group.map((a) => a.id));
|
|
364
394
|
continue;
|
|
365
395
|
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
if (unsent) {
|
|
369
|
-
const at = opts.at ?? worldNow();
|
|
370
|
-
for (const action of group)
|
|
371
|
-
settle(action, at, { status: 'landed', reason: unsent });
|
|
372
|
-
report.worldOnly.push(...group.map((a) => a.id));
|
|
373
|
-
continue;
|
|
374
|
-
}
|
|
375
|
-
if (named(group[0]).some((v) => refusedIds.has(v))) {
|
|
396
|
+
const own = classifiedOwn.get(group[0].id) ?? ownWrite(group, refusedIds);
|
|
397
|
+
if (own) {
|
|
376
398
|
const at = opts.at ?? worldNow();
|
|
377
399
|
for (const action of group) {
|
|
378
|
-
settle(action, at, { status: 'landed', reason:
|
|
379
|
-
|
|
400
|
+
settle(action, at, { status: 'landed', reason: own.reason });
|
|
401
|
+
if (own.refusedSubject)
|
|
402
|
+
refusedIds.add(action.subject.id);
|
|
380
403
|
}
|
|
381
404
|
report.worldOnly.push(...group.map((a) => a.id));
|
|
382
405
|
continue;
|
|
@@ -457,6 +480,10 @@ export async function performAtHead(service, action, root) {
|
|
|
457
480
|
report = await performEntries({ service, actionIds: [action.id], ...(root !== undefined ? { root } : {}) });
|
|
458
481
|
}
|
|
459
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
|
+
}
|
|
460
487
|
throw error instanceof HeadError ? error : new HeadError(error instanceof Error ? error.message : String(error));
|
|
461
488
|
}
|
|
462
489
|
// under live use an entry the vendor did not take does not stay in the tree: the app was told no, the
|
|
@@ -502,6 +529,11 @@ export async function performRequestGroup(service, request, root) {
|
|
|
502
529
|
report = await performEntries({ service, actionIds: group.map((e) => e.id), ...(root !== undefined ? { root } : {}) });
|
|
503
530
|
}
|
|
504
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
|
+
}
|
|
505
537
|
throw error instanceof HeadError ? error : new HeadError(error instanceof Error ? error.message : String(error));
|
|
506
538
|
}
|
|
507
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'; }
|
|
@@ -355,12 +358,38 @@ async function performNow(opts: Parameters<typeof performEntries>[0]): Promise<D
|
|
|
355
358
|
const vendor = bound.vendor;
|
|
356
359
|
const adapters = opts.adapters ?? stateSystemFor(vendor) ?? stateSystemFor(service);
|
|
357
360
|
if (!adapters?.perform) throw new Error(`the ${vendor} twin has no perform adapter — a pack names one in its descriptor and registers itself (registerPack)`);
|
|
361
|
+
const settling = settlingEntries(service, root);
|
|
362
|
+
const selected = opts.actionIds === undefined ? undefined : new Set(opts.actionIds);
|
|
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 }>();
|
|
376
|
+
if (selected !== undefined) {
|
|
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.`);
|
|
388
|
+
}
|
|
358
389
|
// the caller's executor, or the one built over the sealed credential; an unsealed root has no other
|
|
359
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 });
|
|
360
391
|
// an unsealed root's World is where its state resolves (the root given, PROJECT_ROOT, or the working directory)
|
|
361
392
|
const checks = opts.checks ?? (await loadChecks(bound.sealed ? worldRootOf(bound.sealed) : worldPaths(service, root).root));
|
|
362
|
-
const settling = settlingEntries(service, root);
|
|
363
|
-
const pick = (list: TwinAction[]): TwinAction[] => (opts.actionIds === undefined ? list : opts.actionIds.map((id) => list.find((a) => a.id === id)).filter((a): a is TwinAction => a !== undefined));
|
|
364
393
|
const report: DeployReport = { pushed: 0, deployed: [], skipped: [], replayed: [], worldOnly: pick(settling.worldOnly).map((e) => e.id) };
|
|
365
394
|
if (opts.actionIds) {
|
|
366
395
|
const settled = receiptsByEntry(parentEntries(service, root));
|
|
@@ -371,25 +400,13 @@ async function performNow(opts: Parameters<typeof performEntries>[0]): Promise<D
|
|
|
371
400
|
};
|
|
372
401
|
// the subjects the vendor refused and never took: a request naming one is the World's alone, never sent (the
|
|
373
402
|
// real-system adapters, "Perform" 3), settled as it landed here
|
|
374
|
-
const parent = parentEntries(service, root);
|
|
375
|
-
const taken = new Set<string>(); const refusedIds = new Set<string>();
|
|
376
|
-
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); }
|
|
377
|
-
for (const id of taken) refusedIds.delete(id);
|
|
378
|
-
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; };
|
|
379
403
|
let stopped = false;
|
|
380
404
|
for (const group of groupsOf(pick(settling.offered))) {
|
|
381
405
|
if (stopped) { report.skipped.push(...group.map((a) => a.id)); continue; }
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
if (unsent) {
|
|
385
|
-
const at = opts.at ?? worldNow();
|
|
386
|
-
for (const action of group) settle(action, at, { status: 'landed', reason: unsent });
|
|
387
|
-
report.worldOnly.push(...group.map((a) => a.id));
|
|
388
|
-
continue;
|
|
389
|
-
}
|
|
390
|
-
if (named(group[0]!).some((v) => refusedIds.has(v))) {
|
|
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:
|
|
409
|
+
for (const action of group) { settle(action, at, { status: 'landed', reason: own.reason }); if (own.refusedSubject) refusedIds.add(action.subject.id); }
|
|
393
410
|
report.worldOnly.push(...group.map((a) => a.id));
|
|
394
411
|
continue;
|
|
395
412
|
}
|
|
@@ -453,7 +470,13 @@ export async function performAtHead(service: string, action: Pick<TwinAction, 'i
|
|
|
453
470
|
if (head.kind !== 'real' || head.deploy !== 'auto') return { performed: false };
|
|
454
471
|
let report: DeployReport;
|
|
455
472
|
try { report = await performEntries({ service, actionIds: [action.id], ...(root !== undefined ? { root } : {}) }); }
|
|
456
|
-
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
|
+
}
|
|
457
480
|
// under live use an entry the vendor did not take does not stay in the tree: the app was told no, the
|
|
458
481
|
// world says no — a revert lands (the receipt stays on the original, so the log shows what happened)
|
|
459
482
|
const undone = report.refused?.actionId === action.id || report.failed?.actionId === action.id;
|
|
@@ -485,7 +508,13 @@ export async function performRequestGroup(service: string, request: string, root
|
|
|
485
508
|
if (!group.length) return { ids };
|
|
486
509
|
let report: DeployReport;
|
|
487
510
|
try { report = await performEntries({ service, actionIds: group.map((e) => e.id), ...(root !== undefined ? { root } : {}) }); }
|
|
488
|
-
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
|
+
}
|
|
489
518
|
if (report.refused || report.failed) {
|
|
490
519
|
for (const e of [...group].reverse()) revertAction({ service, actionId: e.id, occurredAt: worldNow(), ...(root !== undefined ? { root } : {}) });
|
|
491
520
|
return report.refused ? new RefusedWriteError(report.refused.check, report.refused.reason, report.refused.actionId) : new VendorWriteError(report.failed!.error, report.failed!.actionId);
|