@lanes-sh/link 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lanes-sh/link",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "A self-hostable MCP gateway for all your connections, memory, skills, and secrets",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://lanes.sh/link",
@@ -8,7 +8,7 @@ import {
8
8
  type Config,
9
9
  } from '#profile';
10
10
  import { ConfigDocument } from '#cli/config-edit.ts';
11
- import { diffConfigs, keyedArrayFor, type Change } from './sync.ts';
11
+ import { diffConfigs, keyOfElement, keyedArrayFor, type Change } from './sync.ts';
12
12
  import { isWorkspaceConfig } from './upload.ts';
13
13
 
14
14
  /**
@@ -155,28 +155,82 @@ export async function applyPulls(
155
155
  const remote = await readRawProfile(remoteRoot, profile);
156
156
  if (remote === undefined) return 0;
157
157
 
158
+ const local = document.toJSON();
158
159
  const written = new Set<string>();
160
+
159
161
  for (const change of pulls) {
160
162
  const array = keyedArrayFor(change.path);
161
- const path = array ?? change.path;
162
163
 
163
- // One write per array, however many of its elements were missing.
164
- const key = path.join('.');
165
- if (written.has(key)) continue;
166
- written.add(key);
164
+ if (array) {
165
+ // One write per array, however many of its elements were missing — and
166
+ // the value written is the *merge*, never the remote array.
167
+ //
168
+ // It used to be `setIn(array, remoteArray)`, which reads as "pull the
169
+ // connections" and means "replace the connections". A profile that had
170
+ // gained six accounts locally since the last deploy lost all six to a
171
+ // command whose entire purpose is not losing things. The tests missed it
172
+ // because every one of them had a local array that was a subset of the
173
+ // remote — the case where replacing and merging agree.
174
+ const key = array.join('.');
175
+ if (written.has(key)) continue;
176
+ written.add(key);
177
+
178
+ const merged = mergeKeyed(
179
+ array,
180
+ valueAt(local, array),
181
+ valueAt(remote, array),
182
+ pulls
183
+ .filter((one) => keyedArrayFor(one.path)?.join('.') === key)
184
+ .map((one) => one.path[array.length])
185
+ .filter((name): name is string => name !== undefined),
186
+ );
187
+ if (merged !== undefined) document.setIn([...array], merged);
188
+ continue;
189
+ }
167
190
 
168
- const value = valueAt(remote, path);
191
+ const value = valueAt(remote, change.path);
169
192
  // Absent from the raw document means the remote side only has it as a
170
193
  // default, which the local side fills in identically. Nothing to write.
171
194
  if (value === undefined) continue;
172
195
 
173
- document.setIn([...path], value);
196
+ document.setIn([...change.path], value);
174
197
  }
175
198
 
176
199
  await document.save();
177
200
  return written.size;
178
201
  }
179
202
 
203
+ /**
204
+ * The local array, with the named elements taken from the remote one.
205
+ *
206
+ * Element order is local's, with anything new appended, so a pull reads as a
207
+ * diff in the file rather than as a reshuffle. An element named in `wanted` and
208
+ * absent from the remote array is skipped rather than removed: the diff said it
209
+ * was missing locally, so it cannot also be missing remotely.
210
+ */
211
+ function mergeKeyed(
212
+ arrayPath: readonly string[],
213
+ local: unknown,
214
+ remote: unknown,
215
+ wanted: readonly string[],
216
+ ): unknown[] | undefined {
217
+ if (!Array.isArray(remote)) return undefined;
218
+
219
+ const merged = Array.isArray(local) ? [...(local as unknown[])] : [];
220
+ const keyOf = (item: unknown): string | undefined => keyOfElement(arrayPath, item);
221
+
222
+ for (const name of new Set(wanted)) {
223
+ const incoming = remote.find((item) => keyOf(item) === name);
224
+ if (incoming === undefined) continue;
225
+
226
+ const at = merged.findIndex((item) => keyOf(item) === name);
227
+ if (at >= 0) merged[at] = incoming;
228
+ else merged.push(incoming);
229
+ }
230
+
231
+ return merged;
232
+ }
233
+
180
234
  /** Read a path out of the raw document, for handing to `setIn`. */
181
235
  function valueAt(config: unknown, path: readonly string[]): unknown {
182
236
  let value: unknown = config;
@@ -41,12 +41,23 @@ export interface Change {
41
41
  * first entry of a kind is the one to reach for — so it is an ordered list and
42
42
  * compares as one.
43
43
  */
44
- const KEYED: Record<string, (item: Record<string, unknown>) => string> = {
45
- connections: (item) => `${String(item['provider'])}.${String(item['id'])}`,
46
- 'policy.allow': (item) => String(item['capability']),
47
- 'policy.deny': (item) => String(item['capability']),
44
+ const KEYED: Record<string, (item: unknown) => string | undefined> = {
45
+ connections: (item) =>
46
+ isRecord(item) ? `${String(item['provider'])}.${String(item['id'])}` : undefined,
47
+ // Two shapes, and both are reached. The diff runs over validated configs,
48
+ // where `allow: [gmail.*]` has become `[{capability: gmail.*}]`; the writer
49
+ // runs over the raw document, where it is still a string. A key function that
50
+ // knew only the validated shape found nothing to merge and silently wrote the
51
+ // array without it.
52
+ 'policy.allow': capabilityOf,
53
+ 'policy.deny': capabilityOf,
48
54
  };
49
55
 
56
+ function capabilityOf(item: unknown): string | undefined {
57
+ if (typeof item === 'string') return item;
58
+ return isRecord(item) ? String(item['capability']) : undefined;
59
+ }
60
+
50
61
  const isRecord = (value: unknown): value is Record<string, unknown> =>
51
62
  typeof value === 'object' && value !== null && !Array.isArray(value);
52
63
 
@@ -102,12 +113,16 @@ function walk(path: readonly string[], local: unknown, remote: unknown): Change[
102
113
  */
103
114
  function walkKeyed(
104
115
  path: readonly string[],
105
- keyOf: (item: Record<string, unknown>) => string,
116
+ keyOf: (item: unknown) => string | undefined,
106
117
  local: readonly unknown[],
107
118
  remote: readonly unknown[],
108
119
  ): Change[] {
109
120
  const index = (items: readonly unknown[]): Map<string, unknown> =>
110
- new Map(items.filter(isRecord).map((item) => [keyOf(item), item]));
121
+ new Map(
122
+ items
123
+ .map((item) => [keyOf(item), item] as const)
124
+ .filter((pair): pair is readonly [string, unknown] => pair[0] !== undefined),
125
+ );
111
126
 
112
127
  const here = index(local);
113
128
  const there = index(remote);
@@ -130,6 +145,19 @@ export function keyedArrayFor(path: readonly string[]): readonly string[] | unde
130
145
  return undefined;
131
146
  }
132
147
 
148
+ /**
149
+ * How an element of a keyed array identifies itself, for the writer.
150
+ *
151
+ * The diff indexes these to compare them; applying one has to find the same
152
+ * element again in *both* raw documents, and it cannot re-derive the key from
153
+ * the path — `connections.gmail.work` is one key containing a dot, not two
154
+ * steps. Exported so the two halves cannot disagree about what identifies a
155
+ * connection.
156
+ */
157
+ export function keyOfElement(arrayPath: readonly string[], item: unknown): string | undefined {
158
+ return KEYED[arrayPath.join('.')]?.(item);
159
+ }
160
+
133
161
  /** Whether a set of changes can be applied without being told which side wins. */
134
162
  export function conflictsIn(changes: readonly Change[]): Change[] {
135
163
  return changes.filter((change) => change.direction === 'conflict');