fieldlog 0.15.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/CHANGELOG.md +109 -0
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/bin/fieldlog.js +18 -0
- package/bin/fieldlog.ts +145 -0
- package/package.json +35 -0
- package/src/auth.ts +297 -0
- package/src/cas.ts +357 -0
- package/src/deltasync.ts +306 -0
- package/src/hashchain.ts +106 -0
- package/src/index.ts +41 -0
- package/src/kernel.ts +333 -0
- package/src/log.ts +344 -0
- package/src/quota.ts +122 -0
- package/src/relay.ts +1027 -0
- package/src/retain.ts +267 -0
- package/src/revokelog.ts +291 -0
- package/src/store.ts +706 -0
- package/src/sync.ts +828 -0
- package/src/tombstone.ts +306 -0
package/src/deltasync.ts
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
// deltasync.ts — manifest-first delta sync between two fieldlog replicas.
|
|
2
|
+
//
|
|
3
|
+
// Protocol (boring, in order):
|
|
4
|
+
// 1. manifest: receiver pulls sender manifest { count, tip, ids[] } first.
|
|
5
|
+
// 2. want-list: receiver diffs sender ids against its local UUID set.
|
|
6
|
+
// 3. fetch: receiver fetches only the want-list, in chunks, in manifest order.
|
|
7
|
+
// 4. apply: each fetched event appends under a fresh local seq, idempotent by UUID.
|
|
8
|
+
// 5. resume: want-list progress persists per chunk in store meta; a cut
|
|
9
|
+
// re-runs from the persisted remainder (plus any new manifest ids).
|
|
10
|
+
// 6. dead-letter: a shape-invalid (poison) event is recorded by UUID in
|
|
11
|
+
// store meta (`<cursorKey>.dead`) and never refetched on later runs;
|
|
12
|
+
// the want-list still drains past it, so one bad write can never
|
|
13
|
+
// brick sync or retry forever.
|
|
14
|
+
//
|
|
15
|
+
// Origin-auth stripping contract (interop audit — every implementation MUST
|
|
16
|
+
// match this, byte for byte in effect):
|
|
17
|
+
// - The receiver NEVER copies the sender's auth envelope or chain position:
|
|
18
|
+
// remote.signature, remote.countersignatures, remote.seq,
|
|
19
|
+
// remote.prev_hash, and remote.hash are all dropped on the floor, as is
|
|
20
|
+
// the sender's device_id as an owner (it is kept only as origin_device).
|
|
21
|
+
// - The local append mints a fresh local seq, prev_hash, hash, and
|
|
22
|
+
// device_id (the receiver's own deviceId); the local signer (if any)
|
|
23
|
+
// re-signs the re-hashed event. Keeping the origin signature would fail
|
|
24
|
+
// verification under the local device_id and brick relayed pulls.
|
|
25
|
+
// - Preserved verbatim as audit/display metadata: type, payload, actor,
|
|
26
|
+
// ts_device (origin wall clock, display only — NEVER authoritative),
|
|
27
|
+
// origin_seq (= remote.seq), origin_device (= remote.device_id).
|
|
28
|
+
// server_time is NOT carried over (it is excluded from the hash on
|
|
29
|
+
// purpose; the receiver keeps its own clock view).
|
|
30
|
+
// - No signature verification happens here: peers are trusted replicas
|
|
31
|
+
// (same operator). Forgery-gated pull with a device registry stays on
|
|
32
|
+
// the sync.ts path (pullRemote); this file does shape validation
|
|
33
|
+
// (checkAppend) but no signature verification.
|
|
34
|
+
//
|
|
35
|
+
// Transport is a DeltaPeer { manifest, fetch } — memory, file, or ws backed.
|
|
36
|
+
// Trust note: peers here are trusted replicas (same operator). Forgery-gated
|
|
37
|
+
// pull with a device registry stays on the sync.ts path (pullRemote); this
|
|
38
|
+
// file does shape validation (checkAppend) but no signature verification.
|
|
39
|
+
import { checkAppend, type EventStore } from './store.js';
|
|
40
|
+
import { withBackoff } from './sync.js';
|
|
41
|
+
import type { AppendLog, LogEvent } from './log.js';
|
|
42
|
+
|
|
43
|
+
export interface DeltaManifest {
|
|
44
|
+
v: 1;
|
|
45
|
+
/** sender event count at manifest time. */
|
|
46
|
+
count: number;
|
|
47
|
+
/** sender tip hash at manifest time (change hint, not verified here). */
|
|
48
|
+
tip: string;
|
|
49
|
+
/** sender UUIDs in log order. */
|
|
50
|
+
ids: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Minimal delta transport: manifest first, then fetch by UUID. */
|
|
54
|
+
export interface DeltaPeer {
|
|
55
|
+
manifest(): Promise<DeltaManifest>;
|
|
56
|
+
/** Return events for the requested UUIDs, in request order when possible. */
|
|
57
|
+
fetch(ids: string[]): Promise<LogEvent[]>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface DeltaOpts {
|
|
61
|
+
/** events fetched+applied per chunk; cursor persists per chunk. */
|
|
62
|
+
chunkSize?: number;
|
|
63
|
+
/** store-meta namespace for persisted resume state (default 'deltasync'). */
|
|
64
|
+
cursorKey?: string;
|
|
65
|
+
maxRetries?: number;
|
|
66
|
+
baseMs?: number;
|
|
67
|
+
maxMs?: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface DeltaResult {
|
|
71
|
+
/** UUIDs missing locally at plan time (before this run's applies). */
|
|
72
|
+
wanted: number;
|
|
73
|
+
/** requested UUIDs matched by the peer's fetch replies this run. */
|
|
74
|
+
fetched: number;
|
|
75
|
+
/** events newly applied to the local log+store this run. */
|
|
76
|
+
applied: number;
|
|
77
|
+
/** shape-invalid UUIDs dead-lettered this run (recorded, never refetched). */
|
|
78
|
+
poisoned: number;
|
|
79
|
+
/** true when this run continued persisted want-list progress. */
|
|
80
|
+
resumed: boolean;
|
|
81
|
+
/** true when nothing remains (local has every manifest id). */
|
|
82
|
+
done: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Build the sender-side manifest over log order. */
|
|
86
|
+
export function buildManifest(log: AppendLog): DeltaManifest {
|
|
87
|
+
const ids = log.readAll().map((e) => e.id);
|
|
88
|
+
return { v: 1, count: ids.length, tip: log.lastHash(), ids };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Receiver-side diff: sender ids missing from the local UUID set, in sender order. */
|
|
92
|
+
export function computeWant(localIds: Set<string>, remote: DeltaManifest): string[] {
|
|
93
|
+
const want: string[] = [];
|
|
94
|
+
for (const id of remote.ids) {
|
|
95
|
+
if (typeof id === 'string' && id !== '' && !localIds.has(id)) want.push(id);
|
|
96
|
+
}
|
|
97
|
+
return want;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** In-memory peer over an AppendLog (tests, local dev). */
|
|
101
|
+
export function createMemoryPeer(log: AppendLog): DeltaPeer {
|
|
102
|
+
return {
|
|
103
|
+
async manifest(): Promise<DeltaManifest> {
|
|
104
|
+
return buildManifest(log);
|
|
105
|
+
},
|
|
106
|
+
async fetch(ids: string[]): Promise<LogEvent[]> {
|
|
107
|
+
const out: LogEvent[] = [];
|
|
108
|
+
for (const id of ids) {
|
|
109
|
+
const ev = log.getById(id);
|
|
110
|
+
if (ev !== null) out.push(ev);
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function localIdSet(log: AppendLog): Set<string> {
|
|
118
|
+
const ids = new Set<string>();
|
|
119
|
+
for (const e of log.readAll()) ids.add(e.id);
|
|
120
|
+
return ids;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function loadPersistedWant(store: EventStore, cursorKey: string): string[] {
|
|
124
|
+
const raw = store.getMeta(`${cursorKey}.want`);
|
|
125
|
+
if (!raw) return [];
|
|
126
|
+
try {
|
|
127
|
+
const parsed: unknown = JSON.parse(raw);
|
|
128
|
+
if (!Array.isArray(parsed)) throw new Error('not an array');
|
|
129
|
+
return parsed.filter((x): x is string => typeof x === 'string' && x !== '');
|
|
130
|
+
} catch (err) {
|
|
131
|
+
// A corrupt resume queue must surface, never silently drop (refetch
|
|
132
|
+
// storm) or silently resurrect poison (dead-set loss below).
|
|
133
|
+
throw new Error(`syncDelta: corrupt ${cursorKey}.want meta (expected JSON string array): ${(err as Error).message}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function savePersistedWant(store: EventStore, cursorKey: string, want: string[]): void {
|
|
138
|
+
store.setMeta(`${cursorKey}.want`, JSON.stringify(want));
|
|
139
|
+
}
|
|
140
|
+
/** UUIDs already judged shape-invalid: recorded, never refetched. */
|
|
141
|
+
function loadDeadSet(store: EventStore, cursorKey: string): Set<string> {
|
|
142
|
+
const raw = store.getMeta(`${cursorKey}.dead`);
|
|
143
|
+
if (!raw) return new Set();
|
|
144
|
+
try {
|
|
145
|
+
const parsed: unknown = JSON.parse(raw);
|
|
146
|
+
if (!Array.isArray(parsed)) throw new Error('not an array');
|
|
147
|
+
return new Set(parsed.filter((x): x is string => typeof x === 'string' && x !== ''));
|
|
148
|
+
} catch (err) {
|
|
149
|
+
// A corrupt dead-set must surface: silently resetting it resurrects
|
|
150
|
+
// every poison UUID into an unbounded cross-run refetch loop.
|
|
151
|
+
throw new Error(`syncDelta: corrupt ${cursorKey}.dead meta (expected JSON string array): ${(err as Error).message}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function saveDeadSet(store: EventStore, cursorKey: string, dead: Set<string>): void {
|
|
156
|
+
store.setMeta(`${cursorKey}.dead`, JSON.stringify([...dead]));
|
|
157
|
+
}
|
|
158
|
+
/** Merge persisted remainder with a fresh want-list; manifest order wins. */
|
|
159
|
+
function mergeWant(persisted: string[], fresh: string[], freshOrder: string[]): string[] {
|
|
160
|
+
const freshSet = new Set(fresh);
|
|
161
|
+
const order = new Map<string, number>(freshOrder.map((id, i) => [id, i]));
|
|
162
|
+
const seen = new Set(persisted.filter((id) => freshSet.has(id)));
|
|
163
|
+
const merged = [...seen];
|
|
164
|
+
for (const id of fresh) {
|
|
165
|
+
if (!seen.has(id)) {
|
|
166
|
+
merged.push(id);
|
|
167
|
+
seen.add(id);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
merged.sort((a, b) => (order.get(a) ?? 1e12) - (order.get(b) ?? 1e12));
|
|
171
|
+
return merged;
|
|
172
|
+
}
|
|
173
|
+
type ApplyOutcome = 'applied' | 'duplicate' | 'poison' | 'retry';
|
|
174
|
+
|
|
175
|
+
// Validate + append one remote event locally. Origin-auth stripping: the
|
|
176
|
+
// local append mints a fresh seq/hash/device_id; the origin survives only
|
|
177
|
+
// as origin_seq/origin_device audit metadata (see file header contract).
|
|
178
|
+
function applyOneRemote(
|
|
179
|
+
log: AppendLog,
|
|
180
|
+
store: EventStore,
|
|
181
|
+
deviceId: string,
|
|
182
|
+
remote: LogEvent,
|
|
183
|
+
): ApplyOutcome {
|
|
184
|
+
if (!remote || typeof remote.id !== 'string' || remote.id === '') return 'poison';
|
|
185
|
+
// Idempotent by UUID: already stored -> no-op; logged but not stored
|
|
186
|
+
// (kill between log.append and store.apply) -> re-drive the stored copy.
|
|
187
|
+
if (store.hasId(remote.id)) return 'duplicate';
|
|
188
|
+
if (log.hasId(remote.id)) {
|
|
189
|
+
const pending = log.getById(remote.id);
|
|
190
|
+
if (pending === null) return 'retry';
|
|
191
|
+
try {
|
|
192
|
+
store.apply(pending);
|
|
193
|
+
} catch {
|
|
194
|
+
return 'retry';
|
|
195
|
+
}
|
|
196
|
+
return store.hasId(remote.id) ? 'applied' : 'retry';
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
if (!remote.type || typeof remote.type !== 'string') return 'poison';
|
|
200
|
+
const payload = (remote.payload ?? {}) as Record<string, unknown>;
|
|
201
|
+
if (typeof payload !== 'object' || payload === null) return 'poison';
|
|
202
|
+
checkAppend(remote.type, payload);
|
|
203
|
+
} catch {
|
|
204
|
+
return 'poison'; // poison shape: dead-letter, never pins the want-list
|
|
205
|
+
}
|
|
206
|
+
const ev = log.append({
|
|
207
|
+
type: remote.type,
|
|
208
|
+
payload: (remote.payload ?? {}) as Record<string, unknown>,
|
|
209
|
+
actor: remote.actor,
|
|
210
|
+
device_id: deviceId,
|
|
211
|
+
id: remote.id,
|
|
212
|
+
ts_device: remote.ts_device,
|
|
213
|
+
origin_seq: remote.seq,
|
|
214
|
+
origin_device: remote.device_id,
|
|
215
|
+
});
|
|
216
|
+
try {
|
|
217
|
+
store.apply(ev);
|
|
218
|
+
} catch {
|
|
219
|
+
return 'retry'; // fsynced but not stored: hold the id so retry re-drives it
|
|
220
|
+
}
|
|
221
|
+
return 'applied';
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Manifest-first delta sync from a peer into this replica.
|
|
226
|
+
* Resume: want-list remainder persists per chunk; re-call after a cut.
|
|
227
|
+
*/
|
|
228
|
+
export async function syncDelta(
|
|
229
|
+
log: AppendLog,
|
|
230
|
+
store: EventStore,
|
|
231
|
+
deviceId: string,
|
|
232
|
+
peer: DeltaPeer,
|
|
233
|
+
opts: DeltaOpts = {},
|
|
234
|
+
): Promise<DeltaResult> {
|
|
235
|
+
const chunkSize = opts.chunkSize ?? 50;
|
|
236
|
+
const cursorKey = opts.cursorKey ?? 'deltasync';
|
|
237
|
+
if (!Number.isInteger(chunkSize) || chunkSize < 1) {
|
|
238
|
+
throw new Error(`syncDelta: chunkSize must be a positive integer, got ${opts.chunkSize}`);
|
|
239
|
+
}
|
|
240
|
+
const manifest = await withBackoff(() => peer.manifest(), opts);
|
|
241
|
+
if (!manifest || manifest.v !== 1 || !Array.isArray(manifest.ids)) {
|
|
242
|
+
throw new Error('syncDelta: bad manifest (want { v: 1, ids: string[] })');
|
|
243
|
+
}
|
|
244
|
+
const dead = loadDeadSet(store, cursorKey);
|
|
245
|
+
const persisted = loadPersistedWant(store, cursorKey).filter((id) => !dead.has(id));
|
|
246
|
+
const fresh = computeWant(localIdSet(log), manifest).filter((id) => !dead.has(id));
|
|
247
|
+
const persistedLive = persisted.filter((id) => !store.hasId(id) && !log.hasId(id));
|
|
248
|
+
const want = persistedLive.length > 0 ? mergeWant(persistedLive, fresh, manifest.ids) : fresh;
|
|
249
|
+
const resumed = persistedLive.length > 0;
|
|
250
|
+
const wanted = want.length;
|
|
251
|
+
if (want.length === 0) {
|
|
252
|
+
savePersistedWant(store, cursorKey, []);
|
|
253
|
+
return { wanted: 0, fetched: 0, applied: 0, poisoned: 0, resumed, done: true };
|
|
254
|
+
}
|
|
255
|
+
let fetched = 0;
|
|
256
|
+
let applied = 0;
|
|
257
|
+
let poisoned = 0;
|
|
258
|
+
let remainder = [...want];
|
|
259
|
+
let idle = 0;
|
|
260
|
+
while (remainder.length > 0) {
|
|
261
|
+
const chunk = remainder.slice(0, chunkSize);
|
|
262
|
+
const chunkSet = new Set(chunk);
|
|
263
|
+
const events = (await withBackoff(() => peer.fetch(chunk), opts)) ?? [];
|
|
264
|
+
// Requested ids only: a peer that volunteers extra (or duplicate) lines
|
|
265
|
+
// must not inflate the metric — count each requested UUID once.
|
|
266
|
+
const matched = new Set<string>();
|
|
267
|
+
for (const e of events) {
|
|
268
|
+
const id = e?.id;
|
|
269
|
+
if (typeof id === 'string' && chunkSet.has(id)) matched.add(id);
|
|
270
|
+
}
|
|
271
|
+
fetched += matched.size;
|
|
272
|
+
const appliedBefore = applied;
|
|
273
|
+
const before = remainder.length;
|
|
274
|
+
const byId = new Map(events.map((e) => [e?.id, e]));
|
|
275
|
+
const hold: string[] = [];
|
|
276
|
+
for (const id of chunk) {
|
|
277
|
+
const remote = byId.get(id);
|
|
278
|
+
if (!remote) {
|
|
279
|
+
hold.push(id); // peer short: retry next run, keep cursor
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
const outcome = applyOneRemote(log, store, deviceId, remote);
|
|
283
|
+
if (outcome === 'applied') applied += 1;
|
|
284
|
+
else if (outcome === 'retry') hold.push(id);
|
|
285
|
+
else if (outcome === 'poison') {
|
|
286
|
+
// Real dead-letter: record the UUID so later runs never refetch it.
|
|
287
|
+
// The want-list still drains past it (never pins, never retries).
|
|
288
|
+
if (!dead.has(id)) {
|
|
289
|
+
dead.add(id);
|
|
290
|
+
poisoned += 1;
|
|
291
|
+
saveDeadSet(store, cursorKey, dead);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
// 'duplicate' needs nothing: already stored, drop from the remainder.
|
|
295
|
+
}
|
|
296
|
+
remainder = [...hold, ...remainder.slice(chunkSize)];
|
|
297
|
+
savePersistedWant(store, cursorKey, remainder);
|
|
298
|
+
if (remainder.length >= before && applied === appliedBefore) {
|
|
299
|
+
idle += 1;
|
|
300
|
+
if (idle >= 2) return { wanted, fetched, applied, poisoned, resumed, done: false };
|
|
301
|
+
} else {
|
|
302
|
+
idle = 0;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return { wanted, fetched, applied, poisoned, resumed, done: true };
|
|
306
|
+
}
|
package/src/hashchain.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// hashchain.ts — skill-12 hash-chain-log port (stable) for fieldlog.
|
|
2
|
+
//
|
|
3
|
+
// Append + verify + quarantine + re-anchor facade over log.ts. The chain
|
|
4
|
+
// itself lives in log.ts (canonicalOf/hashFor/openLog); this module names
|
|
5
|
+
// the four operations as one boring surface so callers never reimplement
|
|
6
|
+
// chain checks by hand.
|
|
7
|
+
//
|
|
8
|
+
// append -> openHashChain(path, device).append(...) chains prev_hash
|
|
9
|
+
// verify -> chain.verify() replays hash + prev linkage
|
|
10
|
+
// quarantine -> on open, unparsable mid-file lines move to <path>.quarantine
|
|
11
|
+
// re-anchor -> the first kept event after a gap verifies OK with gaps=[seq]
|
|
12
|
+
import {
|
|
13
|
+
GENESIS_HASH,
|
|
14
|
+
canonicalOf,
|
|
15
|
+
hashFor,
|
|
16
|
+
openLog,
|
|
17
|
+
type AppendInput,
|
|
18
|
+
type AppendLog,
|
|
19
|
+
type LogEvent,
|
|
20
|
+
type VerifyResult,
|
|
21
|
+
} from './log.js';
|
|
22
|
+
|
|
23
|
+
export { GENESIS_HASH, canonicalOf, hashFor };
|
|
24
|
+
export type { LogEvent, AppendInput, VerifyResult };
|
|
25
|
+
|
|
26
|
+
/** Forensic sidecar for quarantined lines. */
|
|
27
|
+
export function quarantinePathFor(path: string): string {
|
|
28
|
+
return path + '.quarantine';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface HashChain extends AppendLog {}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Pure chain replay over already-loaded events.
|
|
35
|
+
*
|
|
36
|
+
* Checks hash + prev linkage AND seq continuity (mirroring
|
|
37
|
+
* AppendLog.verify()): only a forward jump onto a quarantined `gaps` seq
|
|
38
|
+
* is forgiven (re-anchored). The chain is anchored at the first event's
|
|
39
|
+
* seq, so a bare post-sweep suffix verifies — pass `opts.base` (marker tip)
|
|
40
|
+
* and `opts.startSeq` (marker truncated_before) to pin the expected base
|
|
41
|
+
* instead. Without them a foreign `prev_hash` on the first event fails
|
|
42
|
+
* with a hint to use `log.verify()` (which knows the truncate marker);
|
|
43
|
+
* post-sweep callers should prefer `openHashChain(path).verify()`.
|
|
44
|
+
*/
|
|
45
|
+
export function verifyChain(
|
|
46
|
+
events: LogEvent[],
|
|
47
|
+
gaps: Iterable<number> = [],
|
|
48
|
+
opts: { base?: string; startSeq?: number } = {},
|
|
49
|
+
): VerifyResult {
|
|
50
|
+
const gapSet = new Set(gaps);
|
|
51
|
+
const echoed: number[] = [];
|
|
52
|
+
let prev = opts.base ?? GENESIS_HASH;
|
|
53
|
+
let expectedSeq = opts.startSeq ?? events[0]?.seq ?? 1;
|
|
54
|
+
for (const e of events) {
|
|
55
|
+
const { hash, signature: _s, countersignatures: _c, ...core } = e;
|
|
56
|
+
void _s;
|
|
57
|
+
void _c;
|
|
58
|
+
if (hashFor(core) !== hash) {
|
|
59
|
+
return { ok: false, at: e.seq, reason: 'hash mismatch (tampered payload?)' };
|
|
60
|
+
}
|
|
61
|
+
const seqForgiven = e.seq > expectedSeq && gapSet.has(e.seq);
|
|
62
|
+
if (e.seq !== expectedSeq && !seqForgiven) {
|
|
63
|
+
return {
|
|
64
|
+
ok: false,
|
|
65
|
+
at: e.seq,
|
|
66
|
+
reason:
|
|
67
|
+
e.seq < expectedSeq
|
|
68
|
+
? 'duplicate seq (forked/edited log?)'
|
|
69
|
+
: e.seq === events[0]?.seq && e.prev_hash !== prev && opts.base === undefined
|
|
70
|
+
? 'prev_hash mismatch (swept prefix? pass opts.base/startSeq or use log.verify())'
|
|
71
|
+
: 'seq gap (truncated/edited log?)',
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
if (e.prev_hash !== prev) {
|
|
75
|
+
if (!gapSet.has(e.seq)) {
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
at: e.seq,
|
|
79
|
+
reason:
|
|
80
|
+
e.seq === events[0]?.seq && opts.base === undefined
|
|
81
|
+
? 'prev_hash mismatch (swept prefix? pass opts.base/startSeq or use log.verify())'
|
|
82
|
+
: 'prev_hash mismatch (truncated/edited log?)',
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
echoed.push(e.seq);
|
|
86
|
+
} else if (seqForgiven) {
|
|
87
|
+
echoed.push(e.seq);
|
|
88
|
+
}
|
|
89
|
+
prev = hash;
|
|
90
|
+
expectedSeq = e.seq + 1;
|
|
91
|
+
}
|
|
92
|
+
return echoed.length > 0 ? { ok: true, gaps: echoed } : { ok: true };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Open a hash-chained log at `path`. Append chains prev_hash to the tip and
|
|
97
|
+
* fsyncs per write; open quarantines corrupt mid-file lines and re-anchors
|
|
98
|
+
* the survivor (see AppendLog.verify/quarantined/repairedTail/sealedBelow).
|
|
99
|
+
*/
|
|
100
|
+
export function openHashChain(
|
|
101
|
+
path: string,
|
|
102
|
+
deviceId: string,
|
|
103
|
+
signer?: (ev: LogEvent) => string,
|
|
104
|
+
): HashChain {
|
|
105
|
+
return openLog(path, deviceId, signer);
|
|
106
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// fieldlog v0.1 — offline-first kernel: append-only log + SQLite read-model + sync.
|
|
2
|
+
export { openLog, hashFor, canonicalOf, GENESIS_HASH } from './log.js';
|
|
3
|
+
export type { LogEvent, AppendInput, AppendLog, VerifyResult } from './log.js';
|
|
4
|
+
export { openHashChain, verifyChain, quarantinePathFor } from './hashchain.js';
|
|
5
|
+
export type { HashChain } from './hashchain.js';
|
|
6
|
+
export { openStore, EntryState, checkAppend } from './store.js';
|
|
7
|
+
export type { EventStore, EntryState as EntryStateType, SqlParams } from './store.js';
|
|
8
|
+
export { MemoryRelay, pushPending, pullRemote, syncKernel, syncWithFailover, createFailoverState, withBackoff, backoffMs, getAckSeq, getServerTime } from './sync.js';
|
|
9
|
+
export type { Relay, PushAck, PushResult, PullResult, SyncOpts, FailoverState, FailoverResult } from './sync.js';
|
|
10
|
+
export { buildManifest, computeWant, createMemoryPeer, syncDelta } from './deltasync.js';
|
|
11
|
+
export type { DeltaManifest, DeltaPeer, DeltaOpts, DeltaResult } from './deltasync.js';
|
|
12
|
+
export {
|
|
13
|
+
generateDeviceKey,
|
|
14
|
+
signBytes,
|
|
15
|
+
verifyBytes,
|
|
16
|
+
signEvent,
|
|
17
|
+
verifyEvent,
|
|
18
|
+
GRANT_TTL_MS,
|
|
19
|
+
CAP_TOKEN_TTL_MS,
|
|
20
|
+
canonicalGrant,
|
|
21
|
+
issueGrant,
|
|
22
|
+
verifyGrant,
|
|
23
|
+
RevocationList,
|
|
24
|
+
countersignEvent,
|
|
25
|
+
checkThreshold,
|
|
26
|
+
mintCapToken,
|
|
27
|
+
verifyCapToken,
|
|
28
|
+
canonicalCapToken,
|
|
29
|
+
CapRevocationList,
|
|
30
|
+
authorizeCapToken,
|
|
31
|
+
authorizeGrant,
|
|
32
|
+
} from './auth.js';
|
|
33
|
+
export type { DeviceKeypair, ScopeGrant, Countersignature, CapToken, AuthorizeVerdict } from './auth.js';
|
|
34
|
+
export { createKernel, logPathFor, DEFAULT_OUTBOX_CAP } from './kernel.js';
|
|
35
|
+
export type { Kernel, KernelOpts, AppendArgs, LogHealth } from './kernel.js';
|
|
36
|
+
export { WsRelayServer, WsRelayClient, mulberry32 } from './relay.js';
|
|
37
|
+
export type { WsRelayServerOpts, WsRelayClientOpts } from './relay.js';
|
|
38
|
+
export { takeSnapshot, sweepLogFile, snapshotPathFor } from './retain.js';
|
|
39
|
+
export type { SnapshotResult, TruncateResult } from './retain.js';
|
|
40
|
+
export { openCas, casKeyFor, casShardFor, casPathFor, casQuarantinePathFor } from './cas.js';
|
|
41
|
+
export type { CasStore, CasStat } from './cas.js';
|