@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 +1 -1
- package/src/deployments/sync-apply.ts +62 -8
- package/src/deployments/sync.ts +34 -6
package/package.json
CHANGED
|
@@ -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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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;
|
package/src/deployments/sync.ts
CHANGED
|
@@ -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:
|
|
45
|
-
connections: (item) =>
|
|
46
|
-
|
|
47
|
-
|
|
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:
|
|
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(
|
|
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');
|