@graview/core 0.1.56 → 0.1.58

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.
Files changed (111) hide show
  1. package/README.md +12 -4
  2. package/dist/capabilities.js +1 -1
  3. package/dist/capabilities.js.map +1 -1
  4. package/dist/cli/document-schema.js +3 -3
  5. package/dist/cli/document-schema.js.map +1 -1
  6. package/dist/document/blocks.d.ts +4 -2
  7. package/dist/document/blocks.d.ts.map +1 -1
  8. package/dist/document/blocks.js +7 -4
  9. package/dist/document/blocks.js.map +1 -1
  10. package/dist/document/compile.d.ts.map +1 -1
  11. package/dist/document/compile.js +4 -0
  12. package/dist/document/compile.js.map +1 -1
  13. package/dist/document/compiled.d.ts +2 -0
  14. package/dist/document/compiled.d.ts.map +1 -1
  15. package/dist/document/compiled.js +21 -1
  16. package/dist/document/compiled.js.map +1 -1
  17. package/dist/document/connection-findings.d.ts.map +1 -1
  18. package/dist/document/connection-findings.js +189 -25
  19. package/dist/document/connection-findings.js.map +1 -1
  20. package/dist/document/connection-spec.d.ts +42 -2
  21. package/dist/document/connection-spec.d.ts.map +1 -1
  22. package/dist/document/connection-spec.js +64 -19
  23. package/dist/document/connection-spec.js.map +1 -1
  24. package/dist/document/describe-place.d.ts +2 -0
  25. package/dist/document/describe-place.d.ts.map +1 -1
  26. package/dist/document/describe-place.js +8 -1
  27. package/dist/document/describe-place.js.map +1 -1
  28. package/dist/document/described.d.ts +2 -1
  29. package/dist/document/described.d.ts.map +1 -1
  30. package/dist/document/edit.d.ts +1 -1
  31. package/dist/document/edit.d.ts.map +1 -1
  32. package/dist/document/edit.js +101 -4
  33. package/dist/document/edit.js.map +1 -1
  34. package/dist/document/expr/evaluate.d.ts +1 -1
  35. package/dist/document/expr/evaluate.d.ts.map +1 -1
  36. package/dist/document/expr/evaluate.js +20 -1
  37. package/dist/document/expr/evaluate.js.map +1 -1
  38. package/dist/document/kept-in-step.d.ts +7 -0
  39. package/dist/document/kept-in-step.d.ts.map +1 -0
  40. package/dist/document/kept-in-step.js +28 -0
  41. package/dist/document/kept-in-step.js.map +1 -0
  42. package/dist/document/rule-line.d.ts.map +1 -1
  43. package/dist/document/rule-line.js +2 -0
  44. package/dist/document/rule-line.js.map +1 -1
  45. package/dist/document/schema.d.ts +33 -5
  46. package/dist/document/schema.d.ts.map +1 -1
  47. package/dist/document/schema.js +13 -1
  48. package/dist/document/schema.js.map +1 -1
  49. package/dist/document/to-document.d.ts.map +1 -1
  50. package/dist/document/to-document.js +4 -2
  51. package/dist/document/to-document.js.map +1 -1
  52. package/dist/document/views.d.ts +6 -1
  53. package/dist/document/views.d.ts.map +1 -1
  54. package/dist/document/views.js +16 -5
  55. package/dist/document/views.js.map +1 -1
  56. package/dist/document.schema.json +281 -98
  57. package/dist/mutations/derive-edits.d.ts +4 -2
  58. package/dist/mutations/derive-edits.d.ts.map +1 -1
  59. package/dist/mutations/derive-edits.js +13 -4
  60. package/dist/mutations/derive-edits.js.map +1 -1
  61. package/dist/scaffold/from-template.d.ts.map +1 -1
  62. package/dist/scaffold/from-template.js +2 -1
  63. package/dist/scaffold/from-template.js.map +1 -1
  64. package/dist/schema/field-words.d.ts +10 -0
  65. package/dist/schema/field-words.d.ts.map +1 -1
  66. package/dist/schema/field-words.js +26 -10
  67. package/dist/schema/field-words.js.map +1 -1
  68. package/dist/schema/reading.d.ts.map +1 -1
  69. package/dist/schema/reading.js +5 -2
  70. package/dist/schema/reading.js.map +1 -1
  71. package/dist/schema/types.d.ts +32 -0
  72. package/dist/schema/types.d.ts.map +1 -1
  73. package/dist/sync/connection-filter.d.ts +2 -2
  74. package/dist/sync/connection-filter.d.ts.map +1 -1
  75. package/dist/sync/connection-filter.js.map +1 -1
  76. package/dist/sync/connection.d.ts +72 -28
  77. package/dist/sync/connection.d.ts.map +1 -1
  78. package/dist/sync/connection.js +255 -43
  79. package/dist/sync/connection.js.map +1 -1
  80. package/dist/sync/creating-act.d.ts +20 -0
  81. package/dist/sync/creating-act.d.ts.map +1 -0
  82. package/dist/sync/creating-act.js +29 -0
  83. package/dist/sync/creating-act.js.map +1 -0
  84. package/dist/sync/engine.d.ts +83 -1
  85. package/dist/sync/engine.d.ts.map +1 -1
  86. package/dist/sync/engine.js +269 -32
  87. package/dist/sync/engine.js.map +1 -1
  88. package/dist/sync/google-calendar.d.ts +3 -0
  89. package/dist/sync/google-calendar.d.ts.map +1 -1
  90. package/dist/sync/index.d.ts +10 -4
  91. package/dist/sync/index.d.ts.map +1 -1
  92. package/dist/sync/index.js +6 -2
  93. package/dist/sync/index.js.map +1 -1
  94. package/dist/sync/notion-stub.d.ts +81 -0
  95. package/dist/sync/notion-stub.d.ts.map +1 -0
  96. package/dist/sync/notion-stub.js +467 -0
  97. package/dist/sync/notion-stub.js.map +1 -0
  98. package/dist/sync/notion-types.d.ts +23 -0
  99. package/dist/sync/notion-types.d.ts.map +1 -0
  100. package/dist/sync/notion-types.js +49 -0
  101. package/dist/sync/notion-types.js.map +1 -0
  102. package/dist/sync/notion.d.ts +98 -0
  103. package/dist/sync/notion.d.ts.map +1 -0
  104. package/dist/sync/notion.js +477 -0
  105. package/dist/sync/notion.js.map +1 -0
  106. package/dist/sync/types.d.ts +38 -0
  107. package/dist/sync/types.d.ts.map +1 -1
  108. package/dist/sync/types.js.map +1 -1
  109. package/dist/version.d.ts +1 -1
  110. package/dist/version.js +1 -1
  111. package/package.json +1 -1
@@ -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.fields)) {
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
- values[ours] = decode ? decode(remote[theirs]) : remote[theirs];
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.fields)) {
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 = mappedValues(node, mapping);
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(mappedValues(node, mapping)[conflict.field], conflict.theirs)) {
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 = mappedValues(node, mapping);
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(mappedValues(node, mapping)[conflict.field], conflict.base))
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
- const call = this.options.applyInbound({ localId, kind: this.mappingFor(link.resource).kind, fields });
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 result2 = this.land(created, `${this.options.mapping.system}: new ${change.resource}`, { localId: String(created.args["id"] ?? ""), resource: change.resource, id: change.id }, refused);
383
- if (!result2)
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
- const madeId = String(created.args["id"] ?? result2.writes[0] ?? "");
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: ourFields(change.fields ?? {}, mapping),
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 = mappedValues(node, mapping);
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
- if (!this.land(call, `${this.options.mapping.system}: ${Object.keys(lands).join(", ")}`, { localId, resource: change.resource, id: change.id }, refused))
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
- applied.push(localId);
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.apply(call, { author: systemAuthor(this.options.mapping.system), intent });
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 result = this.land(created, `${this.options.mapping.system}: restored ${link.resource}`, { localId, resource: link.resource, id: link.id }, refused);
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.options.store.apply(this.options.applyInbound({
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"