@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.js CHANGED
@@ -22,7 +22,9 @@ var index_exports = {};
22
22
  __export(index_exports, {
23
23
  DATA_FILE: () => DATA_FILE,
24
24
  GITHUB_APP: () => GITHUB_APP,
25
+ GOOGLE_DRIVE_SCOPE: () => GOOGLE_DRIVE_SCOPE,
25
26
  GitHubProvider: () => GitHubProvider,
27
+ GoogleDriveProvider: () => GoogleDriveProvider,
26
28
  META_FILE: () => META_FILE,
27
29
  META_FORMAT: () => META_FORMAT,
28
30
  MemoryProvider: () => MemoryProvider,
@@ -38,6 +40,7 @@ __export(index_exports, {
38
40
  emptyMeta: () => emptyMeta,
39
41
  encodeBase64: () => encodeBase64,
40
42
  equal: () => equal,
43
+ googleAuthorizeUrl: () => googleAuthorizeUrl,
41
44
  installUrl: () => installUrl,
42
45
  isAppId: () => isAppId,
43
46
  isObject: () => isObject,
@@ -346,6 +349,16 @@ var decodeBase64 = (base64) => {
346
349
  return new TextDecoder().decode(Uint8Array.from(binary, (char) => char.charCodeAt(0)));
347
350
  };
348
351
 
352
+ // src/provider/detail.ts
353
+ var MAX_DETAIL_LENGTH = 300;
354
+ var isRecord2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
355
+ var detailOf = async (response) => {
356
+ const body = await response.json().catch(() => null);
357
+ if (!isRecord2(body)) return void 0;
358
+ const message = isRecord2(body.error) ? body.error.message : body.message;
359
+ return typeof message === "string" ? message.slice(0, MAX_DETAIL_LENGTH) : void 0;
360
+ };
361
+
349
362
  // src/provider/types.ts
350
363
  var VersionConflictError = class extends Error {
351
364
  constructor(path) {
@@ -356,23 +369,25 @@ var VersionConflictError = class extends Error {
356
369
  path;
357
370
  };
358
371
  var ProviderError = class extends Error {
359
- constructor(kind, message, status, retryAfterSeconds) {
372
+ constructor(kind, message, status, retryAfterSeconds, detail) {
360
373
  super(message);
361
374
  this.kind = kind;
362
375
  this.status = status;
363
376
  this.retryAfterSeconds = retryAfterSeconds;
377
+ this.detail = detail;
364
378
  this.name = "ProviderError";
365
379
  }
366
380
  kind;
367
381
  status;
368
382
  retryAfterSeconds;
383
+ detail;
369
384
  };
370
385
 
371
386
  // src/provider/github.ts
372
387
  var API = "https://api.github.com";
373
388
  var API_VERSION = "2022-11-28";
374
389
  var encodePath = (path) => path.split("/").map(encodeURIComponent).join("/");
375
- var isRecord2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
390
+ var isRecord3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
376
391
  var retryAfterOf = (response) => {
377
392
  const header = response.headers.get("retry-after");
378
393
  if (header !== null && Number.isFinite(Number(header))) return Number(header);
@@ -438,10 +453,10 @@ var GitHubProvider = class {
438
453
  const response = await this.request("GET", `${API}/repos/${this.options.repository}`);
439
454
  await this.assertOk(response);
440
455
  const body = await response.json();
441
- if (!isRecord2(body) || typeof body.private !== "boolean") {
456
+ if (!isRecord3(body) || typeof body.private !== "boolean") {
442
457
  throw this.invalid("The repository answer is not understood.");
443
458
  }
444
- const permissions = isRecord2(body.permissions) ? body.permissions : {};
459
+ const permissions = isRecord3(body.permissions) ? body.permissions : {};
445
460
  return {
446
461
  private: body.private,
447
462
  canWrite: permissions.push === true,
@@ -476,7 +491,10 @@ var GitHubProvider = class {
476
491
  async assertOk(response) {
477
492
  if (response.ok) return;
478
493
  const { status } = response;
479
- if (status === 401) throw new ProviderError("auth", "The token is not valid.", status);
494
+ const detail = await detailOf(response);
495
+ if (status === 401) {
496
+ throw new ProviderError("auth", "The token is not valid.", status, void 0, detail);
497
+ }
480
498
  if (status === 403 || status === 429) {
481
499
  const limited = status === 429 || response.headers.get("x-ratelimit-remaining") === "0";
482
500
  if (limited) {
@@ -484,18 +502,34 @@ var GitHubProvider = class {
484
502
  "rate-limit",
485
503
  "GitHub limits the calls.",
486
504
  status,
487
- retryAfterOf(response)
505
+ retryAfterOf(response),
506
+ detail
488
507
  );
489
508
  }
490
- throw new ProviderError("auth", "The token has no permission for the repository.", status);
509
+ throw new ProviderError(
510
+ "auth",
511
+ "The token has no permission for the repository.",
512
+ status,
513
+ void 0,
514
+ detail
515
+ );
516
+ }
517
+ if (status === 404) {
518
+ throw new ProviderError(
519
+ "not-found",
520
+ "The repository was not found.",
521
+ status,
522
+ void 0,
523
+ detail
524
+ );
491
525
  }
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);
526
+ if (status >= 500) {
527
+ throw new ProviderError("server", "GitHub failed.", status, void 0, detail);
528
+ }
529
+ throw this.invalid(`GitHub answered ${status}.`, status, detail);
496
530
  }
497
- invalid(message, status) {
498
- return new ProviderError("invalid", message, status);
531
+ invalid(message, status, detail) {
532
+ return new ProviderError("invalid", message, status, void 0, detail);
499
533
  }
500
534
  };
501
535
 
@@ -558,11 +592,14 @@ var readMeta = (text, appId) => {
558
592
  if (!parsed.ok) throw new RemoteDataError(parsed.error);
559
593
  return parsed.meta;
560
594
  };
561
- var writeMeta = async (options) => {
595
+ var writeMeta = async (options, uploaded) => {
562
596
  const { provider, appId, deviceId, deviceName, updateMeta, now } = options;
563
597
  for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
564
598
  const current = await provider.read(metaPath(appId));
565
599
  const meta = readMeta(current?.content, appId);
600
+ if (current && !uploaded && !updateMeta && Object.hasOwn(meta.devices, deviceId)) {
601
+ return current.version;
602
+ }
566
603
  const time = now();
567
604
  const updated = withDevice(updateMeta ? updateMeta(meta) : meta, deviceId, {
568
605
  name: deviceName,
@@ -592,7 +629,7 @@ var sync = async (options) => {
592
629
  const cloud = restoreCloudValues(local, void 0, schema);
593
630
  const created = await tryWrite(provider, path, cloud, null);
594
631
  if (created === null) continue;
595
- const metaVersion2 = await writeMeta(options);
632
+ const metaVersion2 = await writeMeta(options, true);
596
633
  return done(local, cloud, created, metaVersion2, [], true);
597
634
  }
598
635
  const remote = parseData(file.content, validate);
@@ -605,12 +642,12 @@ var sync = async (options) => {
605
642
  throw new RemoteDataError("The merged data is not valid.");
606
643
  }
607
644
  if (equal(merged.cloud, remote)) {
608
- const metaVersion2 = await writeMeta(options);
645
+ const metaVersion2 = await writeMeta(options, false);
609
646
  return done(merged.merged, merged.cloud, file.version, metaVersion2, merged.conflicts, false);
610
647
  }
611
648
  const version = await tryWrite(provider, path, merged.cloud, file.version);
612
649
  if (version === null) continue;
613
- const metaVersion = await writeMeta(options);
650
+ const metaVersion = await writeMeta(options, true);
614
651
  return done(merged.merged, merged.cloud, version, metaVersion, merged.conflicts, true);
615
652
  }
616
653
  throw new SyncBusyError();
@@ -649,10 +686,10 @@ var GITHUB_APP = {
649
686
  };
650
687
 
651
688
  // src/auth/tokens.ts
652
- var isRecord3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
689
+ var isRecord4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
653
690
  var isSeconds = (value) => typeof value === "number" && Number.isFinite(value) && value > 0;
654
691
  var parseTokenResponse = (body) => {
655
- if (!isRecord3(body)) return null;
692
+ if (!isRecord4(body)) return null;
656
693
  const { accessToken, expiresIn, refreshToken, refreshExpiresIn } = body;
657
694
  if (typeof accessToken !== "string" || accessToken === "") return null;
658
695
  if (typeof refreshToken !== "string" || refreshToken === "") return null;
@@ -742,7 +779,7 @@ var readCallback = (search, expectedState) => {
742
779
  // src/provider/repositories.ts
743
780
  var API2 = "https://api.github.com";
744
781
  var PAGE_SIZE = 100;
745
- var isRecord4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
782
+ var isRecord5 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
746
783
  var getJson = async (send, token, url) => {
747
784
  let response;
748
785
  try {
@@ -756,13 +793,26 @@ var getJson = async (send, token, url) => {
756
793
  } catch {
757
794
  throw new ProviderError("network", "GitHub could not be reached.");
758
795
  }
759
- if (response.status === 401 || response.status === 403) {
760
- throw new ProviderError("auth", "The token is not valid.", response.status);
796
+ if (!response.ok) {
797
+ const { status } = response;
798
+ const detail = await detailOf(response);
799
+ if (status === 401 || status === 403) {
800
+ throw new ProviderError("auth", "The token is not valid.", status, void 0, detail);
801
+ }
802
+ if (status >= 500)
803
+ throw new ProviderError("server", "GitHub failed.", status, void 0, detail);
804
+ throw new ProviderError(
805
+ "invalid",
806
+ "GitHub answer is not understood.",
807
+ status,
808
+ void 0,
809
+ detail
810
+ );
761
811
  }
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))
812
+ const body = await response.json().catch(() => null);
813
+ if (!isRecord5(body)) {
765
814
  throw new ProviderError("invalid", "GitHub answer is not understood.", response.status);
815
+ }
766
816
  return body;
767
817
  };
768
818
  var listInstalledRepositories = async (token, send = (...args) => globalThis.fetch(...args)) => {
@@ -771,7 +821,7 @@ var listInstalledRepositories = async (token, send = (...args) => globalThis.fet
771
821
  token,
772
822
  `${API2}/user/installations?per_page=${PAGE_SIZE}`
773
823
  );
774
- const ids = (Array.isArray(installations.installations) ? installations.installations : []).filter(isRecord4).map((installation) => installation.id).filter((id) => typeof id === "number");
824
+ const ids = (Array.isArray(installations.installations) ? installations.installations : []).filter(isRecord5).map((installation) => installation.id).filter((id) => typeof id === "number");
775
825
  const repositories = [];
776
826
  for (const id of ids) {
777
827
  const page = await getJson(
@@ -780,18 +830,286 @@ var listInstalledRepositories = async (token, send = (...args) => globalThis.fet
780
830
  `${API2}/user/installations/${id}/repositories?per_page=${PAGE_SIZE}`
781
831
  );
782
832
  for (const item of Array.isArray(page.repositories) ? page.repositories : []) {
783
- if (isRecord4(item) && typeof item.full_name === "string" && typeof item.private === "boolean") {
833
+ if (isRecord5(item) && typeof item.full_name === "string" && typeof item.private === "boolean") {
784
834
  repositories.push({ fullName: item.full_name, private: item.private });
785
835
  }
786
836
  }
787
837
  }
788
838
  return repositories;
789
839
  };
840
+
841
+ // src/provider/drive.ts
842
+ var API3 = "https://www.googleapis.com/drive/v3";
843
+ var UPLOAD = "https://www.googleapis.com/upload/drive/v3";
844
+ var FOLDER_TYPE = "application/vnd.google-apps.folder";
845
+ var RATE_LIMIT_REASONS = ["rateLimitExceeded", "userRateLimitExceeded", "dailyLimitExceeded"];
846
+ var isRecord6 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
847
+ var quoted = (value) => `'${value.replace(/[\\']/g, "\\$&")}'`;
848
+ var oldestFirst = (a, b) => a.createdTime.localeCompare(b.createdTime) || a.id.localeCompare(b.id);
849
+ var reasonOf = (body) => {
850
+ if (!isRecord6(body) || !isRecord6(body.error) || !Array.isArray(body.error.errors))
851
+ return void 0;
852
+ const first = body.error.errors[0];
853
+ return isRecord6(first) && typeof first.reason === "string" ? first.reason : void 0;
854
+ };
855
+ var GoogleDriveProvider = class {
856
+ constructor(options) {
857
+ this.options = options;
858
+ this.send = options.fetch ?? ((...args) => globalThis.fetch(...args));
859
+ }
860
+ options;
861
+ send;
862
+ folders = /* @__PURE__ */ new Map();
863
+ async read(path) {
864
+ const file = await this.findFile(path);
865
+ if (!file) return null;
866
+ const head = await this.headOf(file.id);
867
+ if (head === null) return null;
868
+ return { content: await this.revisionContent(file.id, head), version: head };
869
+ }
870
+ async write(path, content, expectedVersion) {
871
+ if (expectedVersion === null) return this.create(path, content);
872
+ const file = await this.findFile(path);
873
+ if (!file || await this.headOf(file.id) !== expectedVersion) {
874
+ throw new VersionConflictError(path);
875
+ }
876
+ const version = await this.upload(file.id, content);
877
+ if (version === expectedVersion) return { version };
878
+ const previous = await this.revisionBefore(file.id, version);
879
+ if (previous === expectedVersion) return { version };
880
+ await this.upload(file.id, await this.revisionContent(file.id, previous));
881
+ throw new VersionConflictError(path);
882
+ }
883
+ async remove(path, expectedVersion) {
884
+ const file = await this.findFile(path);
885
+ if (!file) return;
886
+ if (await this.headOf(file.id) !== expectedVersion) throw new VersionConflictError(path);
887
+ await this.deleteItem(file.id);
888
+ }
889
+ async create(path, content) {
890
+ const { parentId, name } = await this.locate(path, true);
891
+ if ((await this.children(parentId, name)).length > 0) throw new VersionConflictError(path);
892
+ const created = await this.createFile(parentId, name, content);
893
+ const [oldest] = await this.children(parentId, name);
894
+ if (oldest.id !== created.id) {
895
+ await this.deleteItem(created.id);
896
+ throw new VersionConflictError(path);
897
+ }
898
+ return { version: created.version };
899
+ }
900
+ async findFile(path) {
901
+ const located = await this.locate(path, false);
902
+ if (!located) return null;
903
+ return (await this.children(located.parentId, located.name))[0] ?? null;
904
+ }
905
+ async locate(path, create) {
906
+ const parts = path.split("/");
907
+ const name = parts.pop();
908
+ let parentId = "root";
909
+ let walked = "";
910
+ for (const folder of parts) {
911
+ walked += `/${folder}`;
912
+ const known = this.folders.get(walked);
913
+ if (known) {
914
+ parentId = known;
915
+ continue;
916
+ }
917
+ const id = await this.folderId(parentId, folder, create);
918
+ if (id === null) return null;
919
+ this.folders.set(walked, id);
920
+ parentId = id;
921
+ }
922
+ return { parentId, name };
923
+ }
924
+ async folderId(parentId, name, create) {
925
+ const found = await this.children(parentId, name, true);
926
+ if (found.length > 0) return found[0].id;
927
+ if (!create) return null;
928
+ const response = await this.request("POST", `${API3}/files?fields=id`, {
929
+ body: JSON.stringify({ name, mimeType: FOLDER_TYPE, parents: [parentId] }),
930
+ headers: { "Content-Type": "application/json" }
931
+ });
932
+ const id = this.stringField(await response.json(), "id");
933
+ const [oldest] = await this.children(parentId, name, true);
934
+ if (oldest.id !== id) {
935
+ await this.deleteItem(id);
936
+ return oldest.id;
937
+ }
938
+ return id;
939
+ }
940
+ async children(parentId, name, folder = false) {
941
+ const query = [
942
+ `name = ${quoted(name)}`,
943
+ `${quoted(parentId)} in parents`,
944
+ "trashed = false",
945
+ `mimeType ${folder ? "=" : "!="} '${FOLDER_TYPE}'`
946
+ ].join(" and ");
947
+ const url = `${API3}/files?${new URLSearchParams({
948
+ q: query,
949
+ fields: "files(id,createdTime)",
950
+ pageSize: "100"
951
+ })}`;
952
+ const body = await (await this.request("GET", url)).json();
953
+ const files = isRecord6(body) && Array.isArray(body.files) ? body.files : [];
954
+ return files.filter(
955
+ (item) => isRecord6(item) && typeof item.id === "string" && typeof item.createdTime === "string"
956
+ ).sort(oldestFirst);
957
+ }
958
+ async headOf(id) {
959
+ const response = await this.request(
960
+ "GET",
961
+ `${API3}/files/${encodeURIComponent(id)}?fields=headRevisionId,trashed`,
962
+ { allowMissing: true }
963
+ );
964
+ if (response.status === 404) return null;
965
+ const body = await response.json();
966
+ if (isRecord6(body) && body.trashed === true) return null;
967
+ return this.stringField(body, "headRevisionId");
968
+ }
969
+ async revisionContent(id, revision) {
970
+ const url = `${API3}/files/${encodeURIComponent(id)}/revisions/${encodeURIComponent(revision)}?alt=media`;
971
+ return (await this.request("GET", url)).text();
972
+ }
973
+ async revisionBefore(id, revision) {
974
+ const url = `${API3}/files/${encodeURIComponent(id)}/revisions?${new URLSearchParams({
975
+ fields: "revisions(id)",
976
+ pageSize: "1000"
977
+ })}`;
978
+ const body = await (await this.request("GET", url)).json();
979
+ const revisions = (isRecord6(body) && Array.isArray(body.revisions) ? body.revisions : []).filter(isRecord6).map((item) => item.id);
980
+ const index = revisions.indexOf(revision);
981
+ const previous = revisions[index - 1];
982
+ if (index < 1 || typeof previous !== "string") {
983
+ throw new ProviderError("server", "The new revision is not in the revision list.");
984
+ }
985
+ return previous;
986
+ }
987
+ async upload(id, content) {
988
+ const url = `${UPLOAD}/files/${encodeURIComponent(id)}?uploadType=media&fields=headRevisionId`;
989
+ const response = await this.request("PATCH", url, {
990
+ body: content,
991
+ headers: { "Content-Type": "application/json" }
992
+ });
993
+ return this.stringField(await response.json(), "headRevisionId");
994
+ }
995
+ async createFile(parentId, name, content) {
996
+ const boundary = `gamerdex-${crypto.randomUUID()}`;
997
+ const metadata = JSON.stringify({ name, parents: [parentId], mimeType: "application/json" });
998
+ const body = `--${boundary}\r
999
+ Content-Type: application/json; charset=UTF-8\r
1000
+ \r
1001
+ ${metadata}\r
1002
+ --${boundary}\r
1003
+ Content-Type: application/json\r
1004
+ \r
1005
+ ${content}\r
1006
+ --${boundary}--`;
1007
+ const response = await this.request(
1008
+ "POST",
1009
+ `${UPLOAD}/files?uploadType=multipart&fields=id,headRevisionId`,
1010
+ { body, headers: { "Content-Type": `multipart/related; boundary=${boundary}` } }
1011
+ );
1012
+ const answer = await response.json();
1013
+ return {
1014
+ id: this.stringField(answer, "id"),
1015
+ version: this.stringField(answer, "headRevisionId")
1016
+ };
1017
+ }
1018
+ async deleteItem(id) {
1019
+ await this.request("DELETE", `${API3}/files/${encodeURIComponent(id)}`, { allowMissing: true });
1020
+ }
1021
+ stringField(body, field) {
1022
+ const value = isRecord6(body) ? body[field] : void 0;
1023
+ if (typeof value !== "string") {
1024
+ throw new ProviderError("invalid", `Google Drive answered without ${field}.`);
1025
+ }
1026
+ return value;
1027
+ }
1028
+ async request(method, url, options = {}) {
1029
+ const token = await this.options.getToken();
1030
+ let response;
1031
+ try {
1032
+ response = await this.send(url, {
1033
+ method,
1034
+ headers: { Authorization: `Bearer ${token}`, ...options.headers ?? {} },
1035
+ body: options.body
1036
+ });
1037
+ } catch {
1038
+ throw new ProviderError("network", "Google Drive could not be reached.");
1039
+ }
1040
+ if (response.ok || options.allowMissing && response.status === 404) return response;
1041
+ throw await this.failure(response);
1042
+ }
1043
+ async failure(response) {
1044
+ const { status } = response;
1045
+ const body = await response.clone().json().catch(() => null);
1046
+ const detail = await detailOf(response);
1047
+ if (status === 401) {
1048
+ return new ProviderError("auth", "The token is not valid.", status, void 0, detail);
1049
+ }
1050
+ const reason = reasonOf(body);
1051
+ if (status === 429 || status === 403 && reason && RATE_LIMIT_REASONS.includes(reason)) {
1052
+ const retryAfter = Number(response.headers.get("retry-after"));
1053
+ return new ProviderError(
1054
+ "rate-limit",
1055
+ "Google Drive limits the calls.",
1056
+ status,
1057
+ Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : void 0,
1058
+ detail
1059
+ );
1060
+ }
1061
+ if (status === 403) {
1062
+ return new ProviderError(
1063
+ "auth",
1064
+ "The token has no permission for Drive.",
1065
+ status,
1066
+ void 0,
1067
+ detail
1068
+ );
1069
+ }
1070
+ if (status === 404) {
1071
+ return new ProviderError(
1072
+ "not-found",
1073
+ "The file or folder was not found.",
1074
+ status,
1075
+ void 0,
1076
+ detail
1077
+ );
1078
+ }
1079
+ if (status >= 500) {
1080
+ return new ProviderError("server", "Google Drive failed.", status, void 0, detail);
1081
+ }
1082
+ return new ProviderError(
1083
+ "invalid",
1084
+ `Google Drive answered ${status}.`,
1085
+ status,
1086
+ void 0,
1087
+ detail
1088
+ );
1089
+ }
1090
+ };
1091
+
1092
+ // src/auth/google.ts
1093
+ var GOOGLE_DRIVE_SCOPE = "https://www.googleapis.com/auth/drive.file";
1094
+ var googleAuthorizeUrl = (clientId, redirectUri, state) => {
1095
+ const query = new URLSearchParams({
1096
+ client_id: clientId,
1097
+ redirect_uri: redirectUri,
1098
+ response_type: "code",
1099
+ scope: GOOGLE_DRIVE_SCOPE,
1100
+ access_type: "offline",
1101
+ prompt: "consent",
1102
+ state
1103
+ });
1104
+ return `https://accounts.google.com/o/oauth2/v2/auth?${query}`;
1105
+ };
790
1106
  // Annotate the CommonJS export names for ESM import in node:
791
1107
  0 && (module.exports = {
792
1108
  DATA_FILE,
793
1109
  GITHUB_APP,
1110
+ GOOGLE_DRIVE_SCOPE,
794
1111
  GitHubProvider,
1112
+ GoogleDriveProvider,
795
1113
  META_FILE,
796
1114
  META_FORMAT,
797
1115
  MemoryProvider,
@@ -807,6 +1125,7 @@ var listInstalledRepositories = async (token, send = (...args) => globalThis.fet
807
1125
  emptyMeta,
808
1126
  encodeBase64,
809
1127
  equal,
1128
+ googleAuthorizeUrl,
810
1129
  installUrl,
811
1130
  isAppId,
812
1131
  isObject,