@gamerdexapp/gamerdex-sync 0.0.0-stage → 0.1.0
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/CHANGELOG.md +29 -0
- package/LICENSE +21 -0
- package/README.md +81 -2
- package/dist/index.d.mts +392 -0
- package/dist/index.d.ts +392 -0
- package/dist/index.js +829 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +767 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +62 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,829 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
DATA_FILE: () => DATA_FILE,
|
|
24
|
+
GITHUB_APP: () => GITHUB_APP,
|
|
25
|
+
GitHubProvider: () => GitHubProvider,
|
|
26
|
+
META_FILE: () => META_FILE,
|
|
27
|
+
META_FORMAT: () => META_FORMAT,
|
|
28
|
+
MemoryProvider: () => MemoryProvider,
|
|
29
|
+
ProviderError: () => ProviderError,
|
|
30
|
+
ROOT_FOLDER: () => ROOT_FOLDER,
|
|
31
|
+
RemoteDataError: () => RemoteDataError,
|
|
32
|
+
SyncBusyError: () => SyncBusyError,
|
|
33
|
+
VersionConflictError: () => VersionConflictError,
|
|
34
|
+
authorizeUrl: () => authorizeUrl,
|
|
35
|
+
createTokenSource: () => createTokenSource,
|
|
36
|
+
dataPath: () => dataPath,
|
|
37
|
+
decodeBase64: () => decodeBase64,
|
|
38
|
+
emptyMeta: () => emptyMeta,
|
|
39
|
+
encodeBase64: () => encodeBase64,
|
|
40
|
+
equal: () => equal,
|
|
41
|
+
installUrl: () => installUrl,
|
|
42
|
+
isAppId: () => isAppId,
|
|
43
|
+
isObject: () => isObject,
|
|
44
|
+
listInstalledRepositories: () => listInstalledRepositories,
|
|
45
|
+
merge: () => merge,
|
|
46
|
+
metaPath: () => metaPath,
|
|
47
|
+
newState: () => newState,
|
|
48
|
+
parseMeta: () => parseMeta,
|
|
49
|
+
parseTokenResponse: () => parseTokenResponse,
|
|
50
|
+
readCallback: () => readCallback,
|
|
51
|
+
resolveConflict: () => resolveConflict,
|
|
52
|
+
restoreCloudValues: () => restoreCloudValues,
|
|
53
|
+
sync: () => sync,
|
|
54
|
+
toStoredTokens: () => toStoredTokens,
|
|
55
|
+
withChiefRemoved: () => withChiefRemoved,
|
|
56
|
+
withChiefRevision: () => withChiefRevision,
|
|
57
|
+
withDevice: () => withDevice,
|
|
58
|
+
withSettingsRevision: () => withSettingsRevision
|
|
59
|
+
});
|
|
60
|
+
module.exports = __toCommonJS(index_exports);
|
|
61
|
+
|
|
62
|
+
// src/layout.ts
|
|
63
|
+
var ROOT_FOLDER = "Gamerdex";
|
|
64
|
+
var DATA_FILE = "data.json";
|
|
65
|
+
var META_FILE = "meta.json";
|
|
66
|
+
var APP_ID = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
67
|
+
var isAppId = (value) => APP_ID.test(value);
|
|
68
|
+
var folderOf = (appId) => {
|
|
69
|
+
if (!isAppId(appId)) throw new RangeError(`Invalid app id: ${appId}`);
|
|
70
|
+
return `${ROOT_FOLDER}/${appId}`;
|
|
71
|
+
};
|
|
72
|
+
var dataPath = (appId) => `${folderOf(appId)}/${DATA_FILE}`;
|
|
73
|
+
var metaPath = (appId) => `${folderOf(appId)}/${META_FILE}`;
|
|
74
|
+
|
|
75
|
+
// src/meta.ts
|
|
76
|
+
var META_FORMAT = 1;
|
|
77
|
+
var emptyMeta = (appId) => ({
|
|
78
|
+
format: META_FORMAT,
|
|
79
|
+
appId,
|
|
80
|
+
revisions: {},
|
|
81
|
+
settingsRevision: 0,
|
|
82
|
+
removed: {},
|
|
83
|
+
devices: {},
|
|
84
|
+
lastSyncAt: null
|
|
85
|
+
});
|
|
86
|
+
var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
87
|
+
var isCounter = (value) => typeof value === "number" && Number.isInteger(value) && value >= 0;
|
|
88
|
+
var isCounterMap = (value) => isRecord(value) && Object.values(value).every(isCounter);
|
|
89
|
+
var isDevice = (value) => isRecord(value) && typeof value.name === "string" && typeof value.lastSeenAt === "string";
|
|
90
|
+
var parseMeta = (text, appId) => {
|
|
91
|
+
let value;
|
|
92
|
+
try {
|
|
93
|
+
value = JSON.parse(text);
|
|
94
|
+
} catch {
|
|
95
|
+
return { ok: false, error: "The meta file is not valid JSON." };
|
|
96
|
+
}
|
|
97
|
+
if (!isRecord(value)) return { ok: false, error: "The meta file is not an object." };
|
|
98
|
+
if (value.format !== META_FORMAT) return { ok: false, error: "The meta format is not known." };
|
|
99
|
+
if (value.appId !== appId) return { ok: false, error: "The meta file belongs to another app." };
|
|
100
|
+
if (!isCounterMap(value.revisions)) return { ok: false, error: "The revisions are not valid." };
|
|
101
|
+
if (!isCounter(value.settingsRevision)) {
|
|
102
|
+
return { ok: false, error: "The settings revision is not valid." };
|
|
103
|
+
}
|
|
104
|
+
if (!isCounterMap(value.removed)) return { ok: false, error: "The removed list is not valid." };
|
|
105
|
+
if (!isRecord(value.devices) || !Object.values(value.devices).every(isDevice)) {
|
|
106
|
+
return { ok: false, error: "The devices are not valid." };
|
|
107
|
+
}
|
|
108
|
+
if (value.lastSyncAt !== null && typeof value.lastSyncAt !== "string") {
|
|
109
|
+
return { ok: false, error: "The last sync time is not valid." };
|
|
110
|
+
}
|
|
111
|
+
return { ok: true, meta: value };
|
|
112
|
+
};
|
|
113
|
+
var withChiefRevision = (meta, chiefId) => {
|
|
114
|
+
return {
|
|
115
|
+
...meta,
|
|
116
|
+
revisions: { ...meta.revisions, [chiefId]: (meta.revisions[chiefId] ?? 0) + 1 },
|
|
117
|
+
removed: Object.fromEntries(Object.entries(meta.removed).filter(([id]) => id !== chiefId))
|
|
118
|
+
};
|
|
119
|
+
};
|
|
120
|
+
var withChiefRemoved = (meta, chiefId) => {
|
|
121
|
+
const revision = (meta.revisions[chiefId] ?? 0) + 1;
|
|
122
|
+
return {
|
|
123
|
+
...meta,
|
|
124
|
+
revisions: { ...meta.revisions, [chiefId]: revision },
|
|
125
|
+
removed: { ...meta.removed, [chiefId]: revision }
|
|
126
|
+
};
|
|
127
|
+
};
|
|
128
|
+
var withSettingsRevision = (meta) => ({
|
|
129
|
+
...meta,
|
|
130
|
+
settingsRevision: meta.settingsRevision + 1
|
|
131
|
+
});
|
|
132
|
+
var withDevice = (meta, deviceId, device) => ({
|
|
133
|
+
...meta,
|
|
134
|
+
devices: { ...meta.devices, [deviceId]: device },
|
|
135
|
+
lastSyncAt: device.lastSeenAt
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// src/merge/equal.ts
|
|
139
|
+
var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
140
|
+
var equal = (a, b) => {
|
|
141
|
+
if (a === b) return true;
|
|
142
|
+
if (Array.isArray(a)) {
|
|
143
|
+
return Array.isArray(b) && a.length === b.length && a.every((item, i) => equal(item, b[i]));
|
|
144
|
+
}
|
|
145
|
+
if (isObject(a) && isObject(b)) {
|
|
146
|
+
const keys = Object.keys(a);
|
|
147
|
+
return keys.length === Object.keys(b).length && keys.every((key) => Object.hasOwn(b, key) && equal(a[key], b[key]));
|
|
148
|
+
}
|
|
149
|
+
return false;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// src/merge/cloud.ts
|
|
153
|
+
var own = (value, key) => Object.hasOwn(value, key) ? value[key] : void 0;
|
|
154
|
+
var childSchema = (schema, key) => (schema.fields && Object.hasOwn(schema.fields, key) ? schema.fields[key] : void 0) ?? schema.each ?? { type: "record" };
|
|
155
|
+
var holdsDeviceValue = (schema) => {
|
|
156
|
+
if (schema.type === "local") return true;
|
|
157
|
+
if (schema.type === "keyed") return holdsDeviceValue(schema.item);
|
|
158
|
+
if (schema.type !== "object") return false;
|
|
159
|
+
return schema.each !== void 0 && holdsDeviceValue(schema.each) || Object.values(schema.fields ?? {}).some(holdsDeviceValue);
|
|
160
|
+
};
|
|
161
|
+
var restoreCloudValues = (merged, remote, schema) => {
|
|
162
|
+
if (schema.type === "local") return remote;
|
|
163
|
+
if (!holdsDeviceValue(schema)) return merged;
|
|
164
|
+
if (schema.type === "object" && isObject(merged) && (isObject(remote) || remote === void 0)) {
|
|
165
|
+
const cloud = remote ?? {};
|
|
166
|
+
const result = {};
|
|
167
|
+
for (const key of /* @__PURE__ */ new Set([...Object.keys(merged), ...Object.keys(cloud)])) {
|
|
168
|
+
const child = childSchema(schema, key);
|
|
169
|
+
const value = restoreCloudValues(own(merged, key), own(cloud, key), child);
|
|
170
|
+
if (value !== void 0) result[key] = value;
|
|
171
|
+
}
|
|
172
|
+
return result;
|
|
173
|
+
}
|
|
174
|
+
if (schema.type === "keyed" && Array.isArray(merged) && Array.isArray(remote)) {
|
|
175
|
+
const remoteById = new Map(
|
|
176
|
+
remote.filter(isObject).map((item) => [item[schema.key], item])
|
|
177
|
+
);
|
|
178
|
+
return merged.map(
|
|
179
|
+
(item) => isObject(item) ? restoreCloudValues(item, remoteById.get(item[schema.key]), schema.item) : item
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
return merged;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
// src/merge/merge.ts
|
|
186
|
+
var own2 = (map, key) => map !== void 0 && Object.hasOwn(map, key) ? map[key] : void 0;
|
|
187
|
+
var record = (base, local, remote, path, context) => {
|
|
188
|
+
context.conflicts.push({
|
|
189
|
+
path,
|
|
190
|
+
kind: "both-changed",
|
|
191
|
+
base,
|
|
192
|
+
local,
|
|
193
|
+
remote,
|
|
194
|
+
taken: context.prefer
|
|
195
|
+
});
|
|
196
|
+
return context.prefer === "local" ? local : remote;
|
|
197
|
+
};
|
|
198
|
+
var joinLists = (base, local, remote) => {
|
|
199
|
+
const has = (list, value) => list.some((item) => equal(item, value));
|
|
200
|
+
const kept = (value) => {
|
|
201
|
+
const inBase = has(base, value);
|
|
202
|
+
const inLocal = has(local, value);
|
|
203
|
+
const inRemote = has(remote, value);
|
|
204
|
+
return inBase ? inLocal && inRemote : inLocal || inRemote;
|
|
205
|
+
};
|
|
206
|
+
const result = [];
|
|
207
|
+
for (const value of [...local, ...remote]) {
|
|
208
|
+
if (kept(value) && !has(result, value)) result.push(value);
|
|
209
|
+
}
|
|
210
|
+
return result;
|
|
211
|
+
};
|
|
212
|
+
var entriesOf = (list, key) => {
|
|
213
|
+
const entries = /* @__PURE__ */ new Map();
|
|
214
|
+
for (const item of list) {
|
|
215
|
+
if (!isObject(item) || typeof item[key] !== "string") return null;
|
|
216
|
+
entries.set(item[key], item);
|
|
217
|
+
}
|
|
218
|
+
return entries;
|
|
219
|
+
};
|
|
220
|
+
var mergeEntries = (ids, pick, schemaOf, path, context, deleteWins) => {
|
|
221
|
+
const merged = /* @__PURE__ */ new Map();
|
|
222
|
+
for (const id of ids) {
|
|
223
|
+
const [base, local, remote] = pick(id);
|
|
224
|
+
const value = mergeNode(base, local, remote, schemaOf(id), [...path, id], context, deleteWins);
|
|
225
|
+
if (value !== void 0) merged.set(id, value);
|
|
226
|
+
}
|
|
227
|
+
return merged;
|
|
228
|
+
};
|
|
229
|
+
var orderedIds = (local, remote, base) => [
|
|
230
|
+
.../* @__PURE__ */ new Set([...local, ...remote, ...base])
|
|
231
|
+
];
|
|
232
|
+
var mergeNode = (base, local, remote, schema, path, context, deleteWins) => {
|
|
233
|
+
if (schema.type === "local") return local;
|
|
234
|
+
if (equal(local, remote)) return local;
|
|
235
|
+
if (equal(local, base)) return remote;
|
|
236
|
+
if (equal(remote, base)) return local;
|
|
237
|
+
if (local === void 0 || remote === void 0) {
|
|
238
|
+
if (deleteWins) return void 0;
|
|
239
|
+
const taken = local === void 0 ? "remote" : "local";
|
|
240
|
+
context.conflicts.push({ path, kind: "delete-edit", base, local, remote, taken });
|
|
241
|
+
return taken === "local" ? local : remote;
|
|
242
|
+
}
|
|
243
|
+
switch (schema.type) {
|
|
244
|
+
case "max": {
|
|
245
|
+
const l = schema.order(local);
|
|
246
|
+
const r = schema.order(remote);
|
|
247
|
+
if (l === r) return record(base, local, remote, path, context);
|
|
248
|
+
return l > r ? local : remote;
|
|
249
|
+
}
|
|
250
|
+
case "join":
|
|
251
|
+
if (Array.isArray(local) && Array.isArray(remote)) {
|
|
252
|
+
return joinLists(Array.isArray(base) ? base : [], local, remote);
|
|
253
|
+
}
|
|
254
|
+
return record(base, local, remote, path, context);
|
|
255
|
+
case "object": {
|
|
256
|
+
if (!isObject(local) || !isObject(remote)) return record(base, local, remote, path, context);
|
|
257
|
+
const baseObject = isObject(base) ? base : {};
|
|
258
|
+
const ids = orderedIds(Object.keys(local), Object.keys(remote), Object.keys(baseObject));
|
|
259
|
+
const merged = mergeEntries(
|
|
260
|
+
ids,
|
|
261
|
+
(id) => [own2(baseObject, id), own2(local, id), own2(remote, id)],
|
|
262
|
+
(id) => own2(schema.fields, id) ?? schema.each ?? { type: "record" },
|
|
263
|
+
path,
|
|
264
|
+
context,
|
|
265
|
+
deleteWins || schema.deleteWins === true
|
|
266
|
+
);
|
|
267
|
+
return Object.fromEntries(merged);
|
|
268
|
+
}
|
|
269
|
+
case "keyed": {
|
|
270
|
+
const localEntries = Array.isArray(local) ? entriesOf(local, schema.key) : null;
|
|
271
|
+
const remoteEntries = Array.isArray(remote) ? entriesOf(remote, schema.key) : null;
|
|
272
|
+
const baseEntries = (Array.isArray(base) ? entriesOf(base, schema.key) : null) ?? /* @__PURE__ */ new Map();
|
|
273
|
+
if (!localEntries || !remoteEntries) return record(base, local, remote, path, context);
|
|
274
|
+
const ids = orderedIds(localEntries.keys(), remoteEntries.keys(), baseEntries.keys());
|
|
275
|
+
const merged = mergeEntries(
|
|
276
|
+
ids,
|
|
277
|
+
(id) => [baseEntries.get(id), localEntries.get(id), remoteEntries.get(id)],
|
|
278
|
+
() => schema.item,
|
|
279
|
+
path,
|
|
280
|
+
context,
|
|
281
|
+
deleteWins || schema.deleteWins === true
|
|
282
|
+
);
|
|
283
|
+
return [...merged.values()];
|
|
284
|
+
}
|
|
285
|
+
case "record":
|
|
286
|
+
return record(base, local, remote, path, context);
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
var merge = ({
|
|
290
|
+
base,
|
|
291
|
+
local,
|
|
292
|
+
remote,
|
|
293
|
+
schema,
|
|
294
|
+
prefer = "remote"
|
|
295
|
+
}) => {
|
|
296
|
+
const context = { prefer, conflicts: [] };
|
|
297
|
+
const merged = mergeNode(base, local, remote, schema, [], context, false);
|
|
298
|
+
return {
|
|
299
|
+
merged,
|
|
300
|
+
cloud: restoreCloudValues(merged, remote, schema),
|
|
301
|
+
conflicts: context.conflicts
|
|
302
|
+
};
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
// src/merge/resolve.ts
|
|
306
|
+
var own3 = (value, key) => Object.hasOwn(value, key) ? value[key] : void 0;
|
|
307
|
+
var childSchema2 = (schema, step) => {
|
|
308
|
+
if (schema.type === "object") {
|
|
309
|
+
const named = schema.fields && Object.hasOwn(schema.fields, step) ? schema.fields[step] : void 0;
|
|
310
|
+
return named ?? schema.each ?? { type: "record" };
|
|
311
|
+
}
|
|
312
|
+
return { type: "record" };
|
|
313
|
+
};
|
|
314
|
+
var setAt = (data, schema, path, value) => {
|
|
315
|
+
if (path.length === 0) return value;
|
|
316
|
+
const [step, ...rest] = path;
|
|
317
|
+
if (schema.type === "keyed") {
|
|
318
|
+
const list = Array.isArray(data) ? data : [];
|
|
319
|
+
const index = list.findIndex((item) => isObject(item) && item[schema.key] === step);
|
|
320
|
+
const next2 = setAt(index >= 0 ? list[index] : void 0, schema.item, rest, value);
|
|
321
|
+
if (index < 0) return next2 === void 0 ? list : [...list, next2];
|
|
322
|
+
if (next2 === void 0) return list.filter((_, i) => i !== index);
|
|
323
|
+
return list.map((item, i) => i === index ? next2 : item);
|
|
324
|
+
}
|
|
325
|
+
const object = isObject(data) ? data : {};
|
|
326
|
+
const next = setAt(own3(object, step), childSchema2(schema, step), rest, value);
|
|
327
|
+
if (next === void 0) {
|
|
328
|
+
return Object.fromEntries(Object.entries(object).filter(([key]) => key !== step));
|
|
329
|
+
}
|
|
330
|
+
return { ...object, [step]: next };
|
|
331
|
+
};
|
|
332
|
+
var resolveConflict = (merged, schema, conflict, side) => setAt(merged, schema, conflict.path, conflict[side]);
|
|
333
|
+
|
|
334
|
+
// src/provider/base64.ts
|
|
335
|
+
var CHUNK = 32768;
|
|
336
|
+
var encodeBase64 = (text) => {
|
|
337
|
+
const bytes = new TextEncoder().encode(text);
|
|
338
|
+
let binary = "";
|
|
339
|
+
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
340
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
|
341
|
+
}
|
|
342
|
+
return btoa(binary);
|
|
343
|
+
};
|
|
344
|
+
var decodeBase64 = (base64) => {
|
|
345
|
+
const binary = atob(base64.replace(/\s/g, ""));
|
|
346
|
+
return new TextDecoder().decode(Uint8Array.from(binary, (char) => char.charCodeAt(0)));
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
// src/provider/types.ts
|
|
350
|
+
var VersionConflictError = class extends Error {
|
|
351
|
+
constructor(path) {
|
|
352
|
+
super(`The file ${path} changed in the cloud.`);
|
|
353
|
+
this.path = path;
|
|
354
|
+
this.name = "VersionConflictError";
|
|
355
|
+
}
|
|
356
|
+
path;
|
|
357
|
+
};
|
|
358
|
+
var ProviderError = class extends Error {
|
|
359
|
+
constructor(kind, message, status, retryAfterSeconds) {
|
|
360
|
+
super(message);
|
|
361
|
+
this.kind = kind;
|
|
362
|
+
this.status = status;
|
|
363
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
364
|
+
this.name = "ProviderError";
|
|
365
|
+
}
|
|
366
|
+
kind;
|
|
367
|
+
status;
|
|
368
|
+
retryAfterSeconds;
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
// src/provider/github.ts
|
|
372
|
+
var API = "https://api.github.com";
|
|
373
|
+
var API_VERSION = "2022-11-28";
|
|
374
|
+
var encodePath = (path) => path.split("/").map(encodeURIComponent).join("/");
|
|
375
|
+
var isRecord2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
376
|
+
var retryAfterOf = (response) => {
|
|
377
|
+
const header = response.headers.get("retry-after");
|
|
378
|
+
if (header !== null && Number.isFinite(Number(header))) return Number(header);
|
|
379
|
+
const reset = response.headers.get("x-ratelimit-reset");
|
|
380
|
+
if (reset !== null && Number.isFinite(Number(reset))) {
|
|
381
|
+
return Math.max(0, Number(reset) - Math.floor(Date.now() / 1e3));
|
|
382
|
+
}
|
|
383
|
+
return void 0;
|
|
384
|
+
};
|
|
385
|
+
var GitHubProvider = class {
|
|
386
|
+
constructor(options) {
|
|
387
|
+
this.options = options;
|
|
388
|
+
this.send = options.fetch ?? ((...args) => globalThis.fetch(...args));
|
|
389
|
+
}
|
|
390
|
+
options;
|
|
391
|
+
send;
|
|
392
|
+
async read(path) {
|
|
393
|
+
const response = await this.request("GET", this.contentsUrl(path));
|
|
394
|
+
if (response.status === 404) return null;
|
|
395
|
+
await this.assertOk(response);
|
|
396
|
+
const body = await response.json();
|
|
397
|
+
if (typeof body.sha !== "string") throw this.invalid("The file has no version.");
|
|
398
|
+
if (body.encoding === "base64" && typeof body.content === "string") {
|
|
399
|
+
return { content: decodeBase64(body.content), version: body.sha };
|
|
400
|
+
}
|
|
401
|
+
if (body.encoding === "none") {
|
|
402
|
+
const raw = await this.request("GET", this.contentsUrl(path), void 0, {
|
|
403
|
+
Accept: "application/vnd.github.raw+json"
|
|
404
|
+
});
|
|
405
|
+
await this.assertOk(raw);
|
|
406
|
+
return { content: await raw.text(), version: body.sha };
|
|
407
|
+
}
|
|
408
|
+
throw this.invalid("The file content is not in a known encoding.");
|
|
409
|
+
}
|
|
410
|
+
async write(path, content, expectedVersion) {
|
|
411
|
+
const body = {
|
|
412
|
+
message: this.message(path),
|
|
413
|
+
content: encodeBase64(content)
|
|
414
|
+
};
|
|
415
|
+
if (expectedVersion !== null) body.sha = expectedVersion;
|
|
416
|
+
if (this.options.branch) body.branch = this.options.branch;
|
|
417
|
+
const response = await this.request("PUT", this.contentsUrl(path), body);
|
|
418
|
+
if (response.status === 409 || response.status === 422) throw new VersionConflictError(path);
|
|
419
|
+
await this.assertOk(response);
|
|
420
|
+
const result = await response.json();
|
|
421
|
+
const version = result.content?.sha;
|
|
422
|
+
if (typeof version !== "string") throw this.invalid("The write returned no version.");
|
|
423
|
+
return { version };
|
|
424
|
+
}
|
|
425
|
+
async remove(path, expectedVersion) {
|
|
426
|
+
const body = { message: this.message(path), sha: expectedVersion };
|
|
427
|
+
if (this.options.branch) body.branch = this.options.branch;
|
|
428
|
+
const response = await this.request("DELETE", this.contentsUrl(path), body);
|
|
429
|
+
if (response.status === 404) return;
|
|
430
|
+
if (response.status === 409 || response.status === 422) throw new VersionConflictError(path);
|
|
431
|
+
await this.assertOk(response);
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Checks that the repository can be used: it exists, the token can write to it, and it is
|
|
435
|
+
* private. The saved data of a player must never sit in a public repository.
|
|
436
|
+
*/
|
|
437
|
+
async checkRepository() {
|
|
438
|
+
const response = await this.request("GET", `${API}/repos/${this.options.repository}`);
|
|
439
|
+
await this.assertOk(response);
|
|
440
|
+
const body = await response.json();
|
|
441
|
+
if (!isRecord2(body) || typeof body.private !== "boolean") {
|
|
442
|
+
throw this.invalid("The repository answer is not understood.");
|
|
443
|
+
}
|
|
444
|
+
const permissions = isRecord2(body.permissions) ? body.permissions : {};
|
|
445
|
+
return {
|
|
446
|
+
private: body.private,
|
|
447
|
+
canWrite: permissions.push === true,
|
|
448
|
+
defaultBranch: typeof body.default_branch === "string" ? body.default_branch : "main"
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
contentsUrl(path) {
|
|
452
|
+
const base = `${API}/repos/${this.options.repository}/contents/${encodePath(path)}`;
|
|
453
|
+
return this.options.branch ? `${base}?ref=${encodeURIComponent(this.options.branch)}` : base;
|
|
454
|
+
}
|
|
455
|
+
message(path) {
|
|
456
|
+
return this.options.commitMessage?.(path) ?? `Gamerdex sync: ${path}`;
|
|
457
|
+
}
|
|
458
|
+
async request(method, url, body, headers = {}) {
|
|
459
|
+
const token = await this.options.getToken();
|
|
460
|
+
try {
|
|
461
|
+
return await this.send(url, {
|
|
462
|
+
method,
|
|
463
|
+
headers: {
|
|
464
|
+
Accept: "application/vnd.github+json",
|
|
465
|
+
Authorization: `Bearer ${token}`,
|
|
466
|
+
"X-GitHub-Api-Version": API_VERSION,
|
|
467
|
+
...body ? { "Content-Type": "application/json" } : {},
|
|
468
|
+
...headers
|
|
469
|
+
},
|
|
470
|
+
body: body ? JSON.stringify(body) : void 0
|
|
471
|
+
});
|
|
472
|
+
} catch {
|
|
473
|
+
throw new ProviderError("network", "GitHub could not be reached.");
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
async assertOk(response) {
|
|
477
|
+
if (response.ok) return;
|
|
478
|
+
const { status } = response;
|
|
479
|
+
if (status === 401) throw new ProviderError("auth", "The token is not valid.", status);
|
|
480
|
+
if (status === 403 || status === 429) {
|
|
481
|
+
const limited = status === 429 || response.headers.get("x-ratelimit-remaining") === "0";
|
|
482
|
+
if (limited) {
|
|
483
|
+
throw new ProviderError(
|
|
484
|
+
"rate-limit",
|
|
485
|
+
"GitHub limits the calls.",
|
|
486
|
+
status,
|
|
487
|
+
retryAfterOf(response)
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
throw new ProviderError("auth", "The token has no permission for the repository.", status);
|
|
491
|
+
}
|
|
492
|
+
if (status === 404)
|
|
493
|
+
throw new ProviderError("not-found", "The repository was not found.", status);
|
|
494
|
+
if (status >= 500) throw new ProviderError("server", "GitHub failed.", status);
|
|
495
|
+
throw this.invalid(`GitHub answered ${status}.`, status);
|
|
496
|
+
}
|
|
497
|
+
invalid(message, status) {
|
|
498
|
+
return new ProviderError("invalid", message, status);
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
// src/provider/memory.ts
|
|
503
|
+
var MemoryProvider = class {
|
|
504
|
+
files = /* @__PURE__ */ new Map();
|
|
505
|
+
counter = 0;
|
|
506
|
+
async read(path) {
|
|
507
|
+
const file = this.files.get(path);
|
|
508
|
+
return file ? { content: file.content, version: String(file.counter) } : null;
|
|
509
|
+
}
|
|
510
|
+
async write(path, content, expectedVersion) {
|
|
511
|
+
const current = this.files.get(path);
|
|
512
|
+
const currentVersion = current ? String(current.counter) : null;
|
|
513
|
+
if (currentVersion !== expectedVersion) throw new VersionConflictError(path);
|
|
514
|
+
this.counter += 1;
|
|
515
|
+
this.files.set(path, { content, counter: this.counter });
|
|
516
|
+
return { version: String(this.counter) };
|
|
517
|
+
}
|
|
518
|
+
async remove(path, expectedVersion) {
|
|
519
|
+
const current = this.files.get(path);
|
|
520
|
+
if (current && String(current.counter) !== expectedVersion) {
|
|
521
|
+
throw new VersionConflictError(path);
|
|
522
|
+
}
|
|
523
|
+
this.files.delete(path);
|
|
524
|
+
}
|
|
525
|
+
/** The paths of the files that exist, in the order they were first written. */
|
|
526
|
+
paths() {
|
|
527
|
+
return [...this.files.keys()];
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
// src/sync/sync.ts
|
|
532
|
+
var MAX_ATTEMPTS = 4;
|
|
533
|
+
var RemoteDataError = class extends Error {
|
|
534
|
+
constructor(message) {
|
|
535
|
+
super(message);
|
|
536
|
+
this.name = "RemoteDataError";
|
|
537
|
+
}
|
|
538
|
+
};
|
|
539
|
+
var SyncBusyError = class extends Error {
|
|
540
|
+
constructor() {
|
|
541
|
+
super("The cloud kept changing during the sync.");
|
|
542
|
+
this.name = "SyncBusyError";
|
|
543
|
+
}
|
|
544
|
+
};
|
|
545
|
+
var parseData = (text, validate) => {
|
|
546
|
+
let data;
|
|
547
|
+
try {
|
|
548
|
+
data = JSON.parse(text);
|
|
549
|
+
} catch {
|
|
550
|
+
throw new RemoteDataError("The synced data is not valid JSON.");
|
|
551
|
+
}
|
|
552
|
+
if (validate && !validate(data)) throw new RemoteDataError("The synced data is not valid.");
|
|
553
|
+
return data;
|
|
554
|
+
};
|
|
555
|
+
var readMeta = (text, appId) => {
|
|
556
|
+
if (text === void 0) return emptyMeta(appId);
|
|
557
|
+
const parsed = parseMeta(text, appId);
|
|
558
|
+
if (!parsed.ok) throw new RemoteDataError(parsed.error);
|
|
559
|
+
return parsed.meta;
|
|
560
|
+
};
|
|
561
|
+
var writeMeta = async (options) => {
|
|
562
|
+
const { provider, appId, deviceId, deviceName, updateMeta, now } = options;
|
|
563
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
|
|
564
|
+
const current = await provider.read(metaPath(appId));
|
|
565
|
+
const meta = readMeta(current?.content, appId);
|
|
566
|
+
const time = now();
|
|
567
|
+
const updated = withDevice(updateMeta ? updateMeta(meta) : meta, deviceId, {
|
|
568
|
+
name: deviceName,
|
|
569
|
+
lastSeenAt: time
|
|
570
|
+
});
|
|
571
|
+
try {
|
|
572
|
+
const written = await provider.write(
|
|
573
|
+
metaPath(appId),
|
|
574
|
+
JSON.stringify({ ...updated, lastSyncAt: time }, null, 2),
|
|
575
|
+
current?.version ?? null
|
|
576
|
+
);
|
|
577
|
+
return written.version;
|
|
578
|
+
} catch (error) {
|
|
579
|
+
if (!(error instanceof VersionConflictError)) throw error;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
throw new SyncBusyError();
|
|
583
|
+
};
|
|
584
|
+
var sync = async (options) => {
|
|
585
|
+
const { appId, provider, schema, local, state, firstSync, validate } = options;
|
|
586
|
+
const path = dataPath(appId);
|
|
587
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
|
|
588
|
+
const file = await provider.read(path);
|
|
589
|
+
if (file === null) {
|
|
590
|
+
if (validate && !validate(local))
|
|
591
|
+
throw new RemoteDataError("The data of this device is not valid.");
|
|
592
|
+
const cloud = restoreCloudValues(local, void 0, schema);
|
|
593
|
+
const created = await tryWrite(provider, path, cloud, null);
|
|
594
|
+
if (created === null) continue;
|
|
595
|
+
const metaVersion2 = await writeMeta(options);
|
|
596
|
+
return done(local, cloud, created, metaVersion2, [], true);
|
|
597
|
+
}
|
|
598
|
+
const remote = parseData(file.content, validate);
|
|
599
|
+
const differs = state === null && !equal(restoreCloudValues(local, remote, schema), remote);
|
|
600
|
+
const choice = differs ? firstSync : void 0;
|
|
601
|
+
if (differs && choice === void 0) return { status: "needs-choice", local, remote };
|
|
602
|
+
const base = state === null ? void 0 : state.base;
|
|
603
|
+
const merged = mergeFor(choice, { base, local, remote, schema });
|
|
604
|
+
if (validate && !validate(merged.merged)) {
|
|
605
|
+
throw new RemoteDataError("The merged data is not valid.");
|
|
606
|
+
}
|
|
607
|
+
if (equal(merged.cloud, remote)) {
|
|
608
|
+
const metaVersion2 = await writeMeta(options);
|
|
609
|
+
return done(merged.merged, merged.cloud, file.version, metaVersion2, merged.conflicts, false);
|
|
610
|
+
}
|
|
611
|
+
const version = await tryWrite(provider, path, merged.cloud, file.version);
|
|
612
|
+
if (version === null) continue;
|
|
613
|
+
const metaVersion = await writeMeta(options);
|
|
614
|
+
return done(merged.merged, merged.cloud, version, metaVersion, merged.conflicts, true);
|
|
615
|
+
}
|
|
616
|
+
throw new SyncBusyError();
|
|
617
|
+
};
|
|
618
|
+
var mergeFor = (choice, input) => {
|
|
619
|
+
if (choice === "keep-local") {
|
|
620
|
+
const cloud = restoreCloudValues(input.local, input.remote, input.schema);
|
|
621
|
+
return { merged: input.local, cloud, conflicts: [] };
|
|
622
|
+
}
|
|
623
|
+
if (choice === "keep-cloud") {
|
|
624
|
+
const merged = restoreCloudValues(input.remote, input.local, input.schema);
|
|
625
|
+
return { merged, cloud: input.remote, conflicts: [] };
|
|
626
|
+
}
|
|
627
|
+
return merge(input);
|
|
628
|
+
};
|
|
629
|
+
var tryWrite = async (provider, path, data, version) => {
|
|
630
|
+
try {
|
|
631
|
+
return (await provider.write(path, JSON.stringify(data, null, 2), version)).version;
|
|
632
|
+
} catch (error) {
|
|
633
|
+
if (error instanceof VersionConflictError) return null;
|
|
634
|
+
throw error;
|
|
635
|
+
}
|
|
636
|
+
};
|
|
637
|
+
var done = (data, cloud, dataVersion, metaVersion, conflicts, uploaded) => ({
|
|
638
|
+
status: "synced",
|
|
639
|
+
data,
|
|
640
|
+
state: { base: cloud, dataVersion, metaVersion },
|
|
641
|
+
conflicts,
|
|
642
|
+
uploaded
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
// src/auth/app.ts
|
|
646
|
+
var GITHUB_APP = {
|
|
647
|
+
slug: "gamerdex-sync",
|
|
648
|
+
clientId: "Iv23liwzjj3x8O2K5ALA"
|
|
649
|
+
};
|
|
650
|
+
|
|
651
|
+
// src/auth/tokens.ts
|
|
652
|
+
var isRecord3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
653
|
+
var isSeconds = (value) => typeof value === "number" && Number.isFinite(value) && value > 0;
|
|
654
|
+
var parseTokenResponse = (body) => {
|
|
655
|
+
if (!isRecord3(body)) return null;
|
|
656
|
+
const { accessToken, expiresIn, refreshToken, refreshExpiresIn } = body;
|
|
657
|
+
if (typeof accessToken !== "string" || accessToken === "") return null;
|
|
658
|
+
if (typeof refreshToken !== "string" || refreshToken === "") return null;
|
|
659
|
+
if (!isSeconds(expiresIn) || !isSeconds(refreshExpiresIn)) return null;
|
|
660
|
+
return { accessToken, expiresIn, refreshToken, refreshExpiresIn };
|
|
661
|
+
};
|
|
662
|
+
var toStoredTokens = (response, nowMs) => ({
|
|
663
|
+
accessToken: response.accessToken,
|
|
664
|
+
accessExpiresAt: nowMs + response.expiresIn * 1e3,
|
|
665
|
+
refreshToken: response.refreshToken,
|
|
666
|
+
refreshExpiresAt: nowMs + response.refreshExpiresIn * 1e3
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
// src/auth/source.ts
|
|
670
|
+
var RENEW_BEFORE_MS = 6e4;
|
|
671
|
+
var createTokenSource = (options) => {
|
|
672
|
+
const send = options.fetch ?? ((...args) => globalThis.fetch(...args));
|
|
673
|
+
let renewing = null;
|
|
674
|
+
const post = async (path, body) => {
|
|
675
|
+
let response;
|
|
676
|
+
try {
|
|
677
|
+
response = await send(`${options.endpoint}/${path}`, {
|
|
678
|
+
method: "POST",
|
|
679
|
+
headers: { "Content-Type": "application/json" },
|
|
680
|
+
body: JSON.stringify(body)
|
|
681
|
+
});
|
|
682
|
+
} catch {
|
|
683
|
+
throw new ProviderError("network", "The sign in service could not be reached.");
|
|
684
|
+
}
|
|
685
|
+
if (response.status >= 500) throw new ProviderError("server", "The sign in service failed.");
|
|
686
|
+
const parsed = response.ok ? parseTokenResponse(await response.json().catch(() => null)) : null;
|
|
687
|
+
if (parsed === null)
|
|
688
|
+
throw new ProviderError("auth", "The sign in was refused.", response.status);
|
|
689
|
+
const tokens = toStoredTokens(parsed, options.now());
|
|
690
|
+
options.save(tokens);
|
|
691
|
+
return tokens;
|
|
692
|
+
};
|
|
693
|
+
const renew = (tokens) => {
|
|
694
|
+
if (tokens.refreshExpiresAt <= options.now()) {
|
|
695
|
+
options.save(null);
|
|
696
|
+
throw new ProviderError("auth", "The sign in expired.");
|
|
697
|
+
}
|
|
698
|
+
renewing ??= post("refresh", { refreshToken: tokens.refreshToken }).catch((error) => {
|
|
699
|
+
if (error instanceof ProviderError && error.kind === "auth") options.save(null);
|
|
700
|
+
throw error;
|
|
701
|
+
}).finally(() => {
|
|
702
|
+
renewing = null;
|
|
703
|
+
});
|
|
704
|
+
return renewing;
|
|
705
|
+
};
|
|
706
|
+
return {
|
|
707
|
+
/** Finishes a sign in with the code from the callback page. */
|
|
708
|
+
signIn: (code, redirectUri) => post("token", { code, redirectUri }),
|
|
709
|
+
signOut: () => options.save(null),
|
|
710
|
+
isSignedIn: () => options.load() !== null,
|
|
711
|
+
getToken: async () => {
|
|
712
|
+
const tokens = options.load();
|
|
713
|
+
if (tokens === null) throw new ProviderError("auth", "The player is not signed in.");
|
|
714
|
+
if (tokens.accessExpiresAt - RENEW_BEFORE_MS > options.now()) return tokens.accessToken;
|
|
715
|
+
return (await renew(tokens)).accessToken;
|
|
716
|
+
}
|
|
717
|
+
};
|
|
718
|
+
};
|
|
719
|
+
|
|
720
|
+
// src/auth/urls.ts
|
|
721
|
+
var newState = () => crypto.randomUUID();
|
|
722
|
+
var authorizeUrl = (redirectUri, state) => {
|
|
723
|
+
const query = new URLSearchParams({
|
|
724
|
+
client_id: GITHUB_APP.clientId,
|
|
725
|
+
redirect_uri: redirectUri,
|
|
726
|
+
state
|
|
727
|
+
});
|
|
728
|
+
return `https://github.com/login/oauth/authorize?${query}`;
|
|
729
|
+
};
|
|
730
|
+
var installUrl = (state) => `https://github.com/apps/${GITHUB_APP.slug}/installations/new?${new URLSearchParams({ state })}`;
|
|
731
|
+
var readCallback = (search, expectedState) => {
|
|
732
|
+
const query = new URLSearchParams(search);
|
|
733
|
+
if (query.get("error") !== null) return { ok: false, reason: "denied" };
|
|
734
|
+
const code = query.get("code");
|
|
735
|
+
if (code === null || code === "") return { ok: false, reason: "missing" };
|
|
736
|
+
if (expectedState === null || query.get("state") !== expectedState) {
|
|
737
|
+
return { ok: false, reason: "state" };
|
|
738
|
+
}
|
|
739
|
+
return { ok: true, code };
|
|
740
|
+
};
|
|
741
|
+
|
|
742
|
+
// src/provider/repositories.ts
|
|
743
|
+
var API2 = "https://api.github.com";
|
|
744
|
+
var PAGE_SIZE = 100;
|
|
745
|
+
var isRecord4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
746
|
+
var getJson = async (send, token, url) => {
|
|
747
|
+
let response;
|
|
748
|
+
try {
|
|
749
|
+
response = await send(url, {
|
|
750
|
+
headers: {
|
|
751
|
+
Accept: "application/vnd.github+json",
|
|
752
|
+
Authorization: `Bearer ${token}`,
|
|
753
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
754
|
+
}
|
|
755
|
+
});
|
|
756
|
+
} catch {
|
|
757
|
+
throw new ProviderError("network", "GitHub could not be reached.");
|
|
758
|
+
}
|
|
759
|
+
if (response.status === 401 || response.status === 403) {
|
|
760
|
+
throw new ProviderError("auth", "The token is not valid.", response.status);
|
|
761
|
+
}
|
|
762
|
+
if (response.status >= 500) throw new ProviderError("server", "GitHub failed.", response.status);
|
|
763
|
+
const body = response.ok ? await response.json().catch(() => null) : null;
|
|
764
|
+
if (!isRecord4(body))
|
|
765
|
+
throw new ProviderError("invalid", "GitHub answer is not understood.", response.status);
|
|
766
|
+
return body;
|
|
767
|
+
};
|
|
768
|
+
var listInstalledRepositories = async (token, send = (...args) => globalThis.fetch(...args)) => {
|
|
769
|
+
const installations = await getJson(
|
|
770
|
+
send,
|
|
771
|
+
token,
|
|
772
|
+
`${API2}/user/installations?per_page=${PAGE_SIZE}`
|
|
773
|
+
);
|
|
774
|
+
const ids = (Array.isArray(installations.installations) ? installations.installations : []).filter(isRecord4).map((installation) => installation.id).filter((id) => typeof id === "number");
|
|
775
|
+
const repositories = [];
|
|
776
|
+
for (const id of ids) {
|
|
777
|
+
const page = await getJson(
|
|
778
|
+
send,
|
|
779
|
+
token,
|
|
780
|
+
`${API2}/user/installations/${id}/repositories?per_page=${PAGE_SIZE}`
|
|
781
|
+
);
|
|
782
|
+
for (const item of Array.isArray(page.repositories) ? page.repositories : []) {
|
|
783
|
+
if (isRecord4(item) && typeof item.full_name === "string" && typeof item.private === "boolean") {
|
|
784
|
+
repositories.push({ fullName: item.full_name, private: item.private });
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
return repositories;
|
|
789
|
+
};
|
|
790
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
791
|
+
0 && (module.exports = {
|
|
792
|
+
DATA_FILE,
|
|
793
|
+
GITHUB_APP,
|
|
794
|
+
GitHubProvider,
|
|
795
|
+
META_FILE,
|
|
796
|
+
META_FORMAT,
|
|
797
|
+
MemoryProvider,
|
|
798
|
+
ProviderError,
|
|
799
|
+
ROOT_FOLDER,
|
|
800
|
+
RemoteDataError,
|
|
801
|
+
SyncBusyError,
|
|
802
|
+
VersionConflictError,
|
|
803
|
+
authorizeUrl,
|
|
804
|
+
createTokenSource,
|
|
805
|
+
dataPath,
|
|
806
|
+
decodeBase64,
|
|
807
|
+
emptyMeta,
|
|
808
|
+
encodeBase64,
|
|
809
|
+
equal,
|
|
810
|
+
installUrl,
|
|
811
|
+
isAppId,
|
|
812
|
+
isObject,
|
|
813
|
+
listInstalledRepositories,
|
|
814
|
+
merge,
|
|
815
|
+
metaPath,
|
|
816
|
+
newState,
|
|
817
|
+
parseMeta,
|
|
818
|
+
parseTokenResponse,
|
|
819
|
+
readCallback,
|
|
820
|
+
resolveConflict,
|
|
821
|
+
restoreCloudValues,
|
|
822
|
+
sync,
|
|
823
|
+
toStoredTokens,
|
|
824
|
+
withChiefRemoved,
|
|
825
|
+
withChiefRevision,
|
|
826
|
+
withDevice,
|
|
827
|
+
withSettingsRevision
|
|
828
|
+
});
|
|
829
|
+
//# sourceMappingURL=index.js.map
|