@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.
- package/dist/blob-handlers.js +10 -3
- package/dist/blob-store.d.ts +9 -1
- package/dist/blob-store.js +0 -0
- package/dist/bucket-blob-store.d.ts +1 -0
- package/dist/bucket-blob-store.js +22 -16
- package/dist/d1-storage.d.ts +3 -1
- package/dist/d1-storage.js +27 -3
- package/dist/erase.d.ts +28 -0
- package/dist/erase.js +122 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/operations.d.ts +20 -3
- package/dist/operations.js +23 -3
- package/dist/postgres-storage.d.ts +3 -1
- package/dist/postgres-storage.js +40 -0
- package/dist/push.d.ts +8 -2
- package/dist/push.js +1 -1
- package/dist/relational-rows.d.ts +6 -3
- package/dist/relational-rows.js +8 -9
- package/dist/sqlite-blob-store.d.ts +1 -0
- package/dist/sqlite-blob-store.js +14 -2
- package/dist/sqlite-dialect.d.ts +11 -1
- package/dist/sqlite-dialect.js +35 -0
- package/dist/sqlite-storage.d.ts +3 -1
- package/dist/sqlite-storage.js +29 -9
- package/dist/storage-errors.d.ts +1 -1
- package/dist/storage-errors.js +2 -0
- package/dist/storage.d.ts +26 -0
- package/package.json +2 -2
- package/src/blob-handlers.ts +13 -5
- package/src/blob-store.ts +0 -0
- package/src/bucket-blob-store.ts +26 -20
- package/src/d1-storage.ts +48 -3
- package/src/erase.ts +173 -0
- package/src/index.ts +1 -0
- package/src/operations.ts +52 -5
- package/src/postgres-storage.ts +59 -0
- package/src/push.ts +1 -1
- package/src/relational-rows.ts +8 -11
- package/src/sqlite-blob-store.ts +17 -1
- package/src/sqlite-dialect.ts +43 -0
- package/src/sqlite-storage.ts +42 -10
- package/src/storage-errors.ts +7 -1
- 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 {
|
|
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
|
-
|
|
711
|
-
|
|
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');
|
package/src/postgres-storage.ts
CHANGED
|
@@ -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[] {
|
package/src/relational-rows.ts
CHANGED
|
@@ -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.
|
|
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
|
|
715
|
-
|
|
716
|
-
|
|
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 `
|
|
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
|
*/
|
package/src/sqlite-blob-store.ts
CHANGED
|
@@ -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)
|
|
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]>(
|
package/src/sqlite-dialect.ts
CHANGED
|
@@ -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(',');
|
package/src/sqlite-storage.ts
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
assertPhysicalColumns,
|
|
23
23
|
assertStoredLayouts,
|
|
24
24
|
commitWindowPageSql,
|
|
25
|
-
|
|
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
|
-
|
|
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[]> {
|
package/src/storage-errors.ts
CHANGED
|
@@ -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;
|