@gamerdexapp/gamerdex-sync 0.1.0 → 0.2.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/dist/index.mjs CHANGED
@@ -285,6 +285,16 @@ var decodeBase64 = (base64) => {
285
285
  return new TextDecoder().decode(Uint8Array.from(binary, (char) => char.charCodeAt(0)));
286
286
  };
287
287
 
288
+ // src/provider/detail.ts
289
+ var MAX_DETAIL_LENGTH = 300;
290
+ var isRecord2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
291
+ var detailOf = async (response) => {
292
+ const body = await response.json().catch(() => null);
293
+ if (!isRecord2(body)) return void 0;
294
+ const message = isRecord2(body.error) ? body.error.message : body.message;
295
+ return typeof message === "string" ? message.slice(0, MAX_DETAIL_LENGTH) : void 0;
296
+ };
297
+
288
298
  // src/provider/types.ts
289
299
  var VersionConflictError = class extends Error {
290
300
  constructor(path) {
@@ -295,23 +305,25 @@ var VersionConflictError = class extends Error {
295
305
  path;
296
306
  };
297
307
  var ProviderError = class extends Error {
298
- constructor(kind, message, status, retryAfterSeconds) {
308
+ constructor(kind, message, status, retryAfterSeconds, detail) {
299
309
  super(message);
300
310
  this.kind = kind;
301
311
  this.status = status;
302
312
  this.retryAfterSeconds = retryAfterSeconds;
313
+ this.detail = detail;
303
314
  this.name = "ProviderError";
304
315
  }
305
316
  kind;
306
317
  status;
307
318
  retryAfterSeconds;
319
+ detail;
308
320
  };
309
321
 
310
322
  // src/provider/github.ts
311
323
  var API = "https://api.github.com";
312
324
  var API_VERSION = "2022-11-28";
313
325
  var encodePath = (path) => path.split("/").map(encodeURIComponent).join("/");
314
- var isRecord2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
326
+ var isRecord3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
315
327
  var retryAfterOf = (response) => {
316
328
  const header = response.headers.get("retry-after");
317
329
  if (header !== null && Number.isFinite(Number(header))) return Number(header);
@@ -377,10 +389,10 @@ var GitHubProvider = class {
377
389
  const response = await this.request("GET", `${API}/repos/${this.options.repository}`);
378
390
  await this.assertOk(response);
379
391
  const body = await response.json();
380
- if (!isRecord2(body) || typeof body.private !== "boolean") {
392
+ if (!isRecord3(body) || typeof body.private !== "boolean") {
381
393
  throw this.invalid("The repository answer is not understood.");
382
394
  }
383
- const permissions = isRecord2(body.permissions) ? body.permissions : {};
395
+ const permissions = isRecord3(body.permissions) ? body.permissions : {};
384
396
  return {
385
397
  private: body.private,
386
398
  canWrite: permissions.push === true,
@@ -415,7 +427,10 @@ var GitHubProvider = class {
415
427
  async assertOk(response) {
416
428
  if (response.ok) return;
417
429
  const { status } = response;
418
- if (status === 401) throw new ProviderError("auth", "The token is not valid.", status);
430
+ const detail = await detailOf(response);
431
+ if (status === 401) {
432
+ throw new ProviderError("auth", "The token is not valid.", status, void 0, detail);
433
+ }
419
434
  if (status === 403 || status === 429) {
420
435
  const limited = status === 429 || response.headers.get("x-ratelimit-remaining") === "0";
421
436
  if (limited) {
@@ -423,18 +438,34 @@ var GitHubProvider = class {
423
438
  "rate-limit",
424
439
  "GitHub limits the calls.",
425
440
  status,
426
- retryAfterOf(response)
441
+ retryAfterOf(response),
442
+ detail
427
443
  );
428
444
  }
429
- throw new ProviderError("auth", "The token has no permission for the repository.", status);
445
+ throw new ProviderError(
446
+ "auth",
447
+ "The token has no permission for the repository.",
448
+ status,
449
+ void 0,
450
+ detail
451
+ );
452
+ }
453
+ if (status === 404) {
454
+ throw new ProviderError(
455
+ "not-found",
456
+ "The repository was not found.",
457
+ status,
458
+ void 0,
459
+ detail
460
+ );
430
461
  }
431
- if (status === 404)
432
- throw new ProviderError("not-found", "The repository was not found.", status);
433
- if (status >= 500) throw new ProviderError("server", "GitHub failed.", status);
434
- throw this.invalid(`GitHub answered ${status}.`, status);
462
+ if (status >= 500) {
463
+ throw new ProviderError("server", "GitHub failed.", status, void 0, detail);
464
+ }
465
+ throw this.invalid(`GitHub answered ${status}.`, status, detail);
435
466
  }
436
- invalid(message, status) {
437
- return new ProviderError("invalid", message, status);
467
+ invalid(message, status, detail) {
468
+ return new ProviderError("invalid", message, status, void 0, detail);
438
469
  }
439
470
  };
440
471
 
@@ -497,11 +528,14 @@ var readMeta = (text, appId) => {
497
528
  if (!parsed.ok) throw new RemoteDataError(parsed.error);
498
529
  return parsed.meta;
499
530
  };
500
- var writeMeta = async (options) => {
531
+ var writeMeta = async (options, uploaded) => {
501
532
  const { provider, appId, deviceId, deviceName, updateMeta, now } = options;
502
533
  for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
503
534
  const current = await provider.read(metaPath(appId));
504
535
  const meta = readMeta(current?.content, appId);
536
+ if (current && !uploaded && !updateMeta && Object.hasOwn(meta.devices, deviceId)) {
537
+ return current.version;
538
+ }
505
539
  const time = now();
506
540
  const updated = withDevice(updateMeta ? updateMeta(meta) : meta, deviceId, {
507
541
  name: deviceName,
@@ -531,7 +565,7 @@ var sync = async (options) => {
531
565
  const cloud = restoreCloudValues(local, void 0, schema);
532
566
  const created = await tryWrite(provider, path, cloud, null);
533
567
  if (created === null) continue;
534
- const metaVersion2 = await writeMeta(options);
568
+ const metaVersion2 = await writeMeta(options, true);
535
569
  return done(local, cloud, created, metaVersion2, [], true);
536
570
  }
537
571
  const remote = parseData(file.content, validate);
@@ -544,12 +578,12 @@ var sync = async (options) => {
544
578
  throw new RemoteDataError("The merged data is not valid.");
545
579
  }
546
580
  if (equal(merged.cloud, remote)) {
547
- const metaVersion2 = await writeMeta(options);
581
+ const metaVersion2 = await writeMeta(options, false);
548
582
  return done(merged.merged, merged.cloud, file.version, metaVersion2, merged.conflicts, false);
549
583
  }
550
584
  const version = await tryWrite(provider, path, merged.cloud, file.version);
551
585
  if (version === null) continue;
552
- const metaVersion = await writeMeta(options);
586
+ const metaVersion = await writeMeta(options, true);
553
587
  return done(merged.merged, merged.cloud, version, metaVersion, merged.conflicts, true);
554
588
  }
555
589
  throw new SyncBusyError();
@@ -588,10 +622,10 @@ var GITHUB_APP = {
588
622
  };
589
623
 
590
624
  // src/auth/tokens.ts
591
- var isRecord3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
625
+ var isRecord4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
592
626
  var isSeconds = (value) => typeof value === "number" && Number.isFinite(value) && value > 0;
593
627
  var parseTokenResponse = (body) => {
594
- if (!isRecord3(body)) return null;
628
+ if (!isRecord4(body)) return null;
595
629
  const { accessToken, expiresIn, refreshToken, refreshExpiresIn } = body;
596
630
  if (typeof accessToken !== "string" || accessToken === "") return null;
597
631
  if (typeof refreshToken !== "string" || refreshToken === "") return null;
@@ -681,7 +715,7 @@ var readCallback = (search, expectedState) => {
681
715
  // src/provider/repositories.ts
682
716
  var API2 = "https://api.github.com";
683
717
  var PAGE_SIZE = 100;
684
- var isRecord4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
718
+ var isRecord5 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
685
719
  var getJson = async (send, token, url) => {
686
720
  let response;
687
721
  try {
@@ -695,13 +729,26 @@ var getJson = async (send, token, url) => {
695
729
  } catch {
696
730
  throw new ProviderError("network", "GitHub could not be reached.");
697
731
  }
698
- if (response.status === 401 || response.status === 403) {
699
- throw new ProviderError("auth", "The token is not valid.", response.status);
732
+ if (!response.ok) {
733
+ const { status } = response;
734
+ const detail = await detailOf(response);
735
+ if (status === 401 || status === 403) {
736
+ throw new ProviderError("auth", "The token is not valid.", status, void 0, detail);
737
+ }
738
+ if (status >= 500)
739
+ throw new ProviderError("server", "GitHub failed.", status, void 0, detail);
740
+ throw new ProviderError(
741
+ "invalid",
742
+ "GitHub answer is not understood.",
743
+ status,
744
+ void 0,
745
+ detail
746
+ );
700
747
  }
701
- if (response.status >= 500) throw new ProviderError("server", "GitHub failed.", response.status);
702
- const body = response.ok ? await response.json().catch(() => null) : null;
703
- if (!isRecord4(body))
748
+ const body = await response.json().catch(() => null);
749
+ if (!isRecord5(body)) {
704
750
  throw new ProviderError("invalid", "GitHub answer is not understood.", response.status);
751
+ }
705
752
  return body;
706
753
  };
707
754
  var listInstalledRepositories = async (token, send = (...args) => globalThis.fetch(...args)) => {
@@ -710,7 +757,7 @@ var listInstalledRepositories = async (token, send = (...args) => globalThis.fet
710
757
  token,
711
758
  `${API2}/user/installations?per_page=${PAGE_SIZE}`
712
759
  );
713
- const ids = (Array.isArray(installations.installations) ? installations.installations : []).filter(isRecord4).map((installation) => installation.id).filter((id) => typeof id === "number");
760
+ const ids = (Array.isArray(installations.installations) ? installations.installations : []).filter(isRecord5).map((installation) => installation.id).filter((id) => typeof id === "number");
714
761
  const repositories = [];
715
762
  for (const id of ids) {
716
763
  const page = await getJson(
@@ -719,17 +766,285 @@ var listInstalledRepositories = async (token, send = (...args) => globalThis.fet
719
766
  `${API2}/user/installations/${id}/repositories?per_page=${PAGE_SIZE}`
720
767
  );
721
768
  for (const item of Array.isArray(page.repositories) ? page.repositories : []) {
722
- if (isRecord4(item) && typeof item.full_name === "string" && typeof item.private === "boolean") {
769
+ if (isRecord5(item) && typeof item.full_name === "string" && typeof item.private === "boolean") {
723
770
  repositories.push({ fullName: item.full_name, private: item.private });
724
771
  }
725
772
  }
726
773
  }
727
774
  return repositories;
728
775
  };
776
+
777
+ // src/provider/drive.ts
778
+ var API3 = "https://www.googleapis.com/drive/v3";
779
+ var UPLOAD = "https://www.googleapis.com/upload/drive/v3";
780
+ var FOLDER_TYPE = "application/vnd.google-apps.folder";
781
+ var RATE_LIMIT_REASONS = ["rateLimitExceeded", "userRateLimitExceeded", "dailyLimitExceeded"];
782
+ var isRecord6 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
783
+ var quoted = (value) => `'${value.replace(/[\\']/g, "\\$&")}'`;
784
+ var oldestFirst = (a, b) => a.createdTime.localeCompare(b.createdTime) || a.id.localeCompare(b.id);
785
+ var reasonOf = (body) => {
786
+ if (!isRecord6(body) || !isRecord6(body.error) || !Array.isArray(body.error.errors))
787
+ return void 0;
788
+ const first = body.error.errors[0];
789
+ return isRecord6(first) && typeof first.reason === "string" ? first.reason : void 0;
790
+ };
791
+ var GoogleDriveProvider = class {
792
+ constructor(options) {
793
+ this.options = options;
794
+ this.send = options.fetch ?? ((...args) => globalThis.fetch(...args));
795
+ }
796
+ options;
797
+ send;
798
+ folders = /* @__PURE__ */ new Map();
799
+ async read(path) {
800
+ const file = await this.findFile(path);
801
+ if (!file) return null;
802
+ const head = await this.headOf(file.id);
803
+ if (head === null) return null;
804
+ return { content: await this.revisionContent(file.id, head), version: head };
805
+ }
806
+ async write(path, content, expectedVersion) {
807
+ if (expectedVersion === null) return this.create(path, content);
808
+ const file = await this.findFile(path);
809
+ if (!file || await this.headOf(file.id) !== expectedVersion) {
810
+ throw new VersionConflictError(path);
811
+ }
812
+ const version = await this.upload(file.id, content);
813
+ if (version === expectedVersion) return { version };
814
+ const previous = await this.revisionBefore(file.id, version);
815
+ if (previous === expectedVersion) return { version };
816
+ await this.upload(file.id, await this.revisionContent(file.id, previous));
817
+ throw new VersionConflictError(path);
818
+ }
819
+ async remove(path, expectedVersion) {
820
+ const file = await this.findFile(path);
821
+ if (!file) return;
822
+ if (await this.headOf(file.id) !== expectedVersion) throw new VersionConflictError(path);
823
+ await this.deleteItem(file.id);
824
+ }
825
+ async create(path, content) {
826
+ const { parentId, name } = await this.locate(path, true);
827
+ if ((await this.children(parentId, name)).length > 0) throw new VersionConflictError(path);
828
+ const created = await this.createFile(parentId, name, content);
829
+ const [oldest] = await this.children(parentId, name);
830
+ if (oldest.id !== created.id) {
831
+ await this.deleteItem(created.id);
832
+ throw new VersionConflictError(path);
833
+ }
834
+ return { version: created.version };
835
+ }
836
+ async findFile(path) {
837
+ const located = await this.locate(path, false);
838
+ if (!located) return null;
839
+ return (await this.children(located.parentId, located.name))[0] ?? null;
840
+ }
841
+ async locate(path, create) {
842
+ const parts = path.split("/");
843
+ const name = parts.pop();
844
+ let parentId = "root";
845
+ let walked = "";
846
+ for (const folder of parts) {
847
+ walked += `/${folder}`;
848
+ const known = this.folders.get(walked);
849
+ if (known) {
850
+ parentId = known;
851
+ continue;
852
+ }
853
+ const id = await this.folderId(parentId, folder, create);
854
+ if (id === null) return null;
855
+ this.folders.set(walked, id);
856
+ parentId = id;
857
+ }
858
+ return { parentId, name };
859
+ }
860
+ async folderId(parentId, name, create) {
861
+ const found = await this.children(parentId, name, true);
862
+ if (found.length > 0) return found[0].id;
863
+ if (!create) return null;
864
+ const response = await this.request("POST", `${API3}/files?fields=id`, {
865
+ body: JSON.stringify({ name, mimeType: FOLDER_TYPE, parents: [parentId] }),
866
+ headers: { "Content-Type": "application/json" }
867
+ });
868
+ const id = this.stringField(await response.json(), "id");
869
+ const [oldest] = await this.children(parentId, name, true);
870
+ if (oldest.id !== id) {
871
+ await this.deleteItem(id);
872
+ return oldest.id;
873
+ }
874
+ return id;
875
+ }
876
+ async children(parentId, name, folder = false) {
877
+ const query = [
878
+ `name = ${quoted(name)}`,
879
+ `${quoted(parentId)} in parents`,
880
+ "trashed = false",
881
+ `mimeType ${folder ? "=" : "!="} '${FOLDER_TYPE}'`
882
+ ].join(" and ");
883
+ const url = `${API3}/files?${new URLSearchParams({
884
+ q: query,
885
+ fields: "files(id,createdTime)",
886
+ pageSize: "100"
887
+ })}`;
888
+ const body = await (await this.request("GET", url)).json();
889
+ const files = isRecord6(body) && Array.isArray(body.files) ? body.files : [];
890
+ return files.filter(
891
+ (item) => isRecord6(item) && typeof item.id === "string" && typeof item.createdTime === "string"
892
+ ).sort(oldestFirst);
893
+ }
894
+ async headOf(id) {
895
+ const response = await this.request(
896
+ "GET",
897
+ `${API3}/files/${encodeURIComponent(id)}?fields=headRevisionId,trashed`,
898
+ { allowMissing: true }
899
+ );
900
+ if (response.status === 404) return null;
901
+ const body = await response.json();
902
+ if (isRecord6(body) && body.trashed === true) return null;
903
+ return this.stringField(body, "headRevisionId");
904
+ }
905
+ async revisionContent(id, revision) {
906
+ const url = `${API3}/files/${encodeURIComponent(id)}/revisions/${encodeURIComponent(revision)}?alt=media`;
907
+ return (await this.request("GET", url)).text();
908
+ }
909
+ async revisionBefore(id, revision) {
910
+ const url = `${API3}/files/${encodeURIComponent(id)}/revisions?${new URLSearchParams({
911
+ fields: "revisions(id)",
912
+ pageSize: "1000"
913
+ })}`;
914
+ const body = await (await this.request("GET", url)).json();
915
+ const revisions = (isRecord6(body) && Array.isArray(body.revisions) ? body.revisions : []).filter(isRecord6).map((item) => item.id);
916
+ const index = revisions.indexOf(revision);
917
+ const previous = revisions[index - 1];
918
+ if (index < 1 || typeof previous !== "string") {
919
+ throw new ProviderError("server", "The new revision is not in the revision list.");
920
+ }
921
+ return previous;
922
+ }
923
+ async upload(id, content) {
924
+ const url = `${UPLOAD}/files/${encodeURIComponent(id)}?uploadType=media&fields=headRevisionId`;
925
+ const response = await this.request("PATCH", url, {
926
+ body: content,
927
+ headers: { "Content-Type": "application/json" }
928
+ });
929
+ return this.stringField(await response.json(), "headRevisionId");
930
+ }
931
+ async createFile(parentId, name, content) {
932
+ const boundary = `gamerdex-${crypto.randomUUID()}`;
933
+ const metadata = JSON.stringify({ name, parents: [parentId], mimeType: "application/json" });
934
+ const body = `--${boundary}\r
935
+ Content-Type: application/json; charset=UTF-8\r
936
+ \r
937
+ ${metadata}\r
938
+ --${boundary}\r
939
+ Content-Type: application/json\r
940
+ \r
941
+ ${content}\r
942
+ --${boundary}--`;
943
+ const response = await this.request(
944
+ "POST",
945
+ `${UPLOAD}/files?uploadType=multipart&fields=id,headRevisionId`,
946
+ { body, headers: { "Content-Type": `multipart/related; boundary=${boundary}` } }
947
+ );
948
+ const answer = await response.json();
949
+ return {
950
+ id: this.stringField(answer, "id"),
951
+ version: this.stringField(answer, "headRevisionId")
952
+ };
953
+ }
954
+ async deleteItem(id) {
955
+ await this.request("DELETE", `${API3}/files/${encodeURIComponent(id)}`, { allowMissing: true });
956
+ }
957
+ stringField(body, field) {
958
+ const value = isRecord6(body) ? body[field] : void 0;
959
+ if (typeof value !== "string") {
960
+ throw new ProviderError("invalid", `Google Drive answered without ${field}.`);
961
+ }
962
+ return value;
963
+ }
964
+ async request(method, url, options = {}) {
965
+ const token = await this.options.getToken();
966
+ let response;
967
+ try {
968
+ response = await this.send(url, {
969
+ method,
970
+ headers: { Authorization: `Bearer ${token}`, ...options.headers ?? {} },
971
+ body: options.body
972
+ });
973
+ } catch {
974
+ throw new ProviderError("network", "Google Drive could not be reached.");
975
+ }
976
+ if (response.ok || options.allowMissing && response.status === 404) return response;
977
+ throw await this.failure(response);
978
+ }
979
+ async failure(response) {
980
+ const { status } = response;
981
+ const body = await response.clone().json().catch(() => null);
982
+ const detail = await detailOf(response);
983
+ if (status === 401) {
984
+ return new ProviderError("auth", "The token is not valid.", status, void 0, detail);
985
+ }
986
+ const reason = reasonOf(body);
987
+ if (status === 429 || status === 403 && reason && RATE_LIMIT_REASONS.includes(reason)) {
988
+ const retryAfter = Number(response.headers.get("retry-after"));
989
+ return new ProviderError(
990
+ "rate-limit",
991
+ "Google Drive limits the calls.",
992
+ status,
993
+ Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : void 0,
994
+ detail
995
+ );
996
+ }
997
+ if (status === 403) {
998
+ return new ProviderError(
999
+ "auth",
1000
+ "The token has no permission for Drive.",
1001
+ status,
1002
+ void 0,
1003
+ detail
1004
+ );
1005
+ }
1006
+ if (status === 404) {
1007
+ return new ProviderError(
1008
+ "not-found",
1009
+ "The file or folder was not found.",
1010
+ status,
1011
+ void 0,
1012
+ detail
1013
+ );
1014
+ }
1015
+ if (status >= 500) {
1016
+ return new ProviderError("server", "Google Drive failed.", status, void 0, detail);
1017
+ }
1018
+ return new ProviderError(
1019
+ "invalid",
1020
+ `Google Drive answered ${status}.`,
1021
+ status,
1022
+ void 0,
1023
+ detail
1024
+ );
1025
+ }
1026
+ };
1027
+
1028
+ // src/auth/google.ts
1029
+ var GOOGLE_DRIVE_SCOPE = "https://www.googleapis.com/auth/drive.file";
1030
+ var googleAuthorizeUrl = (clientId, redirectUri, state) => {
1031
+ const query = new URLSearchParams({
1032
+ client_id: clientId,
1033
+ redirect_uri: redirectUri,
1034
+ response_type: "code",
1035
+ scope: GOOGLE_DRIVE_SCOPE,
1036
+ access_type: "offline",
1037
+ prompt: "consent",
1038
+ state
1039
+ });
1040
+ return `https://accounts.google.com/o/oauth2/v2/auth?${query}`;
1041
+ };
729
1042
  export {
730
1043
  DATA_FILE,
731
1044
  GITHUB_APP,
1045
+ GOOGLE_DRIVE_SCOPE,
732
1046
  GitHubProvider,
1047
+ GoogleDriveProvider,
733
1048
  META_FILE,
734
1049
  META_FORMAT,
735
1050
  MemoryProvider,
@@ -745,6 +1060,7 @@ export {
745
1060
  emptyMeta,
746
1061
  encodeBase64,
747
1062
  equal,
1063
+ googleAuthorizeUrl,
748
1064
  installUrl,
749
1065
  isAppId,
750
1066
  isObject,