@tinycloud/sdk-services 3.1.0-beta.1 → 3.1.0-beta.10

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.
@@ -34,6 +34,10 @@ var ErrorCodes = {
34
34
  AUTH_EXPIRED: "AUTH_EXPIRED",
35
35
  AUTH_REQUIRED: "AUTH_REQUIRED",
36
36
  AUTH_UNAUTHORIZED: "AUTH_UNAUTHORIZED",
37
+ /** HTTP 401: the delegation the invocation cites was revoked. */
38
+ AUTH_DELEGATION_REVOKED: "AUTH_DELEGATION_REVOKED",
39
+ /** HTTP 401: a delegation the cited one rests on was revoked. */
40
+ AUTH_DELEGATION_ANCESTOR_REVOKED: "AUTH_DELEGATION_ANCESTOR_REVOKED",
37
41
  NETWORK_ERROR: "NETWORK_ERROR",
38
42
  TIMEOUT: "TIMEOUT",
39
43
  ABORTED: "ABORTED",
@@ -45,6 +49,10 @@ var ErrorCodes = {
45
49
  KV_PRECONDITION_FAILED: "KV_PRECONDITION_FAILED",
46
50
  KV_CONFLICT: "KV_CONFLICT",
47
51
  KV_RESPONSE_TOO_LARGE: "KV_RESPONSE_TOO_LARGE",
52
+ /** HTTP 410 from the change feed: discard sync state and restart without a cursor; `meta.reason` says why. */
53
+ KV_SYNC_RESET_REQUIRED: "KV_SYNC_RESET_REQUIRED",
54
+ /** HTTP 403 from the change feed: the retention grant was refused; `meta.reason` says why. */
55
+ KV_RETENTION_GRANT_REFUSED: "KV_RETENTION_GRANT_REFUSED",
48
56
  // SQL-specific errors
49
57
  SQL_ERROR: "SQL_ERROR",
50
58
  SQL_PERMISSION_DENIED: "SQL_PERMISSION_DENIED",
@@ -100,6 +108,7 @@ function serviceError(code, message, service, options) {
100
108
  }
101
109
 
102
110
  // src/errors.ts
111
+ var import_bootstrap = require("@tinycloud/bootstrap");
103
112
  function authRequiredError(service) {
104
113
  return {
105
114
  code: ErrorCodes.AUTH_REQUIRED,
@@ -107,11 +116,31 @@ function authRequiredError(service) {
107
116
  service
108
117
  };
109
118
  }
110
- function timeoutError(service) {
119
+ var RequestTimeoutError = class extends Error {
120
+ constructor(timeoutMs) {
121
+ super(`Request timed out after ${timeoutMs}ms.`);
122
+ this.name = "TimeoutError";
123
+ this.timeoutMs = timeoutMs;
124
+ }
125
+ };
126
+ function isRequestTimeoutError(error) {
127
+ return error instanceof RequestTimeoutError || error instanceof Error && error.name === "TimeoutError" && typeof error.timeoutMs === "number";
128
+ }
129
+ function timeoutError(service, timeoutMs, cause) {
130
+ if (timeoutMs === void 0) {
131
+ return {
132
+ code: ErrorCodes.TIMEOUT,
133
+ message: "Request timed out.",
134
+ service,
135
+ ...cause === void 0 ? {} : { cause }
136
+ };
137
+ }
111
138
  return {
112
139
  code: ErrorCodes.TIMEOUT,
113
- message: "Request timed out.",
114
- service
140
+ message: `Request timed out after ${timeoutMs}ms.`,
141
+ service,
142
+ ...cause === void 0 ? {} : { cause },
143
+ meta: { timeoutMs }
115
144
  };
116
145
  }
117
146
  function abortedError(service) {
@@ -128,8 +157,47 @@ function parseAuthError(responseText) {
128
157
  }
129
158
  return {};
130
159
  }
160
+ var grantableActions = new Set(
161
+ import_bootstrap.CAPABILITY_REGISTRY.map(({ urn }) => urn).filter((urn) => !urn.endsWith("/*"))
162
+ );
163
+ var capabilityServices = new Set(
164
+ import_bootstrap.CAPABILITY_REGISTRY.map(({ service }) => service.slice("tinycloud.".length))
165
+ );
166
+ function storageQuotaExceededError(service, message, meta) {
167
+ return {
168
+ code: ErrorCodes.STORAGE_QUOTA_EXCEEDED,
169
+ message,
170
+ service,
171
+ meta
172
+ };
173
+ }
174
+ function storageLimitReachedError(service, message, meta) {
175
+ return {
176
+ code: ErrorCodes.STORAGE_LIMIT_REACHED,
177
+ message,
178
+ service,
179
+ meta
180
+ };
181
+ }
182
+ var STORAGE_FULL_MESSAGE = "TinyCloud storage is full, so this change was not saved. Reading still works. Free up space or upgrade your plan to save again.";
183
+ var STORAGE_WRITE_TOO_LARGE_MESSAGE = "This change is larger than the TinyCloud storage you have left, so it was not saved. Reading still works. Free up space or upgrade your plan to save it.";
184
+ function parseStorageQuotaBytes(responseText) {
185
+ const match = responseText.match(/Used:\s*(\d+)\s*bytes,\s*Limit:\s*(\d+)\s*bytes/i);
186
+ if (!match) return void 0;
187
+ return {
188
+ usedBytes: parseInt(match[1], 10),
189
+ limitBytes: parseInt(match[2], 10)
190
+ };
191
+ }
192
+ function storageRejectionError(service, status, meta, responseText) {
193
+ const quotaMeta = { ...meta, ...parseStorageQuotaBytes(responseText) };
194
+ return status === 402 ? storageQuotaExceededError(service, STORAGE_FULL_MESSAGE, quotaMeta) : storageLimitReachedError(service, STORAGE_WRITE_TOO_LARGE_MESSAGE, quotaMeta);
195
+ }
131
196
  function wrapError(service, error, defaultCode = ErrorCodes.NETWORK_ERROR) {
132
197
  if (error instanceof Error) {
198
+ if (isRequestTimeoutError(error)) {
199
+ return timeoutError(service, error.timeoutMs, error);
200
+ }
133
201
  if (error.name === "AbortError") {
134
202
  return abortedError(service);
135
203
  }
@@ -151,6 +219,7 @@ function wrapError(service, error, defaultCode = ErrorCodes.NETWORK_ERROR) {
151
219
  }
152
220
 
153
221
  // src/base/BaseService.ts
222
+ var MAX_TIMER_DELAY_MS = 2147483647;
154
223
  var BaseService = class {
155
224
  constructor() {
156
225
  /**
@@ -331,6 +400,64 @@ var BaseService = class {
331
400
  }
332
401
  return controller.signal;
333
402
  }
403
+ /**
404
+ * Create the abort signal for one request, bounded by the request timeout.
405
+ *
406
+ * The signal aborts when the service signs out, the context aborts, the
407
+ * caller's `signal` aborts, or the timeout elapses, whichever comes first.
408
+ * A timeout aborts with a {@link RequestTimeoutError} (`TimeoutError`), which
409
+ * `wrapError` maps to `ErrorCodes.TIMEOUT`; the other sources keep their own
410
+ * reason (normally `AbortError`, mapped to `ErrorCodes.ABORTED`).
411
+ *
412
+ * The timeout is `timeoutMs` when given, otherwise `config.timeout`. Only a
413
+ * positive, finite value (at most 2^31-1 ms) applies; `undefined`, `0`, and
414
+ * `Infinity` mean no timeout, so a per-call `0` opts out of a configured one.
415
+ *
416
+ * Callers must call `dispose()` once the response body has been consumed,
417
+ * which clears the timer and removes the listeners this adds to the
418
+ * long-lived service and context signals.
419
+ *
420
+ * @param signal - The caller's abort signal for this request
421
+ * @param timeoutMs - Per-request timeout override in milliseconds
422
+ */
423
+ createRequestSignal(signal, timeoutMs) {
424
+ const controller = new AbortController();
425
+ const detachers = [];
426
+ let timer;
427
+ const dispose = () => {
428
+ if (timer !== void 0) {
429
+ clearTimeout(timer);
430
+ timer = void 0;
431
+ }
432
+ for (const detach of detachers.splice(0)) {
433
+ detach();
434
+ }
435
+ };
436
+ const abort = (reason) => {
437
+ dispose();
438
+ controller.abort(reason);
439
+ };
440
+ const requestSignal = { signal: controller.signal, dispose };
441
+ const parents = [
442
+ this.abortController.signal,
443
+ this.context?.abortSignal,
444
+ signal
445
+ ].filter((parent) => parent !== void 0);
446
+ for (const parent of parents) {
447
+ if (parent.aborted) {
448
+ abort(parent.reason);
449
+ return requestSignal;
450
+ }
451
+ const onAbort = () => abort(parent.reason);
452
+ parent.addEventListener("abort", onAbort, { once: true });
453
+ detachers.push(() => parent.removeEventListener("abort", onAbort));
454
+ }
455
+ const timeout = timeoutMs ?? this._config.timeout;
456
+ if (typeof timeout === "number" && timeout > 0 && timeout <= MAX_TIMER_DELAY_MS) {
457
+ timer = setTimeout(() => abort(new RequestTimeoutError(timeout)), timeout);
458
+ }
459
+ return requestSignal;
460
+ }
334
461
  /**
335
462
  * Wrap an operation with error handling and telemetry.
336
463
  *
@@ -432,16 +559,16 @@ var DatabaseHandle = class {
432
559
  };
433
560
 
434
561
  // src/sql/types.ts
435
- var import_bootstrap = require("@tinycloud/bootstrap");
562
+ var import_bootstrap2 = require("@tinycloud/bootstrap");
436
563
  var SQLAction = {
437
- READ: import_bootstrap.SQL.READ,
438
- WRITE: import_bootstrap.SQL.WRITE,
439
- SCHEMA: import_bootstrap.SQL.SCHEMA,
440
- ADMIN: import_bootstrap.SQL.ADMIN,
441
- SELECT: import_bootstrap.SQL.SELECT,
442
- EXECUTE: import_bootstrap.SQL.EXECUTE,
443
- EXPORT: import_bootstrap.SQL.EXPORT,
444
- ALL: import_bootstrap.SQL.ALL
564
+ READ: import_bootstrap2.SQL.READ,
565
+ WRITE: import_bootstrap2.SQL.WRITE,
566
+ SCHEMA: import_bootstrap2.SQL.SCHEMA,
567
+ ADMIN: import_bootstrap2.SQL.ADMIN,
568
+ SELECT: import_bootstrap2.SQL.SELECT,
569
+ EXECUTE: import_bootstrap2.SQL.EXECUTE,
570
+ EXPORT: import_bootstrap2.SQL.EXPORT,
571
+ ALL: import_bootstrap2.SQL.ALL
445
572
  };
446
573
 
447
574
  // src/sql/SQLService.ts
@@ -519,6 +646,7 @@ var SQLService = class extends BaseService {
519
646
  if (!this.requireAuth()) {
520
647
  return err(authRequiredError("sql"));
521
648
  }
649
+ const request = this.createRequestSignal(options?.signal);
522
650
  try {
523
651
  const body = {
524
652
  action: "query",
@@ -527,19 +655,22 @@ var SQLService = class extends BaseService {
527
655
  };
528
656
  if (options?.maxRows !== void 0) body.maxRows = options.maxRows;
529
657
  if (options?.maxBytes !== void 0) body.maxBytes = options.maxBytes;
658
+ const action = this.actionForSql(sql, SQLAction.READ);
530
659
  const response = await this.invokeSQL(
531
660
  dbName,
532
- this.actionForSql(sql, SQLAction.READ),
661
+ action,
533
662
  body,
534
- options?.signal
663
+ request.signal
535
664
  );
536
665
  if (!response.ok) {
537
- return this.handleErrorResponse(response, "query");
666
+ return this.handleErrorResponse(response, "query", dbName, [action]);
538
667
  }
539
668
  const data = await response.json();
540
669
  return ok(data);
541
670
  } catch (error) {
542
671
  return err(wrapError("sql", error));
672
+ } finally {
673
+ request.dispose();
543
674
  }
544
675
  });
545
676
  }
@@ -548,6 +679,7 @@ var SQLService = class extends BaseService {
548
679
  if (!this.requireAuth()) {
549
680
  return err(authRequiredError("sql"));
550
681
  }
682
+ const request = this.createRequestSignal(options?.signal);
551
683
  try {
552
684
  const body = {
553
685
  action: "execute",
@@ -563,19 +695,22 @@ var SQLService = class extends BaseService {
563
695
  (statement) => this.actionForSql(statement, SQLAction.SCHEMA)
564
696
  )
565
697
  ];
698
+ const requestedActions = this.dedupeActions(actions);
566
699
  const response = await this.invokeSQL(
567
700
  dbName,
568
- this.dedupeActions(actions),
701
+ requestedActions,
569
702
  body,
570
- options?.signal
703
+ request.signal
571
704
  );
572
705
  if (!response.ok) {
573
- return this.handleErrorResponse(response, "execute");
706
+ return this.handleErrorResponse(response, "execute", dbName, requestedActions);
574
707
  }
575
708
  const data = await response.json();
576
709
  return ok(data);
577
710
  } catch (error) {
578
711
  return err(wrapError("sql", error));
712
+ } finally {
713
+ request.dispose();
579
714
  }
580
715
  });
581
716
  }
@@ -584,20 +719,24 @@ var SQLService = class extends BaseService {
584
719
  if (!this.requireAuth()) {
585
720
  return err(authRequiredError("sql"));
586
721
  }
722
+ const request = this.createRequestSignal(options?.signal);
587
723
  try {
724
+ const requestedActions = this.actionsForSqlBatch(statements);
588
725
  const response = await this.invokeSQL(
589
726
  dbName,
590
- this.actionsForSqlBatch(statements),
727
+ requestedActions,
591
728
  { action: "batch", statements },
592
- options?.signal
729
+ request.signal
593
730
  );
594
731
  if (!response.ok) {
595
- return this.handleErrorResponse(response, "batch");
732
+ return this.handleErrorResponse(response, "batch", dbName, requestedActions);
596
733
  }
597
734
  const data = await response.json();
598
735
  return ok(data);
599
736
  } catch (error) {
600
737
  return err(wrapError("sql", error));
738
+ } finally {
739
+ request.dispose();
601
740
  }
602
741
  });
603
742
  }
@@ -606,20 +745,23 @@ var SQLService = class extends BaseService {
606
745
  if (!this.requireAuth()) {
607
746
  return err(authRequiredError("sql"));
608
747
  }
748
+ const request = this.createRequestSignal(options?.signal);
609
749
  try {
610
750
  const response = await this.invokeSQL(
611
751
  dbName,
612
752
  SQLAction.WRITE,
613
753
  { action: "execute_statement", name, params: params ?? [] },
614
- options?.signal
754
+ request.signal
615
755
  );
616
756
  if (!response.ok) {
617
- return this.handleErrorResponse(response, "executeStatement");
757
+ return this.handleErrorResponse(response, "executeStatement", dbName, [SQLAction.WRITE]);
618
758
  }
619
759
  const data = await response.json();
620
760
  return ok(data);
621
761
  } catch (error) {
622
762
  return err(wrapError("sql", error));
763
+ } finally {
764
+ request.dispose();
623
765
  }
624
766
  });
625
767
  }
@@ -628,15 +770,16 @@ var SQLService = class extends BaseService {
628
770
  if (!this.requireAuth()) {
629
771
  return err(authRequiredError("sql"));
630
772
  }
773
+ const request = this.createRequestSignal(options?.signal);
631
774
  try {
632
775
  const response = await this.invokeSQL(
633
776
  dbName,
634
777
  SQLAction.READ,
635
778
  { action: "export" },
636
- options?.signal
779
+ request.signal
637
780
  );
638
781
  if (!response.ok) {
639
- return this.handleErrorResponse(response, "export");
782
+ return this.handleErrorResponse(response, "export", dbName, [SQLAction.READ]);
640
783
  }
641
784
  const resp = response;
642
785
  if (typeof resp.blob === "function") {
@@ -647,26 +790,31 @@ var SQLService = class extends BaseService {
647
790
  return ok(text);
648
791
  } catch (error) {
649
792
  return err(wrapError("sql", error));
793
+ } finally {
794
+ request.dispose();
650
795
  }
651
796
  });
652
797
  }
653
798
  async applyMigrationsOnDbUnlocked(dbName, options) {
654
799
  return this.withTelemetry("migrations.apply", dbName, async () => {
655
- const created = await this.ensureMigrationsTable(dbName, options.signal);
656
- if (!created.ok) return created;
657
800
  const listed = await this.queryOnDb(
658
801
  dbName,
659
802
  `SELECT id FROM ${MIGRATIONS_TABLE} WHERE namespace = ? ORDER BY applied_at, id`,
660
803
  [options.namespace],
661
804
  { signal: options.signal }
662
805
  );
663
- if (!listed.ok) return listed;
806
+ const tableMissing = !listed.ok && (listed.error.code === ErrorCodes.SQL_DATABASE_NOT_FOUND || isMissingTableError(listed.error.message));
807
+ if (!listed.ok && !tableMissing) return listed;
664
808
  const appliedIds = new Set(
665
- listed.data.rows.map((row) => rowValue(row, 0)).filter((id) => typeof id === "string")
809
+ (listed.ok ? listed.data.rows : []).map((row) => rowValue(row, 0)).filter((id) => typeof id === "string")
666
810
  );
667
811
  const skipped = options.migrations.filter((migration) => appliedIds.has(migration.id)).map((migration) => migration.id);
668
812
  const pending = options.migrations.filter((migration) => !appliedIds.has(migration.id));
669
813
  const applied = [];
814
+ if (pending.length > 0 && tableMissing) {
815
+ const created = await this.ensureMigrationsTable(dbName, options.signal);
816
+ if (!created.ok) return created;
817
+ }
670
818
  for (const migration of pending) {
671
819
  const result = await this.applyOneMigration(dbName, options.namespace, migration, options.signal);
672
820
  if (!result.ok) return result;
@@ -734,7 +882,7 @@ var SQLService = class extends BaseService {
734
882
  "Content-Type": "application/json"
735
883
  },
736
884
  body: JSON.stringify(body),
737
- signal: this.combineSignals(signal)
885
+ signal
738
886
  });
739
887
  }
740
888
  actionForSql(sql, fallback) {
@@ -767,8 +915,12 @@ var SQLService = class extends BaseService {
767
915
  }))
768
916
  );
769
917
  }
770
- async handleErrorResponse(response, operation) {
918
+ async handleErrorResponse(response, operation, dbName, requestedActions) {
771
919
  const errorText = await response.text();
920
+ const meta = responseErrorMeta(response.status, response.statusText, errorText);
921
+ if (response.status === 402) {
922
+ return err(storageRejectionError("sql", 402, meta, errorText));
923
+ }
772
924
  const errorBody = parseServiceErrorBody(errorText);
773
925
  const errorCode = this.mapHttpStatusToErrorCode(
774
926
  response.status,
@@ -781,11 +933,12 @@ var SQLService = class extends BaseService {
781
933
  errorText,
782
934
  errorBody
783
935
  );
784
- const meta = responseErrorMeta(response.status, response.statusText, errorText);
785
936
  if (response.status === 401) {
786
- const { resource, action } = parseAuthError(errorText);
787
- if (action) meta.requiredAction = action;
788
- if (resource) meta.resource = resource;
937
+ const { action } = parseAuthError(errorText);
938
+ if (action && requestedActions.includes(action)) {
939
+ meta.requiredAction = action;
940
+ meta.resource = `${this.context.session.spaceId}/sql/${dbName}`;
941
+ }
789
942
  }
790
943
  return err(
791
944
  serviceError(errorCode, message, "sql", { meta })
@@ -882,6 +1035,9 @@ function serializeSqlValues(values) {
882
1035
  (value) => value instanceof Uint8Array ? Array.from(value) : value
883
1036
  );
884
1037
  }
1038
+ function isMissingTableError(message) {
1039
+ return /no such table/i.test(message);
1040
+ }
885
1041
  function migrationKey(namespace, id) {
886
1042
  return `${encodeURIComponent(namespace)}:${encodeURIComponent(id)}`;
887
1043
  }