@graview/core 0.1.57 → 0.1.59
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 -4
- package/dist/archived-seen.d.ts.map +1 -1
- package/dist/archived-seen.js +7 -3
- package/dist/archived-seen.js.map +1 -1
- package/dist/catalog.js +1 -1
- package/dist/catalog.js.map +1 -1
- package/dist/cli/check/field-sight.d.ts.map +1 -1
- package/dist/cli/check/field-sight.js +3 -1
- package/dist/cli/check/field-sight.js.map +1 -1
- package/dist/cli/document-schema.js +1 -1
- package/dist/cli/document-schema.js.map +1 -1
- package/dist/document/brand.js +3 -3
- package/dist/document/brand.js.map +1 -1
- package/dist/document/compile.d.ts.map +1 -1
- package/dist/document/compile.js +4 -0
- package/dist/document/compile.js.map +1 -1
- package/dist/document/compiled.d.ts +2 -0
- package/dist/document/compiled.d.ts.map +1 -1
- package/dist/document/compiled.js +22 -1
- package/dist/document/compiled.js.map +1 -1
- package/dist/document/connection-findings.d.ts.map +1 -1
- package/dist/document/connection-findings.js +189 -25
- package/dist/document/connection-findings.js.map +1 -1
- package/dist/document/connection-spec.d.ts +42 -2
- package/dist/document/connection-spec.d.ts.map +1 -1
- package/dist/document/connection-spec.js +64 -19
- package/dist/document/connection-spec.js.map +1 -1
- package/dist/document/kept-in-step.d.ts +7 -0
- package/dist/document/kept-in-step.d.ts.map +1 -0
- package/dist/document/kept-in-step.js +28 -0
- package/dist/document/kept-in-step.js.map +1 -0
- package/dist/document/schema.d.ts +5 -5
- package/dist/document.schema.json +243 -98
- package/dist/field-sight.js +4 -2
- package/dist/field-sight.js.map +1 -1
- package/dist/fresh.d.ts +26 -0
- package/dist/fresh.d.ts.map +1 -0
- package/dist/fresh.js +33 -0
- package/dist/fresh.js.map +1 -0
- package/dist/friction.d.ts +7 -0
- package/dist/friction.d.ts.map +1 -1
- package/dist/friction.js +2 -1
- package/dist/friction.js.map +1 -1
- package/dist/import/rows.d.ts.map +1 -1
- package/dist/import/rows.js +20 -10
- package/dist/import/rows.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/linked.d.ts.map +1 -1
- package/dist/linked.js +7 -1
- package/dist/linked.js.map +1 -1
- package/dist/mutations/define-mutation.d.ts +14 -0
- package/dist/mutations/define-mutation.d.ts.map +1 -1
- package/dist/mutations/define-mutation.js +9 -4
- package/dist/mutations/define-mutation.js.map +1 -1
- package/dist/mutations/derive-edits.d.ts +4 -2
- package/dist/mutations/derive-edits.d.ts.map +1 -1
- package/dist/mutations/derive-edits.js +17 -5
- package/dist/mutations/derive-edits.js.map +1 -1
- package/dist/mutations/derive-repeats.d.ts +8 -0
- package/dist/mutations/derive-repeats.d.ts.map +1 -1
- package/dist/mutations/derive-repeats.js +29 -4
- package/dist/mutations/derive-repeats.js.map +1 -1
- package/dist/mutations/type-text.d.ts +6 -1
- package/dist/mutations/type-text.d.ts.map +1 -1
- package/dist/mutations/type-text.js +8 -1
- package/dist/mutations/type-text.js.map +1 -1
- package/dist/mutations/types.d.ts +10 -0
- package/dist/mutations/types.d.ts.map +1 -1
- package/dist/ops/lineage.d.ts +12 -1
- package/dist/ops/lineage.d.ts.map +1 -1
- package/dist/ops/lineage.js +9 -2
- package/dist/ops/lineage.js.map +1 -1
- package/dist/ops/log.d.ts +23 -0
- package/dist/ops/log.d.ts.map +1 -1
- package/dist/ops/log.js +7 -0
- package/dist/ops/log.js.map +1 -1
- package/dist/ops/undo.d.ts.map +1 -1
- package/dist/ops/undo.js +9 -0
- package/dist/ops/undo.js.map +1 -1
- package/dist/permissions/sight.d.ts +8 -0
- package/dist/permissions/sight.d.ts.map +1 -1
- package/dist/permissions/sight.js +8 -0
- package/dist/permissions/sight.js.map +1 -1
- package/dist/publish.d.ts +7 -2
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js.map +1 -1
- package/dist/publishing.d.ts +4 -1
- package/dist/publishing.d.ts.map +1 -1
- package/dist/publishing.js +59 -14
- package/dist/publishing.js.map +1 -1
- package/dist/scaffold/from-template.d.ts.map +1 -1
- package/dist/scaffold/from-template.js +4 -3
- package/dist/scaffold/from-template.js.map +1 -1
- package/dist/scaffold/names.d.ts +10 -0
- package/dist/scaffold/names.d.ts.map +1 -1
- package/dist/scaffold/names.js +20 -1
- package/dist/scaffold/names.js.map +1 -1
- package/dist/scaffold/ui.d.ts.map +1 -1
- package/dist/scaffold/ui.js +9 -8
- package/dist/scaffold/ui.js.map +1 -1
- package/dist/schema/figures.d.ts +9 -2
- package/dist/schema/figures.d.ts.map +1 -1
- package/dist/schema/figures.js +38 -9
- package/dist/schema/figures.js.map +1 -1
- package/dist/schema/linked.d.ts +7 -1
- package/dist/schema/linked.d.ts.map +1 -1
- package/dist/schema/linked.js.map +1 -1
- package/dist/schema/types.d.ts +10 -0
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/seen.d.ts.map +1 -1
- package/dist/seen.js +21 -2
- package/dist/seen.js.map +1 -1
- package/dist/store.d.ts +29 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +158 -31
- package/dist/store.js.map +1 -1
- package/dist/subscriptions.d.ts +12 -0
- package/dist/subscriptions.d.ts.map +1 -1
- package/dist/subscriptions.js +10 -1
- package/dist/subscriptions.js.map +1 -1
- package/dist/sync/connection-filter.d.ts +2 -2
- package/dist/sync/connection-filter.d.ts.map +1 -1
- package/dist/sync/connection-filter.js.map +1 -1
- package/dist/sync/connection.d.ts +72 -28
- package/dist/sync/connection.d.ts.map +1 -1
- package/dist/sync/connection.js +255 -43
- package/dist/sync/connection.js.map +1 -1
- package/dist/sync/creating-act.d.ts +20 -0
- package/dist/sync/creating-act.d.ts.map +1 -0
- package/dist/sync/creating-act.js +29 -0
- package/dist/sync/creating-act.js.map +1 -0
- package/dist/sync/engine.d.ts +83 -1
- package/dist/sync/engine.d.ts.map +1 -1
- package/dist/sync/engine.js +269 -32
- package/dist/sync/engine.js.map +1 -1
- package/dist/sync/google-calendar.d.ts +3 -0
- package/dist/sync/google-calendar.d.ts.map +1 -1
- package/dist/sync/index.d.ts +10 -4
- package/dist/sync/index.d.ts.map +1 -1
- package/dist/sync/index.js +6 -2
- package/dist/sync/index.js.map +1 -1
- package/dist/sync/mcp.d.ts +8 -0
- package/dist/sync/mcp.d.ts.map +1 -1
- package/dist/sync/mcp.js +2 -0
- package/dist/sync/mcp.js.map +1 -1
- package/dist/sync/notion-stub.d.ts +81 -0
- package/dist/sync/notion-stub.d.ts.map +1 -0
- package/dist/sync/notion-stub.js +467 -0
- package/dist/sync/notion-stub.js.map +1 -0
- package/dist/sync/notion-types.d.ts +23 -0
- package/dist/sync/notion-types.d.ts.map +1 -0
- package/dist/sync/notion-types.js +49 -0
- package/dist/sync/notion-types.js.map +1 -0
- package/dist/sync/notion.d.ts +98 -0
- package/dist/sync/notion.d.ts.map +1 -0
- package/dist/sync/notion.js +477 -0
- package/dist/sync/notion.js.map +1 -0
- package/dist/sync/types.d.ts +38 -0
- package/dist/sync/types.d.ts.map +1 -1
- package/dist/sync/types.js.map +1 -1
- package/dist/text/typing-hook.d.ts +2 -2
- package/dist/text/typing-hook.d.ts.map +1 -1
- package/dist/theme/fonts.d.ts +10 -0
- package/dist/theme/fonts.d.ts.map +1 -1
- package/dist/theme/fonts.js +25 -1
- package/dist/theme/fonts.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/views/kept.d.ts.map +1 -1
- package/dist/views/kept.js +3 -0
- package/dist/views/kept.js.map +1 -1
- package/package.json +1 -1
package/dist/sync/engine.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { PlannedChange, Store } from "../store.js";
|
|
2
2
|
import type { AnySchema } from "../schema/schema.js";
|
|
3
|
-
import
|
|
3
|
+
import type { MutationCall } from "../mutations/types.js";
|
|
4
|
+
import { type ConflictPolicy, type LinkResolver, type RemoteSystem, type RemoteWrite, type SyncConflict, type SyncMapping, type SyncState } from "./types.js";
|
|
4
5
|
/** A conflict the engine's policy settled, and the side it took. */
|
|
5
6
|
export type SettledConflict = SyncConflict & {
|
|
6
7
|
readonly took: "ours" | "theirs";
|
|
@@ -41,6 +42,12 @@ export interface SyncReport {
|
|
|
41
42
|
/** Records the remote deleted: removed here through `removeInbound`, or, without one, kept here and no longer synced. */
|
|
42
43
|
readonly gone: readonly string[];
|
|
43
44
|
readonly offline: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* What the remote said when it could not be reached, in its transport's
|
|
47
|
+
* words — a secret it refused, a database not shared — so a host says
|
|
48
|
+
* why rather than only that. Absent when the run reached it.
|
|
49
|
+
*/
|
|
50
|
+
readonly problem?: string;
|
|
44
51
|
readonly state: SyncState;
|
|
45
52
|
}
|
|
46
53
|
/**
|
|
@@ -58,6 +65,8 @@ export interface SyncRefusal {
|
|
|
58
65
|
readonly id?: string;
|
|
59
66
|
}
|
|
60
67
|
export type { SyncConflict } from "./types.js";
|
|
68
|
+
/** A relation's value as both sides compare it: the remote ids it reaches, once each, sorted; none is `null`. */
|
|
69
|
+
export declare function edgeValue(ids: unknown): string[] | null;
|
|
61
70
|
export interface SyncOptions<S extends AnySchema> {
|
|
62
71
|
readonly store: Store<S>;
|
|
63
72
|
readonly mapping: SyncMapping;
|
|
@@ -110,7 +119,43 @@ export interface SyncOptions<S extends AnySchema> {
|
|
|
110
119
|
readonly name: string;
|
|
111
120
|
readonly args: Record<string, unknown>;
|
|
112
121
|
} | null;
|
|
122
|
+
/**
|
|
123
|
+
* WHERE A RECORD A RELATION REACHES IS FOUND, by either id: every engine
|
|
124
|
+
* of the app when its relations reach records another engine keeps
|
|
125
|
+
* (`syncLinks`). Absent: this engine's own links.
|
|
126
|
+
*/
|
|
127
|
+
readonly links?: LinkResolver;
|
|
128
|
+
/** The act that links a record to another, for a relation the remote linked (`connect-<kind>-<edge>`). Required for a mapping with `edges`. */
|
|
129
|
+
connectInbound?(call: {
|
|
130
|
+
readonly localId: string;
|
|
131
|
+
readonly kind: string;
|
|
132
|
+
readonly edge: string;
|
|
133
|
+
readonly to: string;
|
|
134
|
+
}): MutationCall | null;
|
|
135
|
+
/** The act that unlinks them, for a link the remote took away (`disconnect-<kind>-<edge>`). Absent: the link stays here. */
|
|
136
|
+
disconnectInbound?(call: {
|
|
137
|
+
readonly localId: string;
|
|
138
|
+
readonly kind: string;
|
|
139
|
+
readonly edge: string;
|
|
140
|
+
readonly to: string;
|
|
141
|
+
}): MutationCall | null;
|
|
113
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* ONE PLACE TO FIND A RECORD BY EITHER ID, over every engine added to it:
|
|
145
|
+
* what an app's engines share when their relations reach each other's
|
|
146
|
+
* records (one Notion connection per table, each table's relations to
|
|
147
|
+
* another's pages). Read live, so a record another engine linked a moment
|
|
148
|
+
* ago is found; an id is matched as written, then as Notion writes one
|
|
149
|
+
* (hyphenated, lower case).
|
|
150
|
+
*/
|
|
151
|
+
export interface SharedLinks extends LinkResolver {
|
|
152
|
+
/** Adds an engine whose links are looked in. */
|
|
153
|
+
add(engine: {
|
|
154
|
+
linkOf(localId: string): string | undefined;
|
|
155
|
+
linkedTo(id: string): string | undefined;
|
|
156
|
+
}): void;
|
|
157
|
+
}
|
|
158
|
+
export declare function sharedLinks(): SharedLinks;
|
|
114
159
|
/**
|
|
115
160
|
* Two-way sync, on top of the op log rather than beside it.
|
|
116
161
|
*
|
|
@@ -131,6 +176,8 @@ export interface SyncOptions<S extends AnySchema> {
|
|
|
131
176
|
export declare class SyncEngine<S extends AnySchema> {
|
|
132
177
|
private state;
|
|
133
178
|
private readonly options;
|
|
179
|
+
private readonly resolver;
|
|
180
|
+
private reverse?;
|
|
134
181
|
constructor(options: SyncOptions<S>, state?: SyncState);
|
|
135
182
|
get snapshot(): SyncState;
|
|
136
183
|
private get policy();
|
|
@@ -143,6 +190,41 @@ export declare class SyncEngine<S extends AnySchema> {
|
|
|
143
190
|
* an act is even offered.
|
|
144
191
|
*/
|
|
145
192
|
refuses(kind: string): string | undefined;
|
|
193
|
+
/** The remote's id of a record this engine linked. */
|
|
194
|
+
linkOf(localId: string): string | undefined;
|
|
195
|
+
/** Our id of the remote's record, if this engine linked it. */
|
|
196
|
+
linkedTo(id: string): string | undefined;
|
|
197
|
+
/**
|
|
198
|
+
* A NODE'S VALUES AS THE REMOTE IS COMPARED WITH: its mapped fields, and
|
|
199
|
+
* each mapped relation as the remote ids of the records it reaches — those
|
|
200
|
+
* linked, and those held until they are — sorted. A record it reaches that
|
|
201
|
+
* no engine has linked yet (made here, not sent) is not in it until it is.
|
|
202
|
+
*/
|
|
203
|
+
private valuesOf;
|
|
204
|
+
/**
|
|
205
|
+
* THE ACTS ONE CHANGE COMING IN LANDS THROUGH, in one batch: the record
|
|
206
|
+
* made (`create`) or its fields edited (`applyInbound`), then each of its
|
|
207
|
+
* relations brought to what the remote says — links it lost taken away
|
|
208
|
+
* first, so a relation of one is never refused for holding two — and the
|
|
209
|
+
* remote ids no engine has linked yet, to hold.
|
|
210
|
+
*/
|
|
211
|
+
private inboundCalls;
|
|
212
|
+
/** Keeps what a landed change left to link, by our id and edge. */
|
|
213
|
+
private hold;
|
|
214
|
+
/**
|
|
215
|
+
* LANDS ONE CHANGE COMING IN through its acts, authored by the remote's
|
|
216
|
+
* system, and holds the links it could not make yet. Undefined when this
|
|
217
|
+
* store refused it (said in `refused`, or thrown without it).
|
|
218
|
+
*/
|
|
219
|
+
private landInbound;
|
|
220
|
+
/**
|
|
221
|
+
* LINKS WHAT WAS HELD, now that its records are linked — by this engine
|
|
222
|
+
* or another of the app's. Nothing is sent for it: the relation's value
|
|
223
|
+
* already held those ids. A host running several engines calls this on
|
|
224
|
+
* each after a pass, so the order they ran in does not matter; `run`
|
|
225
|
+
* calls it too. Returns the records it linked.
|
|
226
|
+
*/
|
|
227
|
+
resolveHeld(refused?: SyncRefusal[]): string[];
|
|
146
228
|
/** Every node that participates in the sync, by our id. */
|
|
147
229
|
private participants;
|
|
148
230
|
/** A link for every participant whose mapping names its version field and that carries one. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../../src/sync/engine.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAgB,aAAa,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AACtE,OAAO,KAAK,EAAE,SAAS,EAAgB,MAAM,qBAAqB,CAAC;
|
|
1
|
+
{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../../src/sync/engine.ts"],"names":[],"mappings":"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"}
|
package/dist/sync/engine.js
CHANGED
|
@@ -3,6 +3,13 @@ import { PermissionDeniedError } from "../permissions/types.js";
|
|
|
3
3
|
import { seesId } from "../seen.js";
|
|
4
4
|
import { NewerFormatError } from "../format-error.js";
|
|
5
5
|
import { EMPTY_SYNC_STATE, SYNC_STATE_FORMAT, systemAuthor, } from "./types.js";
|
|
6
|
+
/** Everything a mapping keeps in step, its fields and its relations: ours → theirs. */
|
|
7
|
+
const keptOf = (mapping) => (mapping.edges ? { ...mapping.fields, ...mapping.edges } : mapping.fields);
|
|
8
|
+
/** A relation's value as both sides compare it: the remote ids it reaches, once each, sorted; none is `null`. */
|
|
9
|
+
export function edgeValue(ids) {
|
|
10
|
+
const list = Array.isArray(ids) ? [...new Set(ids.filter((id) => typeof id === "string" && id !== ""))].sort() : typeof ids === "string" && ids !== "" ? [ids] : [];
|
|
11
|
+
return list.length > 0 ? list : null;
|
|
12
|
+
}
|
|
6
13
|
/** Reads a node's mapped values, in OUR field names. */
|
|
7
14
|
function mappedValues(node, mapping) {
|
|
8
15
|
const values = {};
|
|
@@ -19,19 +26,20 @@ function mappedValues(node, mapping) {
|
|
|
19
26
|
*/
|
|
20
27
|
function ourFields(remote, mapping, all = false) {
|
|
21
28
|
const values = {};
|
|
22
|
-
for (const [ours, theirs] of Object.entries(mapping
|
|
29
|
+
for (const [ours, theirs] of Object.entries(keptOf(mapping))) {
|
|
23
30
|
if (!all && mapping.ours?.includes(ours))
|
|
24
31
|
continue;
|
|
25
32
|
if (!(theirs in remote))
|
|
26
33
|
continue;
|
|
27
34
|
const decode = mapping.decode?.[ours];
|
|
28
|
-
|
|
35
|
+
const value = decode ? decode(remote[theirs]) : remote[theirs];
|
|
36
|
+
values[ours] = mapping.edges && ours in mapping.edges ? edgeValue(value) : value;
|
|
29
37
|
}
|
|
30
38
|
return values;
|
|
31
39
|
}
|
|
32
40
|
function remoteFields(values, mapping, node = values) {
|
|
33
41
|
const out = {};
|
|
34
|
-
for (const [ours, theirs] of Object.entries(mapping
|
|
42
|
+
for (const [ours, theirs] of Object.entries(keptOf(mapping))) {
|
|
35
43
|
if (!(ours in values))
|
|
36
44
|
continue;
|
|
37
45
|
const encode = mapping.encode?.[ours];
|
|
@@ -43,6 +51,36 @@ const same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
|
|
43
51
|
/** Where a conflict is held: by node and field, and a record deleted here by node alone. */
|
|
44
52
|
const heldKey = (localId, field = "") => `${localId}\u0000${field}`;
|
|
45
53
|
const keyOf = (conflict) => heldKey(conflict.localId, conflict.field);
|
|
54
|
+
const hyphenated = (id) => {
|
|
55
|
+
const hex = id.replace(/-/g, "").toLowerCase();
|
|
56
|
+
return /^[0-9a-f]{32}$/.test(hex) ? `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` : undefined;
|
|
57
|
+
};
|
|
58
|
+
export function sharedLinks() {
|
|
59
|
+
const engines = [];
|
|
60
|
+
const find = (id) => {
|
|
61
|
+
for (const engine of engines) {
|
|
62
|
+
const found = engine.linkedTo(id);
|
|
63
|
+
if (found !== undefined)
|
|
64
|
+
return found;
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
};
|
|
68
|
+
return {
|
|
69
|
+
add: (engine) => void (engines.includes(engine) || engines.push(engine)),
|
|
70
|
+
remoteId(localId) {
|
|
71
|
+
for (const engine of engines) {
|
|
72
|
+
const found = engine.linkOf(localId);
|
|
73
|
+
if (found !== undefined)
|
|
74
|
+
return found;
|
|
75
|
+
}
|
|
76
|
+
return undefined;
|
|
77
|
+
},
|
|
78
|
+
localId(id) {
|
|
79
|
+
const other = hyphenated(id);
|
|
80
|
+
return find(id) ?? (other && other !== id ? find(other) : undefined);
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
46
84
|
/**
|
|
47
85
|
* Two-way sync, on top of the op log rather than beside it.
|
|
48
86
|
*
|
|
@@ -63,8 +101,11 @@ const keyOf = (conflict) => heldKey(conflict.localId, conflict.field);
|
|
|
63
101
|
export class SyncEngine {
|
|
64
102
|
state;
|
|
65
103
|
options;
|
|
104
|
+
resolver;
|
|
105
|
+
reverse;
|
|
66
106
|
constructor(options, state = EMPTY_SYNC_STATE) {
|
|
67
107
|
this.options = options;
|
|
108
|
+
this.resolver = options.links ?? { remoteId: (localId) => this.linkOf(localId), localId: (id) => this.linkedTo(id) };
|
|
68
109
|
// A state a newer build wrote is not run on: its links may say what this build would misread (stability.md §2).
|
|
69
110
|
const format = state.format ?? 1;
|
|
70
111
|
if (!(format <= SYNC_STATE_FORMAT))
|
|
@@ -99,6 +140,165 @@ export class SyncEngine {
|
|
|
99
140
|
const mapping = this.options.mapping.resources.find((resource) => resource.kind === kind);
|
|
100
141
|
return mapping?.readonly ? this.because(mapping) : undefined;
|
|
101
142
|
}
|
|
143
|
+
/** The remote's id of a record this engine linked. */
|
|
144
|
+
linkOf(localId) {
|
|
145
|
+
return this.state?.links[localId]?.id;
|
|
146
|
+
}
|
|
147
|
+
/** Our id of the remote's record, if this engine linked it. */
|
|
148
|
+
linkedTo(id) {
|
|
149
|
+
const links = this.state?.links;
|
|
150
|
+
if (!links)
|
|
151
|
+
return undefined;
|
|
152
|
+
if (this.reverse?.links !== links)
|
|
153
|
+
this.reverse = { links, byId: new Map(Object.entries(links).map(([localId, link]) => [link.id, localId])) };
|
|
154
|
+
return this.reverse.byId.get(id);
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* A NODE'S VALUES AS THE REMOTE IS COMPARED WITH: its mapped fields, and
|
|
158
|
+
* each mapped relation as the remote ids of the records it reaches — those
|
|
159
|
+
* linked, and those held until they are — sorted. A record it reaches that
|
|
160
|
+
* no engine has linked yet (made here, not sent) is not in it until it is.
|
|
161
|
+
*/
|
|
162
|
+
valuesOf(node, mapping) {
|
|
163
|
+
const values = mappedValues(node, mapping);
|
|
164
|
+
if (!mapping.edges)
|
|
165
|
+
return values;
|
|
166
|
+
const id = node["id"];
|
|
167
|
+
const held = this.state?.unresolved?.[id];
|
|
168
|
+
for (const edge of Object.keys(mapping.edges)) {
|
|
169
|
+
const reached = this.options.store.graph.out(id, edge, { archived: "all" }).map((target) => this.resolver.remoteId(target.id));
|
|
170
|
+
values[edge] = edgeValue([...reached, ...(held?.[edge] ?? [])]);
|
|
171
|
+
}
|
|
172
|
+
return values;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* THE ACTS ONE CHANGE COMING IN LANDS THROUGH, in one batch: the record
|
|
176
|
+
* made (`create`) or its fields edited (`applyInbound`), then each of its
|
|
177
|
+
* relations brought to what the remote says — links it lost taken away
|
|
178
|
+
* first, so a relation of one is never refused for holding two — and the
|
|
179
|
+
* remote ids no engine has linked yet, to hold.
|
|
180
|
+
*/
|
|
181
|
+
inboundCalls(inbound) {
|
|
182
|
+
const { localId, mapping, values, create } = inbound;
|
|
183
|
+
const edges = mapping.edges ?? {};
|
|
184
|
+
const fields = Object.fromEntries(Object.entries(values).filter(([key]) => !(key in edges)));
|
|
185
|
+
const calls = [];
|
|
186
|
+
if (create)
|
|
187
|
+
calls.push(create);
|
|
188
|
+
else if (Object.keys(fields).length > 0)
|
|
189
|
+
calls.push(this.options.applyInbound({ localId, kind: mapping.kind, fields }));
|
|
190
|
+
const held = {};
|
|
191
|
+
const graph = this.options.store.graph;
|
|
192
|
+
for (const edge of Object.keys(edges)) {
|
|
193
|
+
if (!(edge in values))
|
|
194
|
+
continue;
|
|
195
|
+
const wanted = edgeValue(values[edge]) ?? [];
|
|
196
|
+
const reach = [];
|
|
197
|
+
held[edge] = [];
|
|
198
|
+
for (const id of wanted) {
|
|
199
|
+
const target = this.resolver.localId(id);
|
|
200
|
+
if (target === undefined)
|
|
201
|
+
held[edge].push(id);
|
|
202
|
+
// Linked, and gone here: its delete is on its way there, and the link with it.
|
|
203
|
+
else if (graph.getNode(target))
|
|
204
|
+
reach.push(target);
|
|
205
|
+
}
|
|
206
|
+
const now = create ? [] : graph.out(localId, edge, { archived: "all" }).map((target) => target.id);
|
|
207
|
+
const about = { localId, kind: mapping.kind, edge };
|
|
208
|
+
// A link to a record the remote has never seen (made here, not sent yet) is not the remote's to take away.
|
|
209
|
+
for (const to of now) {
|
|
210
|
+
if (reach.includes(to) || this.resolver.remoteId(to) === undefined)
|
|
211
|
+
continue;
|
|
212
|
+
const call = this.options.disconnectInbound?.({ ...about, to });
|
|
213
|
+
if (call)
|
|
214
|
+
calls.push(call);
|
|
215
|
+
}
|
|
216
|
+
for (const to of reach) {
|
|
217
|
+
if (now.includes(to))
|
|
218
|
+
continue;
|
|
219
|
+
const call = this.options.connectInbound?.({ ...about, to });
|
|
220
|
+
if (call)
|
|
221
|
+
calls.push(call);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return { calls, held };
|
|
225
|
+
}
|
|
226
|
+
/** Keeps what a landed change left to link, by our id and edge. */
|
|
227
|
+
hold(localId, held) {
|
|
228
|
+
if (Object.keys(held).length === 0)
|
|
229
|
+
return;
|
|
230
|
+
const was = this.state.unresolved ?? {};
|
|
231
|
+
const mine = { ...was[localId], ...held };
|
|
232
|
+
for (const edge of Object.keys(mine))
|
|
233
|
+
if (mine[edge].length === 0)
|
|
234
|
+
delete mine[edge];
|
|
235
|
+
const { [localId]: _was, ...rest } = was;
|
|
236
|
+
const unresolved = Object.keys(mine).length > 0 ? { ...rest, [localId]: mine } : rest;
|
|
237
|
+
const { unresolved: _before, ...state } = this.state;
|
|
238
|
+
this.state = { ...state, ...(Object.keys(unresolved).length > 0 ? { unresolved } : {}) };
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* LANDS ONE CHANGE COMING IN through its acts, authored by the remote's
|
|
242
|
+
* system, and holds the links it could not make yet. Undefined when this
|
|
243
|
+
* store refused it (said in `refused`, or thrown without it).
|
|
244
|
+
*/
|
|
245
|
+
landInbound(inbound, intent, about, refused) {
|
|
246
|
+
const { calls, held } = this.inboundCalls(inbound);
|
|
247
|
+
if (calls.length === 0) {
|
|
248
|
+
this.hold(inbound.localId, held);
|
|
249
|
+
return {};
|
|
250
|
+
}
|
|
251
|
+
const result = this.land(calls, intent, about, refused);
|
|
252
|
+
if (!result)
|
|
253
|
+
return undefined;
|
|
254
|
+
this.hold(inbound.localId, held);
|
|
255
|
+
return { result };
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* LINKS WHAT WAS HELD, now that its records are linked — by this engine
|
|
259
|
+
* or another of the app's. Nothing is sent for it: the relation's value
|
|
260
|
+
* already held those ids. A host running several engines calls this on
|
|
261
|
+
* each after a pass, so the order they ran in does not matter; `run`
|
|
262
|
+
* calls it too. Returns the records it linked.
|
|
263
|
+
*/
|
|
264
|
+
resolveHeld(refused) {
|
|
265
|
+
const unresolved = this.state.unresolved;
|
|
266
|
+
if (!unresolved)
|
|
267
|
+
return [];
|
|
268
|
+
const graph = this.options.store.graph;
|
|
269
|
+
const linked = [];
|
|
270
|
+
for (const [localId, edges] of Object.entries(unresolved)) {
|
|
271
|
+
const node = graph.getNode(localId);
|
|
272
|
+
const mapping = node ? this.mappingFor(node["kind"]) : undefined;
|
|
273
|
+
if (!node || !mapping?.edges) {
|
|
274
|
+
this.hold(localId, Object.fromEntries(Object.keys(edges).map((edge) => [edge, []])));
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
const calls = [];
|
|
278
|
+
const still = {};
|
|
279
|
+
for (const [edge, ids] of Object.entries(edges)) {
|
|
280
|
+
still[edge] = [];
|
|
281
|
+
for (const id of ids) {
|
|
282
|
+
const to = this.resolver.localId(id);
|
|
283
|
+
if (to === undefined) {
|
|
284
|
+
still[edge].push(id);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (!graph.getNode(to) || graph.out(localId, edge, { archived: "all" }).some((target) => target.id === to))
|
|
288
|
+
continue;
|
|
289
|
+
const call = this.options.connectInbound?.({ localId, kind: mapping.kind, edge, to });
|
|
290
|
+
if (call)
|
|
291
|
+
calls.push(call);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (calls.length > 0 && !this.land(calls, `${this.options.mapping.system}: linked`, { localId, resource: mapping.resource, id: this.linkOf(localId) ?? "" }, refused))
|
|
295
|
+
continue;
|
|
296
|
+
this.hold(localId, still);
|
|
297
|
+
if (calls.length > 0)
|
|
298
|
+
linked.push(localId);
|
|
299
|
+
}
|
|
300
|
+
return linked;
|
|
301
|
+
}
|
|
102
302
|
/** Every node that participates in the sync, by our id. */
|
|
103
303
|
participants() {
|
|
104
304
|
const graph = this.options.store.graph;
|
|
@@ -230,7 +430,7 @@ export class SyncEngine {
|
|
|
230
430
|
* BACK HERE (the delete taken back): what it holds against what the
|
|
231
431
|
* remote made of it is held field by field, never sent over the edit.
|
|
232
432
|
*/
|
|
233
|
-
const ours =
|
|
433
|
+
const ours = this.valuesOf(node, mapping);
|
|
234
434
|
for (const [field, theirs] of Object.entries(conflict.theirs)) {
|
|
235
435
|
if (same(ours[field], theirs))
|
|
236
436
|
links = { ...links, [conflict.localId]: { ...links[conflict.localId], fields: { ...links[conflict.localId].fields, [field]: theirs } } };
|
|
@@ -240,7 +440,7 @@ export class SyncEngine {
|
|
|
240
440
|
changed = true;
|
|
241
441
|
continue;
|
|
242
442
|
}
|
|
243
|
-
if (!node || conflict.deleted || !same(
|
|
443
|
+
if (!node || conflict.deleted || !same(this.valuesOf(node, mapping)[conflict.field], conflict.theirs)) {
|
|
244
444
|
still.set(keyOf(conflict), conflict);
|
|
245
445
|
continue;
|
|
246
446
|
}
|
|
@@ -272,7 +472,7 @@ export class SyncEngine {
|
|
|
272
472
|
if (mapping.readonly || gone.has(localId))
|
|
273
473
|
continue;
|
|
274
474
|
const link = this.state.links[localId];
|
|
275
|
-
const values =
|
|
475
|
+
const values = this.valuesOf(node, mapping);
|
|
276
476
|
if (!link) {
|
|
277
477
|
writes.push({
|
|
278
478
|
resource: mapping.resource,
|
|
@@ -320,8 +520,10 @@ export class SyncEngine {
|
|
|
320
520
|
const held = this.held();
|
|
321
521
|
const policy = this.policy;
|
|
322
522
|
let offline = false;
|
|
523
|
+
let problem;
|
|
323
524
|
let links = { ...this.state.links };
|
|
324
525
|
let cursor = this.state.cursor;
|
|
526
|
+
const skipped = { ...this.state.skipped };
|
|
325
527
|
/*
|
|
326
528
|
* A HELD FIELD ONLY THE REMOTE CHANGED is no conflict: ours is back at
|
|
327
529
|
* the value both last agreed — a delete taken back leaves it there, or
|
|
@@ -333,15 +535,14 @@ export class SyncEngine {
|
|
|
333
535
|
const node = this.options.store.graph.getNode(conflict.localId);
|
|
334
536
|
const link = links[conflict.localId];
|
|
335
537
|
const mapping = link && this.mappingFor(link.resource);
|
|
336
|
-
if ("deleted" in conflict || !node || !mapping || !same(
|
|
538
|
+
if ("deleted" in conflict || !node || !mapping || !same(this.valuesOf(node, mapping)[conflict.field], conflict.base))
|
|
337
539
|
continue;
|
|
338
540
|
landing.set(conflict.localId, { ...landing.get(conflict.localId), [conflict.field]: conflict.theirs });
|
|
339
541
|
held.delete(key);
|
|
340
542
|
}
|
|
341
543
|
for (const [localId, fields] of landing) {
|
|
342
544
|
const link = links[localId];
|
|
343
|
-
|
|
344
|
-
if (!this.land(call, `${this.options.mapping.system}: ${Object.keys(fields).join(", ")}`, { localId, resource: link.resource, id: link.id }, refused))
|
|
545
|
+
if (!this.landInbound({ localId, mapping: this.mappingFor(link.resource), values: fields }, `${this.options.mapping.system}: ${Object.keys(fields).join(", ")}`, { localId, resource: link.resource, id: link.id }, refused))
|
|
345
546
|
continue;
|
|
346
547
|
links[localId] = { ...link, fields: { ...link.fields, ...fields } };
|
|
347
548
|
applied.push(localId);
|
|
@@ -368,9 +569,19 @@ export class SyncEngine {
|
|
|
368
569
|
continue;
|
|
369
570
|
}
|
|
370
571
|
if (!localId) {
|
|
572
|
+
const remoteKey = `${change.resource}:${change.id}`;
|
|
371
573
|
// A deletion of a record we never had is not news: a first full
|
|
372
574
|
// pull lists every canceled event the remote remembers.
|
|
373
|
-
if (change.deleted)
|
|
575
|
+
if (change.deleted) {
|
|
576
|
+
delete skipped[remoteKey];
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
/*
|
|
580
|
+
* REFUSED ONCE, AS IT STANDS. A record there this store would not
|
|
581
|
+
* make (a blank row) is said once, then left until it changes
|
|
582
|
+
* there: refused on every pass, it was the same line every 30 s.
|
|
583
|
+
*/
|
|
584
|
+
if (skipped[remoteKey] === change.version)
|
|
374
585
|
continue;
|
|
375
586
|
const created = this.options.createInbound?.({
|
|
376
587
|
resource: change.resource,
|
|
@@ -379,17 +590,25 @@ export class SyncEngine {
|
|
|
379
590
|
});
|
|
380
591
|
if (!created)
|
|
381
592
|
continue;
|
|
382
|
-
const
|
|
383
|
-
|
|
593
|
+
const values = ourFields(change.fields ?? {}, mapping);
|
|
594
|
+
const madeAs = String(created.args["id"] ?? "");
|
|
595
|
+
// Its links are made in the same batch, which needs the id it is made with; without one, they wait for the next pass.
|
|
596
|
+
const landed = madeAs
|
|
597
|
+
? this.landInbound({ localId: madeAs, mapping, values, create: created }, `${this.options.mapping.system}: new ${change.resource}`, { localId: madeAs, resource: change.resource, id: change.id }, refused)?.result
|
|
598
|
+
: this.land(created, `${this.options.mapping.system}: new ${change.resource}`, { localId: "", resource: change.resource, id: change.id }, refused);
|
|
599
|
+
if (!landed) {
|
|
600
|
+
skipped[remoteKey] = change.version;
|
|
384
601
|
continue;
|
|
385
|
-
|
|
602
|
+
}
|
|
603
|
+
delete skipped[remoteKey];
|
|
604
|
+
const madeId = String(created.args["id"] ?? landed.writes[0] ?? "");
|
|
386
605
|
if (madeId) {
|
|
387
606
|
applied.push(madeId);
|
|
388
607
|
links[madeId] = {
|
|
389
608
|
resource: change.resource,
|
|
390
609
|
id: change.id,
|
|
391
610
|
version: change.version,
|
|
392
|
-
fields:
|
|
611
|
+
fields: values,
|
|
393
612
|
};
|
|
394
613
|
}
|
|
395
614
|
continue;
|
|
@@ -446,7 +665,7 @@ export class SyncEngine {
|
|
|
446
665
|
continue;
|
|
447
666
|
}
|
|
448
667
|
const theirs = ourFields(change.fields ?? {}, mapping);
|
|
449
|
-
const ours =
|
|
668
|
+
const ours = this.valuesOf(node, mapping);
|
|
450
669
|
/*
|
|
451
670
|
* Field by field: what lands, what both sides now agree on, and what
|
|
452
671
|
* is contested — both changed since the version we last agreed on,
|
|
@@ -488,11 +707,12 @@ export class SyncEngine {
|
|
|
488
707
|
agreed[field] = value;
|
|
489
708
|
}
|
|
490
709
|
if (Object.keys(lands).length > 0) {
|
|
491
|
-
const call = this.options.applyInbound({ localId, kind: mapping.kind, fields: lands });
|
|
492
710
|
// Refused here, the link keeps what was last agreed, so the field is still news on the next change there.
|
|
493
|
-
|
|
711
|
+
const landed = this.landInbound({ localId, mapping, values: lands }, `${this.options.mapping.system}: ${Object.keys(lands).join(", ")}`, { localId, resource: change.resource, id: change.id }, refused);
|
|
712
|
+
if (!landed)
|
|
494
713
|
continue;
|
|
495
|
-
|
|
714
|
+
if (landed.result)
|
|
715
|
+
applied.push(localId);
|
|
496
716
|
}
|
|
497
717
|
links[localId] = {
|
|
498
718
|
...link,
|
|
@@ -503,7 +723,7 @@ export class SyncEngine {
|
|
|
503
723
|
};
|
|
504
724
|
}
|
|
505
725
|
}
|
|
506
|
-
catch {
|
|
726
|
+
catch (error) {
|
|
507
727
|
/*
|
|
508
728
|
* OFFLINE degrades to local-only. Only the remote throws this far: a
|
|
509
729
|
* change this store refused is caught where it lands, and said in
|
|
@@ -514,11 +734,17 @@ export class SyncEngine {
|
|
|
514
734
|
* asks for the same window again.
|
|
515
735
|
*/
|
|
516
736
|
offline = true;
|
|
737
|
+
problem = error instanceof Error ? error.message : String(error);
|
|
517
738
|
}
|
|
518
739
|
// What the inbound pass decided is what the outbound pass sends from.
|
|
519
|
-
const { conflicts: _before, gone: _gone, ...kept } = this.state;
|
|
740
|
+
const { conflicts: _before, gone: _gone, skipped: _skipped, ...kept } = this.state;
|
|
520
741
|
const unsynced = [...new Set([...(this.state.gone ?? []), ...gone])].filter((id) => this.options.store.graph.getNode(id));
|
|
521
|
-
this.state = { ...kept, links, ...this.heldState(held), ...(unsynced.length > 0 ? { gone: unsynced } : {}) };
|
|
742
|
+
this.state = { ...kept, links, ...this.heldState(held), ...(unsynced.length > 0 ? { gone: unsynced } : {}), ...(Object.keys(skipped).length > 0 ? { skipped } : {}) };
|
|
743
|
+
// What a relation reached that was not linked yet, linked now that this pull may have made it.
|
|
744
|
+
if (!offline)
|
|
745
|
+
for (const id of this.resolveHeld(refused))
|
|
746
|
+
if (!applied.includes(id))
|
|
747
|
+
applied.push(id);
|
|
522
748
|
/*
|
|
523
749
|
* A RECORD THE REMOTE OWNS, DELETED HERE, is refused here and said once:
|
|
524
750
|
* the link goes, so the next pass does not refuse it again, and the
|
|
@@ -567,8 +793,9 @@ export class SyncEngine {
|
|
|
567
793
|
const acked = new Set(acks.map((ack) => ack.localId));
|
|
568
794
|
queued = deduped.filter((write) => !acked.has(write.localId));
|
|
569
795
|
}
|
|
570
|
-
catch {
|
|
796
|
+
catch (error) {
|
|
571
797
|
offline = true;
|
|
798
|
+
problem = error instanceof Error ? error.message : String(error);
|
|
572
799
|
}
|
|
573
800
|
}
|
|
574
801
|
this.state = {
|
|
@@ -577,6 +804,8 @@ export class SyncEngine {
|
|
|
577
804
|
outbox: queued,
|
|
578
805
|
...this.heldState(held),
|
|
579
806
|
...(this.state.gone ? { gone: this.state.gone } : {}),
|
|
807
|
+
...(this.state.unresolved ? { unresolved: this.state.unresolved } : {}),
|
|
808
|
+
...(this.state.skipped ? { skipped: this.state.skipped } : {}),
|
|
580
809
|
};
|
|
581
810
|
return {
|
|
582
811
|
system: this.options.mapping.system,
|
|
@@ -589,6 +818,7 @@ export class SyncEngine {
|
|
|
589
818
|
refused,
|
|
590
819
|
gone,
|
|
591
820
|
offline,
|
|
821
|
+
...(problem ? { problem } : {}),
|
|
592
822
|
state: this.snapshot,
|
|
593
823
|
};
|
|
594
824
|
}
|
|
@@ -630,7 +860,7 @@ export class SyncEngine {
|
|
|
630
860
|
*/
|
|
631
861
|
land(call, intent, about, refused) {
|
|
632
862
|
try {
|
|
633
|
-
return this.options.store.
|
|
863
|
+
return this.options.store.applyAll(Array.isArray(call) ? call : [call], { author: systemAuthor(this.options.mapping.system), intent });
|
|
634
864
|
}
|
|
635
865
|
catch (error) {
|
|
636
866
|
if (!refused)
|
|
@@ -655,7 +885,10 @@ export class SyncEngine {
|
|
|
655
885
|
const created = this.options.createInbound?.({ resource: link.resource, id: link.id, fields: remoteFields(record, mapping) });
|
|
656
886
|
if (!created)
|
|
657
887
|
return { links: rest };
|
|
658
|
-
const
|
|
888
|
+
const madeAs = String(created.args["id"] ?? "");
|
|
889
|
+
const about = { localId, resource: link.resource, id: link.id };
|
|
890
|
+
const intent = `${this.options.mapping.system}: restored ${link.resource}`;
|
|
891
|
+
const result = madeAs ? this.landInbound({ localId: madeAs, mapping, values: record, create: created }, intent, about, refused)?.result : this.land(created, intent, about, refused);
|
|
659
892
|
if (!result)
|
|
660
893
|
return { links };
|
|
661
894
|
const id = String(created.args["id"] ?? result.writes[0] ?? "");
|
|
@@ -694,14 +927,7 @@ export class SyncEngine {
|
|
|
694
927
|
return;
|
|
695
928
|
}
|
|
696
929
|
if (take === "theirs") {
|
|
697
|
-
this.
|
|
698
|
-
localId: conflict.localId,
|
|
699
|
-
kind: mapping.kind,
|
|
700
|
-
fields: { [conflict.field]: conflict.theirs },
|
|
701
|
-
}), {
|
|
702
|
-
author: systemAuthor(this.options.mapping.system),
|
|
703
|
-
intent: `${this.options.mapping.system}: took their "${conflict.field}"`,
|
|
704
|
-
});
|
|
930
|
+
this.landInbound({ localId: conflict.localId, mapping, values: { [conflict.field]: conflict.theirs } }, `${this.options.mapping.system}: took their "${conflict.field}"`, { localId: conflict.localId, resource: link.resource, id: link.id });
|
|
705
931
|
}
|
|
706
932
|
const held = this.held();
|
|
707
933
|
held.delete(heldKey(conflict.localId, conflict.field));
|
|
@@ -729,6 +955,17 @@ export class SyncEngine {
|
|
|
729
955
|
if (op.author.kind === "system" && op.author.id === this.options.mapping.system)
|
|
730
956
|
continue;
|
|
731
957
|
for (const primitive of op.primitives) {
|
|
958
|
+
if (primitive.op === "add-edge" || primitive.op === "remove-edge") {
|
|
959
|
+
// A link the remote keeps, made or taken away on a record it owns.
|
|
960
|
+
const from = graph.getNode(primitive.edge.from);
|
|
961
|
+
const mapping = from ? this.options.mapping.resources.find((resource) => resource.kind === from["kind"]) : undefined;
|
|
962
|
+
if (!mapping?.readonly || !mapping.edges || !(primitive.edge.kind in mapping.edges) || (mapping.match && !mapping.match(from)))
|
|
963
|
+
continue;
|
|
964
|
+
// A record of ours removed takes its links with it, those the remote keeps too: that is the record's removal, not a change to the link.
|
|
965
|
+
if (primitive.op === "remove-edge" && op.primitives.some((other) => other.op === "remove-node" && other.node.id === primitive.edge.to))
|
|
966
|
+
continue;
|
|
967
|
+
throw new PermissionDeniedError({ mutation: op.mutation?.name ?? "", kind: mapping.kind, message: this.because(mapping), wouldNeed: [] });
|
|
968
|
+
}
|
|
732
969
|
const node = (primitive.op === "add-node" || primitive.op === "remove-node"
|
|
733
970
|
? primitive.node
|
|
734
971
|
: primitive.op === "patch-node" || primitive.op === "splice-text"
|