@vrtmrz/livesync-commonlib 0.3.1 → 0.3.2

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.
@@ -12,7 +12,7 @@ import { MinioStorageAdapter } from "./objectstore/MinioStorageAdapter.js";
12
12
  import { LiveSyncAbstractReplicator } from "../LiveSyncAbstractReplicator.js";
13
13
  import { ensureRemoteIsCompatible } from "../../pouchdb/LiveSyncDBFunctions.js";
14
14
  import { getEffectiveTweakValues } from "../../pouchdb/LiveSyncDBFunctions.js";
15
- import { assertSupportedPathNormalizationVersion, getPathNormalizationVersion } from "../../common/pathNormalization.js";
15
+ import { assertSupportedPathNormalizationVersion } from "../../common/pathNormalization.js";
16
16
  import { JournalStorageReadStatuses } from "./objectstore/JournalStorageAdapter.js";
17
17
  import { computeKeyedId, configuredIdKey } from "../../common/idDerivation.js";
18
18
  import { decryptString, encryptString } from "../../encryption/stringEncryption.js";
@@ -31,6 +31,12 @@ import {
31
31
  forgetJoinedDevice as forgetJoinedDeviceFromStore,
32
32
  projectJoinedDevices
33
33
  } from "../joinedDevices.js";
34
+ import {
35
+ journalVersionRange,
36
+ hasJournalNodeDeclarations,
37
+ matchesJournalPathIdentity,
38
+ inspectJournalPathIdentity
39
+ } from "./journalCompatibility.js";
34
40
  const MILSTONE_DOCID = "_00000000-milestone.json";
35
41
  async function readRemoteMilestone(client) {
36
42
  const result = await client.downloadJsonWithResult(MILSTONE_DOCID);
@@ -38,23 +44,6 @@ async function readRemoteMilestone(client) {
38
44
  if (result.status === JournalStorageReadStatuses.NOT_FOUND) return void 0;
39
45
  throw result.error;
40
46
  }
41
- const currentVersionRange = {
42
- min: 0,
43
- max: 2,
44
- current: 2
45
- };
46
- function journalVersionRange(setting) {
47
- if (getPathNormalizationVersion(setting) === 1) return { min: 4, max: 4, current: 4 };
48
- return configuredIdKey(setting) && setting.encrypt && setting.usePathObfuscation ? { min: 3, max: 3, current: 3 } : currentVersionRange;
49
- }
50
- function pathIdentityFromJournalRange(range) {
51
- if (range?.min === 4 && range?.max === 4) return 1;
52
- if (!range) return 0;
53
- if (Number.isSafeInteger(range.min) && Number.isSafeInteger(range.max) && range.min >= 0 && range.max <= 3 && range.min <= range.max) {
54
- return 0;
55
- }
56
- throw new Error("The Journal declares an unsupported path identity range.");
57
- }
58
47
  class LiveSyncJournalReplicator extends LiveSyncAbstractReplicator {
59
48
  get client() {
60
49
  return this.setupJournalSyncClient();
@@ -89,17 +78,8 @@ class LiveSyncJournalReplicator extends LiveSyncAbstractReplicator {
89
78
  return this._client;
90
79
  }
91
80
  async isBucketPathIdentityCompatible(setting, milestone, client) {
92
- const pathVersion = getPathNormalizationVersion(setting);
93
- const ranges = milestone?.node_chunk_info ?? {};
94
- const nodes = /* @__PURE__ */ new Set([...milestone?.accepted_nodes ?? [], ...Object.keys(ranges)]);
95
- for (const node of nodes) {
96
- const range = ranges[node];
97
- const usesNFC = range?.min === 4 && range?.max === 4;
98
- if (usesNFC !== (pathVersion === 1)) return false;
99
- }
100
- const preferred = milestone?.tweak_values?.[DEVICE_ID_PREFERRED];
101
- if (preferred && getPathNormalizationVersion(preferred) !== pathVersion) return false;
102
- if (nodes.size === 0 && await client.hasRemoteJournals()) {
81
+ if (!matchesJournalPathIdentity(setting, milestone)) return false;
82
+ if (!hasJournalNodeDeclarations(milestone) && await client.hasRemoteJournals()) {
103
83
  throw new Error(
104
84
  "The Journal compatibility milestone is missing. Rebuild from the authoritative Vault before synchronising."
105
85
  );
@@ -144,22 +124,13 @@ class LiveSyncJournalReplicator extends LiveSyncAbstractReplicator {
144
124
  async inspectRemotePathIdentity(setting) {
145
125
  const client = this.setupJournalSyncClient(setting);
146
126
  const milestone = await readRemoteMilestone(client);
147
- const versions = /* @__PURE__ */ new Set();
148
- const ranges = milestone?.node_chunk_info ?? {};
149
- const nodes = /* @__PURE__ */ new Set([...milestone?.accepted_nodes ?? [], ...Object.keys(ranges)]);
150
- for (const node of nodes) {
151
- versions.add(pathIdentityFromJournalRange(ranges[node]));
152
- }
153
- const preferred = milestone?.tweak_values?.[DEVICE_ID_PREFERRED];
154
- if (preferred) versions.add(getPathNormalizationVersion(preferred));
155
- if (versions.size > 1) throw new Error("The Journal has conflicting path identity declarations.");
156
- if (nodes.size === 0 && await client.hasRemoteJournals()) {
127
+ const identity = inspectJournalPathIdentity(milestone);
128
+ if (!hasJournalNodeDeclarations(milestone) && await client.hasRemoteJournals()) {
157
129
  throw new Error(
158
130
  "The Journal path identity declaration is missing. Use matching settings or Rebuild from the authoritative Vault."
159
131
  );
160
132
  }
161
- const version = [...versions][0];
162
- return version === void 0 ? { status: "empty" } : { status: "configured", pathNormalizationVersion: version };
133
+ return identity;
163
134
  }
164
135
  /** Lock existing journals for replacement without declaring the replacement's identity. */
165
136
  async markRemoteLockedForRebuild(setting) {
@@ -178,7 +149,7 @@ class LiveSyncJournalReplicator extends LiveSyncAbstractReplicator {
178
149
  throw new Error("Could not upload remote milestone");
179
150
  }
180
151
  }
181
- async ensureBucketIsCompatible(deviceNodeID, currentVersionRange2, setting = this.currentSettings, client = this.setupJournalSyncClient(setting), recordTweakAssessment) {
152
+ async ensureBucketIsCompatible(deviceNodeID, currentVersionRange, setting = this.currentSettings, client = this.setupJournalSyncClient(setting), recordTweakAssessment) {
182
153
  const downloadedMilestone = await readRemoteMilestone(client) ?? false;
183
154
  if (!await this.isBucketPathIdentityCompatible(setting, downloadedMilestone || void 0, client)) {
184
155
  return "INCOMPATIBLE";
@@ -193,7 +164,7 @@ class LiveSyncJournalReplicator extends LiveSyncAbstractReplicator {
193
164
  downloadedMilestone,
194
165
  setting,
195
166
  deviceNodeID,
196
- currentVersionRange2,
167
+ currentVersionRange,
197
168
  {
198
169
  app_version: this.env.services.API.getAppVersion(),
199
170
  plugin_version: this.env.services.API.getPluginVersion(),
@@ -356,6 +327,18 @@ class LiveSyncJournalReplicator extends LiveSyncAbstractReplicator {
356
327
  (client) => client.sendLocalJournal(showingNotice)
357
328
  );
358
329
  }
330
+ /** Return the stopped Journal from the current transfer client without creating another client. */
331
+ getStoppedJournal() {
332
+ return this._client?.getStoppedJournal();
333
+ }
334
+ /** Recheck connectivity and compatibility, skip the confirmed Journal, and resume receipt. */
335
+ async skipStoppedJournalAndResume(setting, stopped, showingNotice = true) {
336
+ return await this.runDirectionalReplication(
337
+ setting,
338
+ showingNotice,
339
+ (client) => client.skipStoppedJournalAndResume(stopped, showingNotice)
340
+ );
341
+ }
359
342
  async replicateAllFromServer(setting, showingNotice) {
360
343
  return await this.runDirectionalReplication(
361
344
  setting,
@@ -721,6 +704,10 @@ ${ex.message}`, LOG_LEVEL_NOTICE);
721
704
  return Promise.resolve(false);
722
705
  }
723
706
  }
707
+ function isLiveSyncJournalReplicator(value) {
708
+ return value instanceof LiveSyncJournalReplicator;
709
+ }
724
710
  export {
725
- LiveSyncJournalReplicator
711
+ LiveSyncJournalReplicator,
712
+ isLiveSyncJournalReplicator
726
713
  };
@@ -0,0 +1,9 @@
1
+ import { type ChunkVersionRange, type EntryMilestoneInfo, type RemoteDBSettings } from "../../common/types.js";
2
+ import type { PathIdentityInspection } from "../../pouchdb/pathIdentityCompatibility.js";
3
+ export declare function journalVersionRange(setting: RemoteDBSettings): ChunkVersionRange;
4
+ export declare function pathIdentityFromJournalRange(range: ChunkVersionRange | undefined): 0 | 1;
5
+ /** Without a node declaration, the caller must inspect storage before treating the bucket as empty. */
6
+ export declare function hasJournalNodeDeclarations(milestone: EntryMilestoneInfo | undefined): boolean;
7
+ export declare function matchesJournalPathIdentity(setting: RemoteDBSettings, milestone: EntryMilestoneInfo | undefined): boolean;
8
+ /** Project declarations only; an empty result does not establish that remote storage is empty. */
9
+ export declare function inspectJournalPathIdentity(milestone: EntryMilestoneInfo | undefined): PathIdentityInspection;
@@ -0,0 +1,58 @@
1
+ import {
2
+ DEVICE_ID_PREFERRED
3
+ } from "../../common/types.js";
4
+ import { configuredIdKey } from "../../common/idDerivation.js";
5
+ import { getPathNormalizationVersion } from "../../common/pathNormalization.js";
6
+ const currentVersionRange = {
7
+ min: 0,
8
+ max: 2,
9
+ current: 2
10
+ };
11
+ function journalVersionRange(setting) {
12
+ if (getPathNormalizationVersion(setting) === 1) return { min: 4, max: 4, current: 4 };
13
+ return configuredIdKey(setting) && setting.encrypt && setting.usePathObfuscation ? { min: 3, max: 3, current: 3 } : currentVersionRange;
14
+ }
15
+ function pathIdentityFromJournalRange(range) {
16
+ if (range?.min === 4 && range?.max === 4) return 1;
17
+ if (!range) return 0;
18
+ if (Number.isSafeInteger(range.min) && Number.isSafeInteger(range.max) && range.min >= 0 && range.max <= 3 && range.min <= range.max) {
19
+ return 0;
20
+ }
21
+ throw new Error("The Journal declares an unsupported path identity range.");
22
+ }
23
+ function journalPathIdentityNodes(milestone) {
24
+ return /* @__PURE__ */ new Set([...milestone?.accepted_nodes ?? [], ...Object.keys(milestone?.node_chunk_info ?? {})]);
25
+ }
26
+ function hasJournalNodeDeclarations(milestone) {
27
+ return journalPathIdentityNodes(milestone).size > 0;
28
+ }
29
+ function matchesJournalPathIdentity(setting, milestone) {
30
+ const pathVersion = getPathNormalizationVersion(setting);
31
+ const ranges = milestone?.node_chunk_info ?? {};
32
+ for (const node of journalPathIdentityNodes(milestone)) {
33
+ const range = ranges[node];
34
+ const usesNFC = range?.min === 4 && range?.max === 4;
35
+ if (usesNFC !== (pathVersion === 1)) return false;
36
+ }
37
+ const preferred = milestone?.tweak_values?.[DEVICE_ID_PREFERRED];
38
+ return !preferred || getPathNormalizationVersion(preferred) === pathVersion;
39
+ }
40
+ function inspectJournalPathIdentity(milestone) {
41
+ const versions = /* @__PURE__ */ new Set();
42
+ const ranges = milestone?.node_chunk_info ?? {};
43
+ for (const node of journalPathIdentityNodes(milestone)) {
44
+ versions.add(pathIdentityFromJournalRange(ranges[node]));
45
+ }
46
+ const preferred = milestone?.tweak_values?.[DEVICE_ID_PREFERRED];
47
+ if (preferred) versions.add(getPathNormalizationVersion(preferred));
48
+ if (versions.size > 1) throw new Error("The Journal has conflicting path identity declarations.");
49
+ const version = [...versions][0];
50
+ return version === void 0 ? { status: "empty" } : { status: "configured", pathNormalizationVersion: version };
51
+ }
52
+ export {
53
+ hasJournalNodeDeclarations,
54
+ inspectJournalPathIdentity,
55
+ journalVersionRange,
56
+ matchesJournalPathIdentity,
57
+ pathIdentityFromJournalRange
58
+ };
@@ -0,0 +1,23 @@
1
+ import type { EntryDoc } from "../../common/types.js";
2
+ import type { ProcessingEntry } from "./journalRecords.js";
3
+ type ChunkReadRow = {
4
+ id?: string;
5
+ error?: string;
6
+ };
7
+ type WriteResult = PouchDB.Core.Error | PouchDB.Core.Response;
8
+ export declare function partitionJournalDocuments(entries: readonly ProcessingEntry[]): {
9
+ chunks: ProcessingEntry[];
10
+ docs: ProcessingEntry[];
11
+ };
12
+ /** Chunks have stable content, so only absent identifiers need a new local revision. */
13
+ export declare function selectChunksToSave(chunks: readonly ProcessingEntry[], rows: readonly ChunkReadRow[]): PouchDB.Core.PutDocument<EntryDoc>[];
14
+ export declare function hasJournalWriteErrors(results: readonly WriteResult[]): boolean;
15
+ /** Report persisted Chunks even when another row failed, but require every requested Chunk for completion. */
16
+ export declare function assessChunkWrites<T extends PouchDB.Core.PutDocument<EntryDoc>>(chunks: readonly T[], results: readonly WriteResult[]): {
17
+ persisted: T[];
18
+ failed: boolean;
19
+ };
20
+ export declare function journalMetadataRevisions(docs: readonly ProcessingEntry[]): PouchDB.Core.RevisionDiffOptions;
21
+ /** Retain complete transferred Metadata, including revision ancestry, for each missing revision. */
22
+ export declare function selectMetadataToSave(docs: readonly ProcessingEntry[], diff: PouchDB.Core.RevisionDiffResponse): ProcessingEntry[];
23
+ export {};
@@ -0,0 +1,44 @@
1
+ function partitionJournalDocuments(entries) {
2
+ const chunks = [];
3
+ const docs = [];
4
+ for (const entry of entries) {
5
+ (entry._id.startsWith("h:") ? chunks : docs).push(entry);
6
+ }
7
+ return { chunks, docs };
8
+ }
9
+ function selectChunksToSave(chunks, rows) {
10
+ if (rows.some((row) => "error" in row && row.error !== "not_found")) {
11
+ throw new Error("Could not inspect existing Chunks");
12
+ }
13
+ const existingIDs = new Set(rows.map((row) => row.id).filter((id) => id !== void 0));
14
+ return chunks.filter((entry) => !existingIDs.has(entry._id)).map((entry) => ({ ...entry, _rev: void 0 }));
15
+ }
16
+ function hasJournalWriteErrors(results) {
17
+ return results.some((row) => "error" in row);
18
+ }
19
+ function assessChunkWrites(chunks, results) {
20
+ const persistedIDs = new Set(results.filter((row) => !("error" in row)).map((row) => row.id));
21
+ return {
22
+ persisted: chunks.filter((entry) => persistedIDs.has(entry._id)),
23
+ failed: hasJournalWriteErrors(results) || chunks.some((entry) => !persistedIDs.has(entry._id))
24
+ };
25
+ }
26
+ function journalMetadataRevisions(docs) {
27
+ return docs.reduce(
28
+ (revisions, entry) => ({ ...revisions, [entry._id]: [...revisions[entry._id] ?? [], entry._rev] }),
29
+ {}
30
+ );
31
+ }
32
+ function selectMetadataToSave(docs, diff) {
33
+ return docs.filter(
34
+ (entry) => entry._id in diff && "missing" in diff[entry._id] && (diff[entry._id].missing?.indexOf(entry._rev) ?? 0) !== -1
35
+ );
36
+ }
37
+ export {
38
+ assessChunkWrites,
39
+ hasJournalWriteErrors,
40
+ journalMetadataRevisions,
41
+ partitionJournalDocuments,
42
+ selectChunksToSave,
43
+ selectMetadataToSave
44
+ };
@@ -0,0 +1,10 @@
1
+ import { type EntryDoc } from "../../common/types.js";
2
+ export type ProcessingEntry = PouchDB.Core.PutDocument<EntryDoc> & PouchDB.Core.GetMeta;
3
+ export declare function serializeDoc(doc: EntryDoc): Uint8Array;
4
+ export declare function decodeJournalRecords(data: Uint8Array): {
5
+ ok: true;
6
+ docs: ProcessingEntry[];
7
+ } | {
8
+ ok: false;
9
+ stage: "decode" | "parse";
10
+ };
@@ -0,0 +1,96 @@
1
+ import {} from "../../common/types.js";
2
+ import { escapeNewLineFromString, unescapeNewLineFromString } from "../../common/utils.js";
3
+ const RECORD_SPLIT = `
4
+ `;
5
+ const UNIT_SPLIT = ``;
6
+ const te = new TextEncoder();
7
+ function parseJournalRecord(value) {
8
+ const parsed = JSON.parse(value);
9
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
10
+ throw new TypeError("Invalid Journal record");
11
+ }
12
+ const record = parsed;
13
+ if (typeof record._id !== "string" || record._id.length === 0) {
14
+ throw new TypeError("Invalid Journal document identifier");
15
+ }
16
+ if (record._id.startsWith("h:")) {
17
+ if (record._id.length <= 2 || typeof record._rev !== "string" || typeof record.data !== "string") {
18
+ throw new TypeError("Invalid Journal Chunk record");
19
+ }
20
+ } else if (typeof record._rev !== "string" || record._rev.length === 0) {
21
+ throw new TypeError("Invalid Journal Metadata revision");
22
+ }
23
+ return parsed;
24
+ }
25
+ function decodeCompactJournalRecord(value) {
26
+ const separatorIndex = value.indexOf(UNIT_SPLIT);
27
+ if (separatorIndex === -1) {
28
+ return { ok: false, stage: "parse" };
29
+ }
30
+ const idPart = value.substring(1, separatorIndex);
31
+ if (!idPart.startsWith("h:") || idPart.length <= 2) {
32
+ return { ok: false, stage: "parse" };
33
+ }
34
+ const dataPart = value.substring(separatorIndex + 1);
35
+ return {
36
+ ok: true,
37
+ doc: {
38
+ _id: idPart,
39
+ data: unescapeNewLineFromString(dataPart),
40
+ type: "leaf",
41
+ _rev: ""
42
+ }
43
+ };
44
+ }
45
+ function decodeJournalRecordFrame(frame, decoder) {
46
+ let value;
47
+ try {
48
+ value = decoder.decode(frame);
49
+ } catch {
50
+ return { ok: false, stage: "decode" };
51
+ }
52
+ if (value.startsWith("~")) {
53
+ return decodeCompactJournalRecord(value);
54
+ }
55
+ try {
56
+ return { ok: true, doc: parseJournalRecord(value) };
57
+ } catch {
58
+ return { ok: false, stage: "parse" };
59
+ }
60
+ }
61
+ function serializeDoc(doc) {
62
+ if (doc._id.startsWith("h:")) {
63
+ const data = doc.data;
64
+ const writeData = escapeNewLineFromString(data);
65
+ return te.encode(`~${doc._id}${UNIT_SPLIT}${writeData}${RECORD_SPLIT}`);
66
+ }
67
+ return te.encode(JSON.stringify(doc) + RECORD_SPLIT);
68
+ }
69
+ function decodeJournalRecords(data) {
70
+ if (data.length === 0) {
71
+ return { ok: true, docs: [] };
72
+ }
73
+ if (data[data.length - 1] !== 10) {
74
+ return { ok: false, stage: "parse" };
75
+ }
76
+ const decoder = new TextDecoder("utf-8", { fatal: true });
77
+ let idxFrom = 0;
78
+ const result = [];
79
+ while (idxFrom < data.length) {
80
+ const idxTo = data.indexOf(10, idxFrom);
81
+ if (idxTo === -1 || idxTo === idxFrom) {
82
+ return { ok: false, stage: "parse" };
83
+ }
84
+ const record = decodeJournalRecordFrame(data.slice(idxFrom, idxTo), decoder);
85
+ if (record.ok === false) {
86
+ return record;
87
+ }
88
+ result.push(record.doc);
89
+ idxFrom = idxTo + 1;
90
+ }
91
+ return { ok: true, docs: result };
92
+ }
93
+ export {
94
+ decodeJournalRecords,
95
+ serializeDoc
96
+ };
@@ -2,7 +2,7 @@
2
2
  date: 2026-11-03
3
3
  commonlib-version: "0.3.0"
4
4
  self-hosted-livesync-version: "1.0.44"
5
- status: accepted-unreleased
5
+ status: published
6
6
  ---
7
7
 
8
8
  # Architectural Decision Record: Shared Storage Path Resolution
@@ -16,7 +16,9 @@ lifecycle. The [design](../design_docs/storage_path_resolution.md) defines the
16
16
  accepted contract and remaining adoption work.
17
17
 
18
18
  Accepted on 2026-11-03 against the versions recorded above. The implementation
19
- is unreleased. Publication does not by itself enable NFC in WebApp or Bridge.
19
+ is published in [Commonlib 0.3.1](https://www.npmjs.com/package/@vrtmrz/livesync-commonlib/v/0.3.1).
20
+ The original date and reviewed versions retain the accepted baseline.
21
+ Publication does not by itself enable NFC in WebApp or Bridge.
20
22
 
21
23
  ## Context
22
24
 
@@ -2,7 +2,7 @@
2
2
  date: 2026-11-03
3
3
  commonlib-version: "0.3.0"
4
4
  self-hosted-livesync-version: "1.0.44"
5
- status: accepted-unreleased
5
+ status: published
6
6
  ---
7
7
 
8
8
  # Shared storage path resolution
@@ -10,8 +10,8 @@ status: accepted-unreleased
10
10
  This design moves the CLI's existing Unicode-aware path resolution into
11
11
  Commonlib so Node, File System Access, and Bridge can apply the same naming
12
12
  rules. The shared resolver, path types, and Node and File System Access
13
- integrations are implemented as unreleased additions. Installing the package
14
- does not enable NFC in an existing host or migrate its database.
13
+ integrations are published in Commonlib 0.3.1. Installing the package does
14
+ not enable NFC in an existing host or migrate its database.
15
15
 
16
16
  Accepted on 2026-11-03. The [decision record](../adr/2026_11_shared_storage_path_resolution.md)
17
17
  records the ownership and compatibility choices. The [platform guide](../platform-storage.md#unicode-path-resolution)
@@ -258,10 +258,11 @@ of state, including hosts which report them as deletion and creation.
258
258
  ## Current adoption and publication status
259
259
 
260
260
  The shared `/storage` contract, Node and File System Access adapters, and
261
- explicit `DirectFileManipulator` path-version option are unreleased additions
262
- after the published Commonlib `0.3.0` artefact. They require a new immutable
263
- package version; a source checkout with version string `0.3.0` is not the
264
- registry artefact.
261
+ explicit `DirectFileManipulator` path-version option are published in
262
+ [Commonlib 0.3.1](https://www.npmjs.com/package/@vrtmrz/livesync-commonlib/v/0.3.1).
263
+ The original decision date and reviewed versions above retain the accepted
264
+ baseline. These additions are absent from the registry artefact `0.3.0`;
265
+ a source checkout carrying that version label is not the published artefact.
265
266
 
266
267
  The CLI candidate uses the shared Node resolver, and the current Obsidian
267
268
  candidate uses shared namespace and comparison helpers while retaining Vault
@@ -21,8 +21,8 @@ Files elsewhere under `src` are implementation details unless a focused entry re
21
21
 
22
22
  ## Storage path boundary
23
23
 
24
- The unreleased `/storage` entry owns namespace helpers, comparison keys, and
25
- strict component resolution. The [design](design_docs/storage_path_resolution.md)
24
+ The `/storage` entry, published in Commonlib 0.3.1, owns namespace helpers,
25
+ comparison keys, and strict component resolution. The [design](design_docs/storage_path_resolution.md)
26
26
  and [decision record](adr/2026_11_shared_storage_path_resolution.md) separate
27
27
  that contract from host handles, caches, watchers, and migration. See the
28
28
  [platform guide](platform-storage.md#unicode-path-resolution) for composition.
@@ -0,0 +1,66 @@
1
+ ---
2
+ date: 2026-11-04
3
+ commonlib-version: "0.3.1"
4
+ self-hosted-livesync-version: "1.0.44"
5
+ status: unreleased
6
+ ---
7
+
8
+ # Journal receive failures and recovery
9
+
10
+ This document describes unreleased receive behaviour. Hosts choose when to
11
+ offer the manual skip operation and remain responsible for Vault reflection
12
+ and conflict handling.
13
+
14
+ ## Stop and retry by default
15
+
16
+ A per-Journal decode, record-processing, or write failure stops receipt. The
17
+ failed Journal and all later Journals remain unacknowledged. The next normal
18
+ receive attempt retries the failed Journal before proceeding to later Journals.
19
+ Partial progress does not change this rule.
20
+
21
+ Chunks are written before Metadata, so a Chunk failure prevents that Journal's
22
+ Metadata stage. Metadata retains its received revision ancestry through
23
+ `new_edits: false`, whose empty success result is valid. Successfully written
24
+ records remain in the database when another record fails. There is no rollback.
25
+
26
+ A retry avoids rewriting existing records and delivers accepted Metadata to
27
+ the host again, including revisions saved before an earlier partial failure.
28
+ The host must tolerate repeated delivery. Chunk-arrival events describe only
29
+ successful writes. The `knownIDs` set suppresses unnecessary
30
+ outbound transfer; it does not prove that a receive has completed locally.
31
+
32
+ Warnings identify the failed Journal key and the stage where receipt stopped,
33
+ without logging document contents. A stop request lets current I/O settle and
34
+ closes the receive operation; it is not a Journal failure.
35
+
36
+ ## Skip the stopped Journal and resume
37
+
38
+ When receipt stops on a Journal failure, a host may offer the explicit
39
+ 'Skip stopped Journal and resume' operation. It accepts the exact stopped
40
+ failure snapshot, marks only that Journal received, and resumes receipt of the
41
+ remaining Journals. If another Journal fails, receipt stops at that Journal
42
+ and requires a separate decision.
43
+
44
+ Skipping does not roll back partial writes. It can leave content or revision
45
+ history missing, and receipt of later Journals does not prove that every file
46
+ can be reconstructed or reflected to storage. Each skip acknowledges the
47
+ failed Journal despite its warning.
48
+
49
+ The stopped failure is runtime state, not a persistent skip mode or failure
50
+ queue. After a restart, normal receipt must encounter the failure again before
51
+ the host can offer a skip for it.
52
+
53
+ ## Replay and host boundaries
54
+
55
+ The existing 'Reset journal received history' operation clears `receivedFiles`
56
+ and `knownIDs`, while retaining the local database and `sentFiles`. After the
57
+ failure has been corrected, resetting this history lets receipt replay remote
58
+ Journals other than those sent by this device. Replay requires the affected
59
+ remote objects to remain available and readable; resetting history cannot
60
+ recreate missing remote bytes.
61
+
62
+ A host operation which replaces the local database, such as CLI Fetch, has
63
+ separate reconstruction and completion rules. Repeating Fetch is not the
64
+ same-database retry described above. Journal receipt also remains separate
65
+ from Vault reflection and conflict handling; hosts must preserve their own
66
+ failure and content-protection checks.
@@ -63,8 +63,8 @@ The File System Access API does not expose portable creation times or permit set
63
63
 
64
64
  ## Unicode path resolution
65
65
 
66
- The following APIs are unreleased additions after Commonlib 0.3.0. Node and
67
- File System Access hosts can use the same NFC comparison and component
66
+ These APIs are published in [Commonlib 0.3.1](https://www.npmjs.com/package/@vrtmrz/livesync-commonlib/v/0.3.1).
67
+ Node and File System Access hosts can use the same NFC comparison and component
68
68
  resolution while retaining the names listed by their filesystem. Hosts still
69
69
  own the selected identity mode and its database migration.
70
70
 
@@ -109,11 +109,13 @@ the package protocol support alone does not mean those host integrations have
109
109
  shipped.
110
110
 
111
111
  The shared `/storage` resolver, Node and File System Access adapters, and
112
- explicit `DirectFileManipulator` path-version option are later unpublished
113
- additions described in the [shared storage path design](design_docs/storage_path_resolution.md).
114
- They are not in the registry artefact `0.3.0` and require a new immutable
115
- Commonlib package version. A source checkout carrying the same version string
116
- does not make these additions part of the published artefact.
112
+ explicit `DirectFileManipulator` path-version option are published in
113
+ [Commonlib 0.3.1](https://www.npmjs.com/package/@vrtmrz/livesync-commonlib/v/0.3.1),
114
+ as described in the [shared storage path design](design_docs/storage_path_resolution.md).
115
+ They are absent from the registry artefact `0.3.0`; select the new immutable
116
+ version rather than a source checkout carrying an older version label. Shared
117
+ path adoption remains in [LiveSync #1315](https://github.com/vrtmrz/obsidian-livesync/pull/1315)
118
+ and [Bridge #92](https://github.com/vrtmrz/livesync-bridge/pull/92).
117
119
 
118
120
  | Consumer surface | Published baseline | Current candidate status |
119
121
  | --- | --- | --- |
@@ -122,7 +124,7 @@ does not make these additions part of the published artefact.
122
124
  | Bridge | Legacy version `0` remains the default baseline. | The Bridge candidate enables version `1` only by explicit peer opt-in; every peer in a group must use the same path version. Its ordinary case-mismatch check remains separate from the required version check. |
123
125
  | WebApp | Legacy version `0` only. | WebApp remains Legacy-only; an explicit version `1` is rejected at startup. The browser storage primitive alone does not enable application support. |
124
126
  | WebPeer | A vaultless P2P utility. | It is not an NFC filesystem host. |
125
- | `DirectFileManipulator` | The published package rejects a remote declaring the NFC feature. | The unpublished option defaults to version `0`, accepts explicit `0` or `1`, and rejects a populated database in the opposite mode. The caller still owns physical path handling. |
127
+ | `DirectFileManipulator` | Commonlib `0.3.1` supports explicit version `1`; omitted or `0` retains Legacy identity. A populated database in the opposite mode is rejected. | The caller still owns physical path handling and coordinated migration. |
126
128
 
127
129
  `PathService` owns logical ID derivation. In the unreleased LiveSync host
128
130
  candidate, the CLI resolves NFC-equivalent names against directory entries
@@ -172,8 +174,7 @@ checkpoints; the P2P source mark and target checkpoint handle database
172
174
  replacement. These operations do not rewrite IDs or merge histories in place.
173
175
  P2P retains its existing source selection during Fetch.
174
176
 
175
- Published Commonlib direct file access refuses a remote which declares the NFC
176
- feature. The unpublished `DirectFileManipulator` addition exposes explicit
177
+ Commonlib 0.3.1 `DirectFileManipulator` exposes explicit
177
178
  `pathNormalizationVersion` options, defaulting to `0`: missing or `0` retains
178
179
  Legacy and rejects an NFC database; `1` accepts NFC and rejects a populated
179
180
  Legacy database before feature negotiation can change its declaration. A
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vrtmrz/livesync-commonlib",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Shared data, storage, and replication primitives for Self-hosted LiveSync clients.",
5
5
  "type": "module",
6
6
  "types": "./dist/index.d.ts",