@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 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 pick = (list) => (opts.actionIds === undefined ? list : opts.actionIds.map((id) => list.find((a) => a.id === id)).filter((a) => a !== undefined));
332
- const report = { pushed: 0, deployed: [], skipped: [], replayed: [], worldOnly: pick(settling.worldOnly).map((e) => e.id) };
333
- if (opts.actionIds) {
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
- // a write of a unit the pack sends nowhere (`vendorBacked.none` beside vendor-backed lanes) is the World's alone
367
- const unsent = adapters.unsent?.(group[0].input);
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: 'names a subject the vendor refused: the World\'s alone' });
379
- refusedIds.add(action.subject.id);
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.140",
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
- // a write of a unit the pack sends nowhere (`vendorBacked.none` beside vendor-backed lanes) is the World's alone
383
- const unsent = adapters.unsent?.(group[0]!.input as Record<string, unknown> | undefined);
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: '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); }
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) { 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
+ }
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) { 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
+ }
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);