@syncular/server 0.33.0 → 0.35.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.
Files changed (44) hide show
  1. package/dist/blob-handlers.js +10 -3
  2. package/dist/blob-store.d.ts +9 -1
  3. package/dist/blob-store.js +0 -0
  4. package/dist/bucket-blob-store.d.ts +1 -0
  5. package/dist/bucket-blob-store.js +22 -16
  6. package/dist/d1-storage.d.ts +3 -1
  7. package/dist/d1-storage.js +27 -3
  8. package/dist/erase.d.ts +28 -0
  9. package/dist/erase.js +122 -0
  10. package/dist/index.d.ts +1 -0
  11. package/dist/index.js +1 -0
  12. package/dist/operations.d.ts +20 -3
  13. package/dist/operations.js +23 -3
  14. package/dist/postgres-storage.d.ts +3 -1
  15. package/dist/postgres-storage.js +40 -0
  16. package/dist/push.d.ts +8 -2
  17. package/dist/push.js +1 -1
  18. package/dist/relational-rows.d.ts +6 -3
  19. package/dist/relational-rows.js +8 -9
  20. package/dist/sqlite-blob-store.d.ts +1 -0
  21. package/dist/sqlite-blob-store.js +14 -2
  22. package/dist/sqlite-dialect.d.ts +11 -1
  23. package/dist/sqlite-dialect.js +35 -0
  24. package/dist/sqlite-storage.d.ts +3 -1
  25. package/dist/sqlite-storage.js +29 -9
  26. package/dist/storage-errors.d.ts +1 -1
  27. package/dist/storage-errors.js +2 -0
  28. package/dist/storage.d.ts +26 -0
  29. package/package.json +2 -2
  30. package/src/blob-handlers.ts +13 -5
  31. package/src/blob-store.ts +0 -0
  32. package/src/bucket-blob-store.ts +26 -20
  33. package/src/d1-storage.ts +48 -3
  34. package/src/erase.ts +173 -0
  35. package/src/index.ts +1 -0
  36. package/src/operations.ts +52 -5
  37. package/src/postgres-storage.ts +59 -0
  38. package/src/push.ts +1 -1
  39. package/src/relational-rows.ts +8 -11
  40. package/src/sqlite-blob-store.ts +17 -1
  41. package/src/sqlite-dialect.ts +43 -0
  42. package/src/sqlite-storage.ts +42 -10
  43. package/src/storage-errors.ts +7 -1
  44. package/src/storage.ts +35 -0
package/src/operations.ts CHANGED
@@ -8,11 +8,12 @@ import {
8
8
  encodeRemoteOperationResponse,
9
9
  encodeSparseRow,
10
10
  type PushOperation,
11
+ type RemoteOperationRequest,
11
12
  type RemoteOperationResponse,
12
13
  type RowValue,
13
14
  type ScopeMap,
14
15
  } from '@syncular/core';
15
- import type { SyncRequestContext } from './context';
16
+ import type { ResolveScopes, SyncRequestContext } from './context';
16
17
  import {
17
18
  REMOTE_COMMAND_CLIENT_ID_PREFIX,
18
19
  RESOLVER_OUTAGE,
@@ -33,7 +34,11 @@ import type {
33
34
  } from './storage';
34
35
  import type { StorageTransaction } from './storage';
35
36
  import { transactionQuery } from './storage-query';
36
- import { toValidateRow, type ValidateRow } from './validate';
37
+ import {
38
+ toValidateRow,
39
+ ValidationRejection,
40
+ type ValidateRow,
41
+ } from './validate';
37
42
 
38
43
  export interface RemoteQueryDependency {
39
44
  readonly table: string;
@@ -683,10 +688,41 @@ export class RemoteOperationRegistry {
683
688
  }
684
689
  }
685
690
 
691
+ /**
692
+ * The registry and identity one remote operation request runs with
693
+ * (REMOTE.md §5.1). `actorId` is the commit's actor and defaults to the
694
+ * authenticated actor; `resolveScopes` replaces the configured resolver for
695
+ * this request.
696
+ */
697
+ export interface RemoteOperationBinding {
698
+ readonly registry: RemoteOperationRegistry;
699
+ readonly actorId?: string;
700
+ readonly resolveScopes?: ResolveScopes;
701
+ }
702
+
703
+ /**
704
+ * Chooses the binding of one decoded request (REMOTE.md §5.1). `ctx` carries
705
+ * the authenticated actor; the resolver runs after the client-id binding
706
+ * check and may throw a `SyncError` or a `ValidationRejection`.
707
+ */
708
+ export type RemoteOperationResolver = (
709
+ request: RemoteOperationRequest,
710
+ ctx: SyncRequestContext,
711
+ ) => RemoteOperationBinding | Promise<RemoteOperationBinding>;
712
+
686
713
  function encodeRemoteOperationError(
687
714
  error: unknown,
688
715
  fallbackCode: 'operation.invalid_request' | 'operation.execution_failed',
689
716
  ): Uint8Array {
717
+ if (error instanceof ValidationRejection) {
718
+ return encodeRemoteOperationResponse({
719
+ revision: 1,
720
+ kind: 'error',
721
+ code: error.code,
722
+ message: error.message,
723
+ retryable: false,
724
+ });
725
+ }
690
726
  const sync =
691
727
  error instanceof SyncError
692
728
  ? error
@@ -707,8 +743,8 @@ function encodeRemoteOperationError(
707
743
 
708
744
  export async function handleRemoteOperation(
709
745
  bytes: Uint8Array,
710
- ctx: SyncRequestContext,
711
- registry: RemoteOperationRegistry,
746
+ authenticated: SyncRequestContext,
747
+ operations: RemoteOperationRegistry | RemoteOperationResolver,
712
748
  ): Promise<Uint8Array> {
713
749
  let request;
714
750
  try {
@@ -728,6 +764,7 @@ export async function handleRemoteOperation(
728
764
  } catch (error) {
729
765
  return encodeRemoteOperationError(error, 'operation.invalid_request');
730
766
  }
767
+ let ctx = authenticated;
731
768
  try {
732
769
  await touchAuthenticatedPartition(ctx);
733
770
  if (request.clientId.startsWith(REMOTE_COMMAND_CLIENT_ID_PREFIX)) {
@@ -752,6 +789,16 @@ export async function handleRemoteOperation(
752
789
  ) {
753
790
  throw syncError('operation.invalid_request');
754
791
  }
792
+ let registry = operations;
793
+ if (typeof registry === 'function') {
794
+ const binding = await registry(request, ctx);
795
+ registry = binding.registry;
796
+ ctx = {
797
+ ...ctx,
798
+ actorId: binding.actorId ?? ctx.actorId,
799
+ resolveScopes: binding.resolveScopes ?? ctx.resolveScopes,
800
+ };
801
+ }
755
802
  const operation = registry.get(request.operationId);
756
803
  if (operation === undefined) {
757
804
  throw syncError('operation.unknown');
@@ -772,7 +819,7 @@ export async function handleRemoteOperation(
772
819
  ),
773
820
  );
774
821
  } catch (error) {
775
- if (!(error instanceof SyncError)) {
822
+ if (!(error instanceof SyncError || error instanceof ValidationRejection)) {
776
823
  reportError(error, ctx.onError, 'operations');
777
824
  }
778
825
  return encodeRemoteOperationError(error, 'operation.execution_failed');
@@ -121,6 +121,8 @@ import type {
121
121
  RowScanQuery,
122
122
  ScopeActivityQuery,
123
123
  ScopeCommitActivity,
124
+ ScopeValueHistoryQuery,
125
+ ScopeValueQuery,
124
126
  ServerStorage,
125
127
  ServeGate,
126
128
  StorageTransaction,
@@ -2567,6 +2569,63 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
2567
2569
  return rows.map((r) => r.blob_id);
2568
2570
  }
2569
2571
 
2572
+ async listScopeValueRows(
2573
+ partition: string,
2574
+ query: ScopeValueQuery,
2575
+ ): Promise<string[]> {
2576
+ const { rows } = await this.#exec.query<{ row_id: string }>(
2577
+ `SELECT row_id FROM sync_row_scopes
2578
+ WHERE partition=$1 AND tbl=$2 AND var=$3 AND value=$4
2579
+ ORDER BY row_id LIMIT $5`,
2580
+ [partition, query.table, query.variable, query.value, query.limit],
2581
+ );
2582
+ return rows.map((row) => row.row_id);
2583
+ }
2584
+
2585
+ async eraseScopeValueHistory(
2586
+ partition: string,
2587
+ query: ScopeValueHistoryQuery,
2588
+ ): Promise<number> {
2589
+ const params = [
2590
+ partition,
2591
+ query.table,
2592
+ query.variable,
2593
+ query.value,
2594
+ query.throughSeq,
2595
+ query.limit,
2596
+ ];
2597
+ return this.#exec.transaction(async (client) => {
2598
+ await lockPartitionOn(client, partition);
2599
+ // Upsert change records of the binding, then the change scope entries
2600
+ // whose commit no longer holds a change of the binding.
2601
+ const changes = await client.query(
2602
+ `DELETE FROM sync_changes c USING (
2603
+ SELECT c2.commit_seq, c2.idx FROM sync_change_scopes s
2604
+ JOIN sync_changes c2 ON c2.partition=s.partition
2605
+ AND c2.commit_seq=s.commit_seq AND c2.tbl=s.tbl
2606
+ WHERE s.partition=$1 AND s.tbl=$2 AND s.var=$3 AND s.value=$4
2607
+ AND s.commit_seq<=$5 AND c2.op=1 AND c2.scopes->>$3=s.value
2608
+ LIMIT $6) d
2609
+ WHERE c.partition=$1 AND c.commit_seq=d.commit_seq AND c.idx=d.idx`,
2610
+ params,
2611
+ );
2612
+ const scopes = await client.query(
2613
+ `DELETE FROM sync_change_scopes cs USING (
2614
+ SELECT s.commit_seq FROM sync_change_scopes s
2615
+ WHERE s.partition=$1 AND s.tbl=$2 AND s.var=$3 AND s.value=$4
2616
+ AND s.commit_seq<=$5 AND NOT EXISTS (
2617
+ SELECT 1 FROM sync_changes c
2618
+ WHERE c.partition=s.partition AND c.commit_seq=s.commit_seq
2619
+ AND c.tbl=s.tbl AND c.scopes->>$3=s.value)
2620
+ LIMIT $6) d
2621
+ WHERE cs.partition=$1 AND cs.tbl=$2 AND cs.var=$3 AND cs.value=$4
2622
+ AND cs.commit_seq=d.commit_seq`,
2623
+ params,
2624
+ );
2625
+ return changes.rowCount + scopes.rowCount;
2626
+ });
2627
+ }
2628
+
2570
2629
  // -- admin/console read surface --------------------------------------------
2571
2630
 
2572
2631
  async listClientRecords(partition: string): Promise<ClientRecord[]> {
package/src/push.ts CHANGED
@@ -75,7 +75,7 @@ import {
75
75
  * columns (§5.9.4), skipping NULLs. Malformed BlobRefs already failed at
76
76
  * row-codec decode (§5.9.1), so `parseBlobRef` here is total.
77
77
  */
78
- function blobIdsInRow(
78
+ export function blobIdsInRow(
79
79
  table: CompiledTable,
80
80
  values: readonly RowValue[],
81
81
  ): string[] {
@@ -709,20 +709,17 @@ export function prefetchRowsSql(table: CompiledTable): string {
709
709
  * SQLite/D1: the newest `commit_seq` in the inverted change-scope index for
710
710
  * one variable's values, as one index-only max per value (a `(partition,
711
711
  * tbl, var, value)` prefix), so the cost is independent of the table's
712
- * history. Bind order: [partition, tbl, var, value] per value.
712
+ * history. The values arrive as one JSON array, so the statement has no
713
+ * compound SELECT and four bound parameters for any value count (D1 caps a
714
+ * compound SELECT at 5 terms and a statement at 100 parameters).
715
+ * Params: [partition, tbl, var, values as JSON array text].
713
716
  */
714
- export function latestScopedChangeSeqSql(valueCount: number): string {
715
- const perValue: string[] = [];
716
- for (let i = 0; i < valueCount; i++) {
717
- perValue.push(
718
- 'SELECT (SELECT max(commit_seq) FROM sync_change_scopes WHERE partition=? AND tbl=? AND var=? AND value=?) AS seq',
719
- );
720
- }
721
- return `SELECT max(seq) AS seq FROM (${perValue.join(' UNION ALL ')})`;
722
- }
717
+ export const LATEST_SCOPED_CHANGE_SEQ_SQLITE_SQL = `SELECT max((SELECT max(commit_seq) FROM sync_change_scopes
718
+ WHERE partition=? AND tbl=? AND var=? AND value=v.value)) AS seq
719
+ FROM json_each(?) AS v`;
723
720
 
724
721
  /**
725
- * PostgreSQL set-based form of `latestScopedChangeSeqSql` for the
722
+ * PostgreSQL set-based form of `LATEST_SCOPED_CHANGE_SEQ_SQLITE_SQL` for the
726
723
  * subscriptions of one table in a pull. `$2` is a JSON array of
727
724
  * `{i, var, vals}` records. Params: [partition, specs (JSON text), tbl].
728
725
  */
@@ -33,7 +33,17 @@ export class SqliteBlobStore implements BlobStore {
33
33
  mediaType?: string,
34
34
  ): Promise<BlobRecord> {
35
35
  const existing = await this.get(partition, blobId);
36
- if (existing !== undefined) return existing.record;
36
+ if (existing !== undefined) {
37
+ if (existing.record.mediaType !== undefined || mediaType === undefined)
38
+ return existing.record;
39
+ // A re-upload names the media type an earlier upload left out.
40
+ this.db
41
+ .query(
42
+ 'UPDATE sync_blobs SET media_type=? WHERE partition=? AND blob_id=? AND media_type IS NULL',
43
+ )
44
+ .run(mediaType, partition, blobId);
45
+ return { ...existing.record, mediaType };
46
+ }
37
47
  const record: BlobRecord = {
38
48
  blobId,
39
49
  partition,
@@ -51,6 +61,12 @@ export class SqliteBlobStore implements BlobStore {
51
61
  return record;
52
62
  }
53
63
 
64
+ async delete(partition: string, blobId: string): Promise<void> {
65
+ this.db
66
+ .query('DELETE FROM sync_blobs WHERE partition=? AND blob_id=?')
67
+ .run(partition, blobId);
68
+ }
69
+
54
70
  async has(partition: string, blobId: string): Promise<boolean> {
55
71
  const row = this.db
56
72
  .query<{ n: number }, [string, string]>(
@@ -18,6 +18,7 @@
18
18
  import type { PushOperationResult, ScopeMap } from '@syncular/core';
19
19
  import { matchesEffective } from './scopes';
20
20
  import type {
21
+ ScopeValueHistoryQuery,
21
22
  StoredChange,
22
23
  StoredCommit,
23
24
  StoredPushResult,
@@ -151,6 +152,48 @@ export function sqliteDdlStatements(): string[] {
151
152
  .filter((statement) => statement.length > 0);
152
153
  }
153
154
 
155
+ /** §4.6.1: rowIds of one table bound to one scope value, budgeted. */
156
+ export const LIST_SCOPE_VALUE_ROWS_SQLITE_SQL =
157
+ 'SELECT row_id FROM sync_row_scopes WHERE partition=? AND tbl=? AND var=? AND value=? ORDER BY row_id LIMIT ?';
158
+
159
+ /**
160
+ * §4.6.1 history scrub, bound by `eraseScopeValueHistoryParams`: upsert change
161
+ * records of the binding first, then the change scope entries whose commit
162
+ * no longer holds a change of the binding. Each returns one row per removed
163
+ * record (D1 batch results carry no reliable change count).
164
+ */
165
+ export const ERASE_SCOPE_VALUE_HISTORY_SQLITE_SQL = [
166
+ `DELETE FROM sync_changes WHERE rowid IN (
167
+ SELECT c.rowid FROM sync_change_scopes s JOIN sync_changes c
168
+ ON c.partition=s.partition AND c.commit_seq=s.commit_seq AND c.tbl=s.tbl
169
+ WHERE s.partition=?1 AND s.tbl=?2 AND s.var=?3 AND s.value=?4
170
+ AND s.commit_seq<=?5 AND c.op=1 AND json_extract(c.scopes, ?6)=s.value
171
+ LIMIT ?7) RETURNING 1 AS removed`,
172
+ `DELETE FROM sync_change_scopes WHERE rowid IN (
173
+ SELECT s.rowid FROM sync_change_scopes s
174
+ WHERE s.partition=?1 AND s.tbl=?2 AND s.var=?3 AND s.value=?4
175
+ AND s.commit_seq<=?5 AND NOT EXISTS (
176
+ SELECT 1 FROM sync_changes c
177
+ WHERE c.partition=s.partition AND c.commit_seq=s.commit_seq
178
+ AND c.tbl=s.tbl AND json_extract(c.scopes, ?6)=s.value)
179
+ LIMIT ?7) RETURNING 1 AS removed`,
180
+ ] as const;
181
+
182
+ export function eraseScopeValueHistoryParams(
183
+ partition: string,
184
+ query: ScopeValueHistoryQuery,
185
+ ): [string, string, string, string, number, string, number] {
186
+ return [
187
+ partition,
188
+ query.table,
189
+ query.variable,
190
+ query.value,
191
+ query.throughSeq,
192
+ `$.${JSON.stringify(query.variable)}`,
193
+ query.limit,
194
+ ];
195
+ }
196
+
154
197
  /** `?,?,…` for an `IN (…)` clause of `count` positional parameters. */
155
198
  export function placeholders(count: number): string {
156
199
  return new Array(count).fill('?').join(',');
@@ -22,7 +22,7 @@ import {
22
22
  assertPhysicalColumns,
23
23
  assertStoredLayouts,
24
24
  commitWindowPageSql,
25
- latestScopedChangeSeqSql,
25
+ LATEST_SCOPED_CHANGE_SEQ_SQLITE_SQL,
26
26
  deleteRowSql,
27
27
  deleteSqliteRowScopesSql,
28
28
  dropTableDdl,
@@ -56,6 +56,9 @@ import { matchesEffective } from './scopes';
56
56
  import {
57
57
  collectCommitWindowPage,
58
58
  deserializePushResult,
59
+ ERASE_SCOPE_VALUE_HISTORY_SQLITE_SQL,
60
+ eraseScopeValueHistoryParams,
61
+ LIST_SCOPE_VALUE_ROWS_SQLITE_SQL,
59
62
  placeholders,
60
63
  SQLITE_DDL,
61
64
  type SqliteChangeRecord,
@@ -100,6 +103,8 @@ import type {
100
103
  RowScanQuery,
101
104
  ScopeActivityQuery,
102
105
  ScopeCommitActivity,
106
+ ScopeValueHistoryQuery,
107
+ ScopeValueQuery,
103
108
  ServerStorage,
104
109
  ServeGate,
105
110
  StorageTransaction,
@@ -1639,16 +1644,9 @@ END`);
1639
1644
  if (firstVariable === undefined || values.length === 0) return 0;
1640
1645
  const row = this.db
1641
1646
  .query<{ seq: number | null }, string[]>(
1642
- latestScopedChangeSeqSql(values.length),
1647
+ LATEST_SCOPED_CHANGE_SEQ_SQLITE_SQL,
1643
1648
  )
1644
- .get(
1645
- ...values.flatMap((value) => [
1646
- partition,
1647
- query.table,
1648
- firstVariable,
1649
- value,
1650
- ]),
1651
- );
1649
+ .get(partition, query.table, firstVariable, JSON.stringify(values));
1652
1650
  return row?.seq ?? 0;
1653
1651
  }
1654
1652
 
@@ -1932,6 +1930,40 @@ END`);
1932
1930
  return rows.map((r) => r.blob_id);
1933
1931
  }
1934
1932
 
1933
+ async listScopeValueRows(
1934
+ partition: string,
1935
+ query: ScopeValueQuery,
1936
+ ): Promise<string[]> {
1937
+ return this.db
1938
+ .query<{ row_id: string }, [string, string, string, string, number]>(
1939
+ LIST_SCOPE_VALUE_ROWS_SQLITE_SQL,
1940
+ )
1941
+ .all(partition, query.table, query.variable, query.value, query.limit)
1942
+ .map((row) => row.row_id);
1943
+ }
1944
+
1945
+ async eraseScopeValueHistory(
1946
+ partition: string,
1947
+ query: ScopeValueHistoryQuery,
1948
+ ): Promise<number> {
1949
+ const params = eraseScopeValueHistoryParams(partition, query);
1950
+ return this.#serializeWrite(() => {
1951
+ this.db.exec('BEGIN IMMEDIATE');
1952
+ try {
1953
+ let removed = 0;
1954
+ for (const sql of ERASE_SCOPE_VALUE_HISTORY_SQLITE_SQL) {
1955
+ removed += this.db.query(sql).all(...params).length;
1956
+ }
1957
+ this.db.exec('COMMIT');
1958
+ return removed;
1959
+ } catch (error) {
1960
+ rollbackAfterError(() => {
1961
+ this.db.exec('ROLLBACK');
1962
+ }, error);
1963
+ }
1964
+ });
1965
+ }
1966
+
1935
1967
  // -- admin/console read surface --------------------------------------------
1936
1968
 
1937
1969
  async listClientRecords(partition: string): Promise<ClientRecord[]> {
@@ -34,7 +34,9 @@ export type StorageQueryErrorCode =
34
34
  | 'sync.storage.stored_layout_mismatch'
35
35
  | 'sync.storage.physical_layout_mismatch'
36
36
  | 'sync.storage.transaction_query_unsupported'
37
- | 'sync.storage.query_over_staged_writes';
37
+ | 'sync.storage.query_over_staged_writes'
38
+ | 'sync.storage.erase_unsupported'
39
+ | 'sync.storage.invalid_erase_query';
38
40
 
39
41
  const STORAGE_QUERY_MESSAGES: Readonly<Record<StorageQueryErrorCode, string>> =
40
42
  {
@@ -78,6 +80,10 @@ const STORAGE_QUERY_MESSAGES: Readonly<Record<StorageQueryErrorCode, string>> =
78
80
  'trusted row lookup requires one exact value per index column',
79
81
  'sync.storage.invalid_limit':
80
82
  'trusted row lookup limit must be an integer from 1 through 1,000',
83
+ 'sync.storage.erase_unsupported':
84
+ 'this storage backend cannot erase a scope value',
85
+ 'sync.storage.invalid_erase_query':
86
+ 'erasure requires a declared scope variable, a non-empty value, and a row budget within the operation cap',
81
87
  };
82
88
 
83
89
  /**
package/src/storage.ts CHANGED
@@ -957,6 +957,29 @@ export interface ServerStorage {
957
957
  ): Promise<BlobReferencingRow[]>;
958
958
  listReferencedBlobIds?(partition: string): Promise<string[]>;
959
959
 
960
+ /**
961
+ * §4.6.1 scope-value erasure — ADDITIVE, optional; `eraseScopeValue`
962
+ * requires both.
963
+ *
964
+ * `listScopeValueRows`: up to `limit` rowIds of `table` whose stored scope
965
+ * binds `variable` to `value`, ordered by rowId (the row scope index).
966
+ *
967
+ * `eraseScopeValueHistory`: under the partition write lock, removes up to
968
+ * `limit` upsert change records of `table` at or below `throughSeq` whose
969
+ * scopes bind `variable` to `value`, then up to `limit` change scope index
970
+ * entries of that binding whose commit holds no such change any more.
971
+ * Delete change records stay: clients behind the erasure still need them.
972
+ * Returns the number of records removed.
973
+ */
974
+ listScopeValueRows?(
975
+ partition: string,
976
+ query: ScopeValueQuery,
977
+ ): Promise<string[]>;
978
+ eraseScopeValueHistory?(
979
+ partition: string,
980
+ query: ScopeValueHistoryQuery,
981
+ ): Promise<number>;
982
+
960
983
  /**
961
984
  * Admin/console read surface (`SyncularAdmin`): ADDITIVE,
962
985
  * optional. All read-only, partition-scoped, and JSON-able. A backend
@@ -1008,6 +1031,18 @@ export interface ServerStorage {
1008
1031
  listPartitions(): Promise<string[]>;
1009
1032
  }
1010
1033
 
1034
+ /** One scope binding of one table, budgeted (§4.6.1). */
1035
+ export interface ScopeValueQuery {
1036
+ readonly table: string;
1037
+ readonly variable: string;
1038
+ readonly value: string;
1039
+ readonly limit: number;
1040
+ }
1041
+
1042
+ export interface ScopeValueHistoryQuery extends ScopeValueQuery {
1043
+ readonly throughSeq: number;
1044
+ }
1045
+
1011
1046
  /** A row referencing a blob, with the scopes needed to authorize download. */
1012
1047
  export interface BlobReferencingRow {
1013
1048
  readonly table: string;