@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.
- package/README.md +12 -2
- package/dist/replication/SyncParamsHandler.d.ts +1 -0
- package/dist/replication/SyncParamsHandler.js +5 -1
- package/dist/replication/journal/JournalReceiveFailure.d.ts +7 -0
- package/dist/replication/journal/JournalReceiveFailure.js +16 -0
- package/dist/replication/journal/JournalSyncCore.d.ts +22 -3
- package/dist/replication/journal/JournalSyncCore.js +288 -166
- package/dist/replication/journal/JournalSyncTypes.d.ts +13 -0
- package/dist/replication/journal/LiveSyncJournalReplicator.d.ts +6 -1
- package/dist/replication/journal/LiveSyncJournalReplicator.js +31 -44
- package/dist/replication/journal/journalCompatibility.d.ts +9 -0
- package/dist/replication/journal/journalCompatibility.js +58 -0
- package/dist/replication/journal/journalReceive.d.ts +23 -0
- package/dist/replication/journal/journalReceive.js +44 -0
- package/dist/replication/journal/journalRecords.d.ts +10 -0
- package/dist/replication/journal/journalRecords.js +96 -0
- package/docs/adr/2026_11_shared_storage_path_resolution.md +4 -2
- package/docs/design_docs/storage_path_resolution.md +8 -7
- package/docs/development.md +2 -2
- package/docs/journal-receive-recovery.md +66 -0
- package/docs/platform-storage.md +2 -2
- package/docs/unicode-path-identity.md +9 -8
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
93
|
-
|
|
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
|
|
148
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
14
|
-
|
|
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
|
|
262
|
-
|
|
263
|
-
|
|
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
|
package/docs/development.md
CHANGED
|
@@ -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
|
|
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.
|
package/docs/platform-storage.md
CHANGED
|
@@ -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
|
-
|
|
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
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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` |
|
|
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
|
-
|
|
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