@volter/twin 0.1.0
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/LICENSE +202 -0
- package/README.md +68 -0
- package/dist/src/actions.d.ts +138 -0
- package/dist/src/actions.js +201 -0
- package/dist/src/args.d.ts +3 -0
- package/dist/src/args.js +12 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +425 -0
- package/dist/src/connector.d.ts +106 -0
- package/dist/src/connector.js +129 -0
- package/dist/src/control-plane.d.ts +21 -0
- package/dist/src/control-plane.js +40 -0
- package/dist/src/egress.d.ts +93 -0
- package/dist/src/egress.js +264 -0
- package/dist/src/fork.d.ts +126 -0
- package/dist/src/fork.js +206 -0
- package/dist/src/index.d.ts +42 -0
- package/dist/src/index.js +52 -0
- package/dist/src/lease.d.ts +50 -0
- package/dist/src/lease.js +80 -0
- package/dist/src/packRegistry.d.ts +34 -0
- package/dist/src/packRegistry.js +22 -0
- package/dist/src/plan.d.ts +97 -0
- package/dist/src/plan.js +151 -0
- package/dist/src/proxy.d.ts +25 -0
- package/dist/src/proxy.js +152 -0
- package/dist/src/pushLedger.d.ts +81 -0
- package/dist/src/pushLedger.js +130 -0
- package/dist/src/queueLifecycle.d.ts +62 -0
- package/dist/src/queueLifecycle.js +95 -0
- package/dist/src/reconcile.d.ts +58 -0
- package/dist/src/reconcile.js +137 -0
- package/dist/src/refs.d.ts +29 -0
- package/dist/src/refs.js +68 -0
- package/dist/src/schemas.d.ts +78 -0
- package/dist/src/schemas.js +50 -0
- package/dist/src/serve.d.ts +44 -0
- package/dist/src/serve.js +93 -0
- package/dist/src/shadow.d.ts +77 -0
- package/dist/src/shadow.js +138 -0
- package/dist/src/status.d.ts +31 -0
- package/dist/src/status.js +42 -0
- package/dist/src/storage.d.ts +119 -0
- package/dist/src/storage.js +535 -0
- package/dist/src/sync.d.ts +91 -0
- package/dist/src/sync.js +121 -0
- package/dist/src/types.d.ts +40 -0
- package/dist/src/types.js +1 -0
- package/dist/src/validate.d.ts +27 -0
- package/dist/src/validate.js +68 -0
- package/dist/src/visualizer.d.ts +13 -0
- package/dist/src/visualizer.js +133 -0
- package/dist/src/worldConfig.d.ts +9 -0
- package/dist/src/worldConfig.js +16 -0
- package/inject.cjs +429 -0
- package/package.json +81 -0
- package/src/actions.ts +285 -0
- package/src/args.ts +14 -0
- package/src/cli.ts +443 -0
- package/src/connector.ts +220 -0
- package/src/control-plane.ts +66 -0
- package/src/egress.ts +355 -0
- package/src/fork.ts +256 -0
- package/src/index.ts +222 -0
- package/src/lease.ts +97 -0
- package/src/packRegistry.ts +60 -0
- package/src/plan.ts +190 -0
- package/src/proxy.ts +180 -0
- package/src/pushLedger.ts +189 -0
- package/src/queueLifecycle.ts +130 -0
- package/src/reconcile.ts +192 -0
- package/src/refs.ts +91 -0
- package/src/schemas.ts +56 -0
- package/src/serve.ts +120 -0
- package/src/shadow.ts +192 -0
- package/src/status.ts +58 -0
- package/src/storage.ts +632 -0
- package/src/sync.ts +160 -0
- package/src/types.ts +50 -0
- package/src/validate.ts +95 -0
- package/src/visualizer.ts +142 -0
- package/src/worldConfig.ts +26 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { AppendEventResult, WorldServiceEvent } from './types.js';
|
|
2
|
+
export declare const DELTA_TYPE_SUFFIX = ".delta";
|
|
3
|
+
export type SubjectFields = Record<string, unknown>;
|
|
4
|
+
export type SubjectShadow = {
|
|
5
|
+
subject: {
|
|
6
|
+
type: string;
|
|
7
|
+
id: string;
|
|
8
|
+
};
|
|
9
|
+
fields: SubjectFields;
|
|
10
|
+
fieldHashes: Record<string, string>;
|
|
11
|
+
latestEventId: string;
|
|
12
|
+
updatedAt: string;
|
|
13
|
+
};
|
|
14
|
+
export type ShadowState = {
|
|
15
|
+
version: 1;
|
|
16
|
+
service: string;
|
|
17
|
+
rebuiltAt: string;
|
|
18
|
+
subjects: Record<string, SubjectShadow>;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Maps a world event to the subject fields it observes, or null when the
|
|
22
|
+
* event says nothing about remote subject state (comments, egress records, …).
|
|
23
|
+
* Extractors are provider-specific; the shadow engine is not.
|
|
24
|
+
*/
|
|
25
|
+
export type SubjectFieldExtractor = (event: WorldServiceEvent) => SubjectFields | null;
|
|
26
|
+
export type FieldChange = {
|
|
27
|
+
before: unknown;
|
|
28
|
+
after: unknown;
|
|
29
|
+
};
|
|
30
|
+
export declare function hashFieldValue(value: unknown): string;
|
|
31
|
+
/**
|
|
32
|
+
* Fold the service event log into per-subject materialized remote state.
|
|
33
|
+
* Delta events apply their `after` values directly; every other event goes
|
|
34
|
+
* through the extractor. The log is the source of truth — the shadow is
|
|
35
|
+
* always reproducible from a replay.
|
|
36
|
+
*/
|
|
37
|
+
export declare function buildShadowState(service: string, extractor: SubjectFieldExtractor, root?: string): ShadowState;
|
|
38
|
+
/** Field-level diff of freshly observed subject fields against the shadow. */
|
|
39
|
+
export declare function diffSubjectFields(shadow: SubjectShadow | undefined, observed: SubjectFields): Record<string, FieldChange>;
|
|
40
|
+
export type DeltaObservation = {
|
|
41
|
+
service: string;
|
|
42
|
+
subject: {
|
|
43
|
+
type: string;
|
|
44
|
+
id: string;
|
|
45
|
+
};
|
|
46
|
+
/** Freshly fetched provider-side fields for the subject. */
|
|
47
|
+
observed: SubjectFields;
|
|
48
|
+
/** Provider timestamp of the observed state when available. */
|
|
49
|
+
occurredAt?: string;
|
|
50
|
+
external?: {
|
|
51
|
+
provider: string;
|
|
52
|
+
id?: string;
|
|
53
|
+
url?: string;
|
|
54
|
+
cursor?: string;
|
|
55
|
+
};
|
|
56
|
+
actor?: {
|
|
57
|
+
kind: 'human' | 'agent' | 'bot' | 'system';
|
|
58
|
+
id?: string;
|
|
59
|
+
name?: string;
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
export type DeltaResult = {
|
|
63
|
+
changed: false;
|
|
64
|
+
} | {
|
|
65
|
+
changed: true;
|
|
66
|
+
event: WorldServiceEvent;
|
|
67
|
+
changes: Record<string, FieldChange>;
|
|
68
|
+
append: AppendEventResult;
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Compare an observed provider snapshot against the shadow and append a
|
|
72
|
+
* single delta event carrying only the changed fields. An unchanged
|
|
73
|
+
* re-observation appends nothing — this is the fix for re-snapshot noise.
|
|
74
|
+
* The delta idempotencyKey is derived from the after-state hashes, so the
|
|
75
|
+
* same observed change never appends twice.
|
|
76
|
+
*/
|
|
77
|
+
export declare function recordObservedDelta(state: ShadowState, observation: DeltaObservation, root?: string): DeltaResult;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { isEgressEventType } from "./egress.js";
|
|
3
|
+
import { appendEvent, createEvent, listEvents } from "./storage.js";
|
|
4
|
+
export const DELTA_TYPE_SUFFIX = '.delta';
|
|
5
|
+
function canonicalJson(value) {
|
|
6
|
+
if (Array.isArray(value))
|
|
7
|
+
return `[${value.map(canonicalJson).join(',')}]`;
|
|
8
|
+
if (value && typeof value === 'object') {
|
|
9
|
+
const entries = Object.entries(value)
|
|
10
|
+
.filter(([, item]) => item !== undefined)
|
|
11
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
12
|
+
.map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`);
|
|
13
|
+
return `{${entries.join(',')}}`;
|
|
14
|
+
}
|
|
15
|
+
return JSON.stringify(value) ?? 'null';
|
|
16
|
+
}
|
|
17
|
+
export function hashFieldValue(value) {
|
|
18
|
+
return createHash('sha256').update(canonicalJson(value)).digest('hex').slice(0, 16);
|
|
19
|
+
}
|
|
20
|
+
function subjectKey(subject) {
|
|
21
|
+
return `${subject.type}:${subject.id}`;
|
|
22
|
+
}
|
|
23
|
+
function isDeltaEvent(event) {
|
|
24
|
+
return event.type.endsWith(DELTA_TYPE_SUFFIX) && typeof event.data.changed === 'object';
|
|
25
|
+
}
|
|
26
|
+
function deltaAfterFields(event) {
|
|
27
|
+
const changed = event.data.changed;
|
|
28
|
+
const fields = {};
|
|
29
|
+
for (const [field, change] of Object.entries(changed))
|
|
30
|
+
fields[field] = change.after;
|
|
31
|
+
return fields;
|
|
32
|
+
}
|
|
33
|
+
function applyFields(state, event, fields) {
|
|
34
|
+
const key = subjectKey(event.subject);
|
|
35
|
+
const existing = state.subjects[key];
|
|
36
|
+
// A late-arriving observation of OLDER provider state must not regress the
|
|
37
|
+
// shadow. Comparable occurredAt timestamps win over log append order.
|
|
38
|
+
if (existing) {
|
|
39
|
+
const incoming = Date.parse(event.occurredAt);
|
|
40
|
+
const current = Date.parse(existing.updatedAt);
|
|
41
|
+
if (Number.isFinite(incoming) && Number.isFinite(current) && incoming < current)
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const target = existing ?? {
|
|
45
|
+
subject: { ...event.subject },
|
|
46
|
+
fields: {},
|
|
47
|
+
fieldHashes: {},
|
|
48
|
+
latestEventId: event.id,
|
|
49
|
+
updatedAt: event.occurredAt,
|
|
50
|
+
};
|
|
51
|
+
for (const [field, value] of Object.entries(fields)) {
|
|
52
|
+
if (value === undefined)
|
|
53
|
+
continue;
|
|
54
|
+
target.fields[field] = value;
|
|
55
|
+
target.fieldHashes[field] = hashFieldValue(value);
|
|
56
|
+
}
|
|
57
|
+
target.latestEventId = event.id;
|
|
58
|
+
target.updatedAt = event.occurredAt || target.updatedAt;
|
|
59
|
+
state.subjects[key] = target;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Fold the service event log into per-subject materialized remote state.
|
|
63
|
+
* Delta events apply their `after` values directly; every other event goes
|
|
64
|
+
* through the extractor. The log is the source of truth — the shadow is
|
|
65
|
+
* always reproducible from a replay.
|
|
66
|
+
*/
|
|
67
|
+
export function buildShadowState(service, extractor, root) {
|
|
68
|
+
const state = { version: 1, service, rebuiltAt: new Date().toISOString(), subjects: {} };
|
|
69
|
+
for (const event of listEvents(service, root)) {
|
|
70
|
+
if (isEgressEventType(event.type))
|
|
71
|
+
continue;
|
|
72
|
+
if (isDeltaEvent(event)) {
|
|
73
|
+
applyFields(state, event, deltaAfterFields(event));
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const fields = extractor(event);
|
|
77
|
+
if (fields)
|
|
78
|
+
applyFields(state, event, fields);
|
|
79
|
+
}
|
|
80
|
+
return state;
|
|
81
|
+
}
|
|
82
|
+
/** Field-level diff of freshly observed subject fields against the shadow. */
|
|
83
|
+
export function diffSubjectFields(shadow, observed) {
|
|
84
|
+
const changed = {};
|
|
85
|
+
for (const [field, after] of Object.entries(observed)) {
|
|
86
|
+
if (after === undefined)
|
|
87
|
+
continue;
|
|
88
|
+
const beforeHash = shadow?.fieldHashes[field];
|
|
89
|
+
if (beforeHash !== undefined && beforeHash === hashFieldValue(after))
|
|
90
|
+
continue;
|
|
91
|
+
changed[field] = { before: shadow?.fields[field], after };
|
|
92
|
+
}
|
|
93
|
+
return changed;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Compare an observed provider snapshot against the shadow and append a
|
|
97
|
+
* single delta event carrying only the changed fields. An unchanged
|
|
98
|
+
* re-observation appends nothing — this is the fix for re-snapshot noise.
|
|
99
|
+
* The delta idempotencyKey is derived from the after-state hashes, so the
|
|
100
|
+
* same observed change never appends twice.
|
|
101
|
+
*/
|
|
102
|
+
export function recordObservedDelta(state, observation, root) {
|
|
103
|
+
const shadow = state.subjects[subjectKey(observation.subject)];
|
|
104
|
+
const changes = diffSubjectFields(shadow, observation.observed);
|
|
105
|
+
if (Object.keys(changes).length === 0)
|
|
106
|
+
return { changed: false };
|
|
107
|
+
// The hash covers the provider timestamp as well as the after-state:
|
|
108
|
+
// A→B→A→B transitions revisit an after-state, and an id derived from
|
|
109
|
+
// content alone would collide with the earlier delta and make appendEvent
|
|
110
|
+
// throw (same id, different occurredAt). Re-observing the SAME provider
|
|
111
|
+
// state still dedupes — occurredAt comes from the provider, not the poll.
|
|
112
|
+
const afterHash = createHash('sha256')
|
|
113
|
+
.update(observation.occurredAt ?? '')
|
|
114
|
+
.update('\u001f')
|
|
115
|
+
.update(canonicalJson(Object.fromEntries(Object.entries(changes).map(([field, change]) => [field, change.after]))))
|
|
116
|
+
.digest('hex')
|
|
117
|
+
.slice(0, 16);
|
|
118
|
+
const type = `${observation.service}.${observation.subject.type}${DELTA_TYPE_SUFFIX}`;
|
|
119
|
+
const event = createEvent({
|
|
120
|
+
id: `${type}:${observation.subject.id}:${afterHash}`,
|
|
121
|
+
service: observation.service,
|
|
122
|
+
type,
|
|
123
|
+
idempotencyKey: `${observation.service}:delta:${observation.subject.type}:${observation.subject.id}:${afterHash}`,
|
|
124
|
+
occurredAt: observation.occurredAt ?? new Date().toISOString(),
|
|
125
|
+
origin: 'connector',
|
|
126
|
+
...(observation.actor ? { actor: observation.actor } : {}),
|
|
127
|
+
subject: observation.subject,
|
|
128
|
+
...(observation.external ? { external: observation.external } : {}),
|
|
129
|
+
data: {
|
|
130
|
+
changedFields: Object.keys(changes),
|
|
131
|
+
changed: changes,
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
const append = appendEvent(event, root);
|
|
135
|
+
if (append.appended)
|
|
136
|
+
applyFields(state, append.event, deltaAfterFields(append.event));
|
|
137
|
+
return { changed: true, event: append.event, changes, append };
|
|
138
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { QueueCounts } from './queueLifecycle.js';
|
|
2
|
+
import type { WorldRemoteRef } from './refs.js';
|
|
3
|
+
export type WorldStatus = {
|
|
4
|
+
service: string;
|
|
5
|
+
remote: WorldRemoteRef | null;
|
|
6
|
+
queue: QueueCounts;
|
|
7
|
+
local: {
|
|
8
|
+
unpushed: number;
|
|
9
|
+
reverted: number;
|
|
10
|
+
};
|
|
11
|
+
push: {
|
|
12
|
+
unconfirmed: number;
|
|
13
|
+
};
|
|
14
|
+
drift: {
|
|
15
|
+
created: number;
|
|
16
|
+
changed: number;
|
|
17
|
+
} | null;
|
|
18
|
+
conflicts: Array<{
|
|
19
|
+
actionId: string;
|
|
20
|
+
reason: string;
|
|
21
|
+
}>;
|
|
22
|
+
staleBase: boolean | null;
|
|
23
|
+
};
|
|
24
|
+
export declare function worldStatus(service: string, opts?: {
|
|
25
|
+
root?: string;
|
|
26
|
+
forkId?: string;
|
|
27
|
+
provider?: string;
|
|
28
|
+
refName?: string;
|
|
29
|
+
}): WorldStatus;
|
|
30
|
+
/** Render the status as the operator text block the doc shows. */
|
|
31
|
+
export declare function formatStatus(s: WorldStatus): string;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// world status (the twins architecture notes → "Status"). The operator safety
|
|
2
|
+
// dashboard: it makes the four state classes impossible to confuse — queued
|
|
3
|
+
// deliveries, canonical observed events, local transaction commits, and push/apply
|
|
4
|
+
// records — plus drift, conflicts, and stale bases. Pure read over the kernel
|
|
5
|
+
// ledgers; deterministic.
|
|
6
|
+
import { listActions, pendingActions } from "./actions.js";
|
|
7
|
+
import { isFork, forkDivergence } from "./fork.js";
|
|
8
|
+
import { pendingConflicts } from "./plan.js";
|
|
9
|
+
import { unconfirmedPushes } from "./pushLedger.js";
|
|
10
|
+
import { queueCounts } from "./queueLifecycle.js";
|
|
11
|
+
import { isBaseStale, readLocalRef, readRemoteRef } from "./refs.js";
|
|
12
|
+
export function worldStatus(service, opts = {}) {
|
|
13
|
+
const { root, forkId, provider, refName = 'main' } = opts;
|
|
14
|
+
const actions = listActions(service, root);
|
|
15
|
+
const remote = provider ? readRemoteRef(service, provider, refName, root) : null;
|
|
16
|
+
const local = forkId ? readLocalRef(service, forkId, root) : null;
|
|
17
|
+
const drift = isFork(service, root ?? '') ? (() => { const d = forkDivergence(service, root ?? ''); return { created: d.created.length, changed: d.changed.length }; })() : null;
|
|
18
|
+
return {
|
|
19
|
+
service,
|
|
20
|
+
remote,
|
|
21
|
+
queue: queueCounts(service, root),
|
|
22
|
+
local: { unpushed: pendingActions(service, root).length, reverted: actions.filter((a) => a.op === 'revert').length },
|
|
23
|
+
push: { unconfirmed: unconfirmedPushes(service, root).length },
|
|
24
|
+
drift,
|
|
25
|
+
conflicts: pendingConflicts(service, root),
|
|
26
|
+
staleBase: local ? isBaseStale(local, root) : null,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** Render the status as the operator text block the doc shows. */
|
|
30
|
+
export function formatStatus(s) {
|
|
31
|
+
const lines = [];
|
|
32
|
+
lines.push(s.remote ? `remote/${s.remote.provider}/${s.remote.name} at ${s.remote.cursor ?? s.remote.eventId ?? '(no checkpoint)'}` : 'remote: (no ref recorded)');
|
|
33
|
+
lines.push(`queue: ${s.queue.queued} queued, ${s.queue.committed} committed, ${s.queue.ignored} ignored, ${s.queue.superseded} superseded, ${s.queue.poisoned} poisoned`);
|
|
34
|
+
lines.push(`local: ${s.local.unpushed} unpushed transactions, ${s.local.reverted} reverted`);
|
|
35
|
+
lines.push(`push: ${s.push.unconfirmed} provider_accepted/attempted awaiting observed confirmation`);
|
|
36
|
+
if (s.drift)
|
|
37
|
+
lines.push(`drift: ${s.drift.created} created, ${s.drift.changed} changed since fork base`);
|
|
38
|
+
lines.push(`conflicts: ${s.conflicts.length}${s.conflicts.length ? ` (${s.conflicts.map((c) => c.actionId).join(', ')})` : ''}`);
|
|
39
|
+
if (s.staleBase !== null)
|
|
40
|
+
lines.push(`base: ${s.staleBase ? 'STALE — rebase before push' : 'current'}`);
|
|
41
|
+
return lines.join('\n');
|
|
42
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { AppendEventResult, CommitQueuedEventsResult, EnqueueEventResult, GenericWorldState, QueuedWorldServiceEvent, WorldPaths, WorldReducer, WorldServiceEvent } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Name of the per-project state directory holding world data
|
|
4
|
+
* (`<root>/<stateDir>/world/...`). Defaults to `.volter`; hosts that need a
|
|
5
|
+
* different directory set VOLTER_STATE_DIR. Every path in this package must
|
|
6
|
+
* go through worldStateRoot — never the literal.
|
|
7
|
+
*/
|
|
8
|
+
export declare function stateDirName(): string;
|
|
9
|
+
export declare function worldStateRoot(root?: string): string;
|
|
10
|
+
export declare function worldPaths(service: string, root?: string): WorldPaths;
|
|
11
|
+
/** Read + parse a whole-file JSON sidecar, citing the path on a parse failure (mirrors
|
|
12
|
+
* readJsonl's `${path}:...` error shape). Callers own existence semantics — this parses a
|
|
13
|
+
* file that is expected to exist; guard with existsSync first when absence is allowed. */
|
|
14
|
+
export declare function readJsonFile<T>(path: string): T;
|
|
15
|
+
/**
|
|
16
|
+
* Append `data` to `path`. When VOLTER_DURABLE=1, `fsyncSync` the file after the write so
|
|
17
|
+
* the appended record survives an OS crash / power loss — not just a process crash.
|
|
18
|
+
*
|
|
19
|
+
* Default (VOLTER_DURABLE unset) is OFF: `appendFileSync` is a completed write syscall, so a
|
|
20
|
+
* *process* crash after it returns still leaves the record on the log (the OS owns the page
|
|
21
|
+
* cache). The window this leaves open is a **kernel-panic / power loss** between the write
|
|
22
|
+
* landing in the page cache and the fs flushing it to stable storage — a torn or lost tail
|
|
23
|
+
* record. That crash window matters more once real pulled staging data lives in these files
|
|
24
|
+
* (purpose 2), so hosts that need durability opt in with VOLTER_DURABLE=1 at the cost of an
|
|
25
|
+
* fsync per append. See ARCHITECTURE.md D1.
|
|
26
|
+
*/
|
|
27
|
+
export declare function appendDurable(path: string, data: string): void;
|
|
28
|
+
/**
|
|
29
|
+
* Opt-in structured stderr logging for the audit trail (ARCHITECTURE-REVIEW-TODOS D3):
|
|
30
|
+
* one line per action-log append and per push-ledger row, so "who reviewed the change
|
|
31
|
+
* that caused this real write" is mechanically greppable from a single log stream via
|
|
32
|
+
* `correlationId`. Off by default — mirrors the VOLTER_DURABLE opt-in policy above: no
|
|
33
|
+
* always-on I/O, never on the default path. Set VOLTER_TWIN_LOG=1 to enable.
|
|
34
|
+
*/
|
|
35
|
+
export declare function twinLog(kind: string, details: Record<string, unknown>): void;
|
|
36
|
+
/** Run `fn` holding an exclusive cross-process file lock (the same primitive the event log
|
|
37
|
+
* uses). Used to make read-then-append critical sections atomic across processes. The
|
|
38
|
+
* lockfile records `{pid, hostname, at}`; a contender that finds a stale lock (dead pid or
|
|
39
|
+
* age > LOCK_STALE_MS) reclaims it by atomically renaming it aside — so a crashed holder
|
|
40
|
+
* can't wedge the world forever. Reclaim is race-safe: only the process that wins the
|
|
41
|
+
* rename clears the stale inode, and the exclusive `wx` create still decides the winner. */
|
|
42
|
+
export declare function withFileLock<T>(lockPath: string, fn: () => T): T;
|
|
43
|
+
export declare function listEvents(service: string, root?: string): WorldServiceEvent[];
|
|
44
|
+
/** Exported so other modules that share a service's events log (e.g. egress.ts,
|
|
45
|
+
* TWIN-58) can serialize their own critical sections on the SAME lock appendEvent
|
|
46
|
+
* itself uses, instead of inventing a second, uncoordinated lockfile. */
|
|
47
|
+
export declare function eventsLockPath(paths: WorldPaths): string;
|
|
48
|
+
/**
|
|
49
|
+
* The append body, assuming the caller already holds `eventsLockPath(paths)`. Split
|
|
50
|
+
* out so `commitQueuedEvents` can run a whole batch of appends (and the rebuild that
|
|
51
|
+
* follows them) under ONE lock acquisition instead of nesting a fresh `withFileLock`
|
|
52
|
+
* per row — `withFileLock` is not reentrant, so re-acquiring it from inside an
|
|
53
|
+
* already-held lock in the same process would just spin to its own timeout. Also
|
|
54
|
+
* exported for egress.ts (TWIN-58): performExternalWrite's ledger-check +
|
|
55
|
+
* intent-append span holds `eventsLockPath` itself, so it must append through this
|
|
56
|
+
* already-locked path rather than the public `appendEvent` (which would try to
|
|
57
|
+
* re-acquire the same lock and spin to its own timeout).
|
|
58
|
+
*/
|
|
59
|
+
export declare function appendEventLocked(parsed: WorldServiceEvent, paths: WorldPaths): AppendEventResult;
|
|
60
|
+
export declare function appendEvent(event: WorldServiceEvent, root?: string): AppendEventResult;
|
|
61
|
+
export declare function listQueuedEvents(service: string, root?: string): QueuedWorldServiceEvent[];
|
|
62
|
+
export declare function enqueueEvent(event: WorldServiceEvent, options?: {
|
|
63
|
+
root?: string;
|
|
64
|
+
source?: QueuedWorldServiceEvent['source'];
|
|
65
|
+
receivedAt?: string;
|
|
66
|
+
id?: string;
|
|
67
|
+
}): EnqueueEventResult;
|
|
68
|
+
/**
|
|
69
|
+
* Commit queued (webhook/listener) events into the canonical log, then rebuild the
|
|
70
|
+
* generic projection — spanning BOTH under the SAME events-lock acquisition (D7).
|
|
71
|
+
* Appending each event and rebuilding state.json are two separate durable writes;
|
|
72
|
+
* without a shared lock, a concurrent commit/append on another process could
|
|
73
|
+
* interleave between "this commit's last append" and "this commit's rebuild",
|
|
74
|
+
* so the rebuilt state.json would not correspond to exactly this commit's view of
|
|
75
|
+
* the log. Holding one lock across the whole sequence rules that out: no other
|
|
76
|
+
* appendEvent/commitQueuedEvents call for this service can run until both the
|
|
77
|
+
* appends AND the rebuild here have completed. (A single-process crash between the
|
|
78
|
+
* last durable append and the rebuild can still leave state.json one rebuild behind
|
|
79
|
+
* — that window is inherent to a two-file update with no WAL, and is unaffected by
|
|
80
|
+
* locking; it is closed by the next successful commit/rebuild, which always starts
|
|
81
|
+
* from the durably-appended log, so no event is ever lost, only state.json's
|
|
82
|
+
* projection is briefly stale.)
|
|
83
|
+
*/
|
|
84
|
+
export declare function commitQueuedEvents(service: string, options?: {
|
|
85
|
+
root?: string;
|
|
86
|
+
limit?: number;
|
|
87
|
+
}): CommitQueuedEventsResult;
|
|
88
|
+
export declare function genericWorldReducer(state: GenericWorldState, event: WorldServiceEvent): GenericWorldState;
|
|
89
|
+
export declare function emptyGenericState(service: string): GenericWorldState;
|
|
90
|
+
export declare function rebuildState<State>(service: string, initialState: State, reducer: WorldReducer<State>, root?: string): State;
|
|
91
|
+
export declare function rebuildGenericState(service: string, root?: string): GenericWorldState;
|
|
92
|
+
export declare function loadState<T = unknown>(service: string, root?: string): T | null;
|
|
93
|
+
export declare function createEvent(input: Omit<WorldServiceEvent, 'schemaVersion' | 'observedAt'> & {
|
|
94
|
+
schemaVersion?: number;
|
|
95
|
+
observedAt?: string;
|
|
96
|
+
}): WorldServiceEvent;
|
|
97
|
+
export type ScrubResult = {
|
|
98
|
+
/** The directory scrub targeted (may not have existed). */
|
|
99
|
+
target: string;
|
|
100
|
+
/** Every file actually removed, path relative to `target`, in no particular order. */
|
|
101
|
+
removed: string[];
|
|
102
|
+
/** One-line human summary, safe to print as-is. */
|
|
103
|
+
message: string;
|
|
104
|
+
};
|
|
105
|
+
/** Delete one service's pulled data at rest: its event log, queued events,
|
|
106
|
+
* rebuilt state.json, and any resources/cursors/ingests sidecars — everything
|
|
107
|
+
* `worldPaths(service)` points at. Refuses (unless `force`) when the dir holds
|
|
108
|
+
* anything storage.ts didn't put there. */
|
|
109
|
+
export declare function scrubService(service: string, options?: {
|
|
110
|
+
root?: string;
|
|
111
|
+
force?: boolean;
|
|
112
|
+
}): ScrubResult;
|
|
113
|
+
/** Delete the ENTIRE world state dir (`<root>/<stateDir>/world`) — every
|
|
114
|
+
* service's pulled data at once. Refuses (unless `force`) when any entry in it
|
|
115
|
+
* isn't itself a recognizable per-service state dir. */
|
|
116
|
+
export declare function scrubWorld(options?: {
|
|
117
|
+
root?: string;
|
|
118
|
+
force?: boolean;
|
|
119
|
+
}): ScrubResult;
|