@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 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 last = settling.offered.reduce((index, action, at) => selected.has(action.id) ? at : index, -1);
331
- const earlier = settling.offered.slice(0, last + 1).find((action) => !selected.has(action.id));
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 Error(`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.`);
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
- // a write of a unit the pack sends nowhere (`vendorBacked.none` beside vendor-backed lanes) is the World's alone
374
- const unsent = adapters.unsent?.(group[0].input);
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: 'names a subject the vendor refused: the World\'s alone' });
386
- refusedIds.add(action.subject.id);
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.141",
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 last = settling.offered.reduce((index, action, at) => selected.has(action.id) ? at : index, -1);
363
- const earlier = settling.offered.slice(0, last + 1).find((action) => !selected.has(action.id));
364
- if (earlier) throw new Error(`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.`);
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
- // a write of a unit the pack sends nowhere (`vendorBacked.none` beside vendor-backed lanes) is the World's alone
389
- const unsent = adapters.unsent?.(group[0]!.input as Record<string, unknown> | undefined);
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: unsent });
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) { throw error instanceof HeadError ? error : new HeadError(error instanceof Error ? error.message : String(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) { throw error instanceof HeadError ? error : new HeadError(error instanceof Error ? error.message : String(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);