@syncular/server 0.34.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 +25 -1
- 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/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/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 +26 -1
- 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 +45 -0
- package/src/erase.ts +173 -0
- package/src/index.ts +1 -0
- package/src/postgres-storage.ts +59 -0
- package/src/push.ts +1 -1
- package/src/sqlite-blob-store.ts +17 -1
- package/src/sqlite-dialect.ts +43 -0
- package/src/sqlite-storage.ts +39 -0
- package/src/storage-errors.ts +7 -1
- package/src/storage.ts +35 -0
package/dist/blob-handlers.js
CHANGED
|
@@ -189,12 +189,19 @@ async function downloadBlob(ctx, blobId) {
|
|
|
189
189
|
// Unreachable: presign not configured ⇒ `entry` was loaded above.
|
|
190
190
|
throw syncError('blob.not_found', 'blob bytes unavailable (§5.9.5)');
|
|
191
191
|
}
|
|
192
|
-
// §5.9.5: inline bytes
|
|
193
|
-
//
|
|
192
|
+
// §5.9.5: inline bytes carry the stored media type, so an <img> or <a>
|
|
193
|
+
// pointed at the route renders them. A JSON type stays octet-stream: a
|
|
194
|
+
// JSON body is reserved for the signed-URL envelope.
|
|
195
|
+
const mediaType = entry?.record.mediaType;
|
|
194
196
|
return {
|
|
195
197
|
bytes,
|
|
196
198
|
headers: {
|
|
197
|
-
'Content-Type':
|
|
199
|
+
'Content-Type': mediaType === undefined ||
|
|
200
|
+
mediaType.toLowerCase().includes('application/json')
|
|
201
|
+
? 'application/octet-stream'
|
|
202
|
+
: mediaType,
|
|
203
|
+
'X-Content-Type-Options': 'nosniff',
|
|
204
|
+
'Content-Security-Policy': "sandbox; default-src 'none'",
|
|
198
205
|
ETag: `"${blobId}"`,
|
|
199
206
|
'Cache-Control': 'private, max-age=0',
|
|
200
207
|
Vary: 'Authorization, Accept-Encoding',
|
package/dist/blob-store.d.ts
CHANGED
|
@@ -37,7 +37,9 @@ export interface BlobStoreStats {
|
|
|
37
37
|
export interface BlobStore {
|
|
38
38
|
/**
|
|
39
39
|
* Store blob bytes under `blobId` (idempotent — same id ⇒ no-op success,
|
|
40
|
-
* §5.9.3).
|
|
40
|
+
* §5.9.3). A re-upload that declares a media type records it on a blob
|
|
41
|
+
* stored without one; a recorded media type never changes. The caller has
|
|
42
|
+
* already verified the content address matches.
|
|
41
43
|
*/
|
|
42
44
|
put(partition: string, blobId: string, bytes: Uint8Array, nowMs: number, mediaType?: string): Promise<BlobRecord>;
|
|
43
45
|
/** Presence check for the push existence rule (§5.9.6) and download. */
|
|
@@ -53,6 +55,11 @@ export interface BlobStore {
|
|
|
53
55
|
* Returns the deleted blobIds.
|
|
54
56
|
*/
|
|
55
57
|
sweepOrphans(partition: string, olderThanMs: number, referencedBlobIds: ReadonlySet<string>): Promise<string[]>;
|
|
58
|
+
/**
|
|
59
|
+
* §4.6.1 erasure: delete one blob whose last reference is gone. Deleting a
|
|
60
|
+
* missing blob is a no-op.
|
|
61
|
+
*/
|
|
62
|
+
delete(partition: string, blobId: string): Promise<void>;
|
|
56
63
|
/** Admin/console counters (work item §2.5) — ADDITIVE, optional. */
|
|
57
64
|
stats?(partition: string): Promise<BlobStoreStats>;
|
|
58
65
|
}
|
|
@@ -68,6 +75,7 @@ export declare class MemoryBlobStore implements BlobStore {
|
|
|
68
75
|
bytes: Uint8Array;
|
|
69
76
|
} | undefined>;
|
|
70
77
|
sweepOrphans(partition: string, olderThanMs: number, referencedBlobIds: ReadonlySet<string>): Promise<string[]>;
|
|
78
|
+
delete(partition: string, blobId: string): Promise<void>;
|
|
71
79
|
stats(partition: string): Promise<BlobStoreStats>;
|
|
72
80
|
}
|
|
73
81
|
export interface SweepOrphanBlobsOptions {
|
package/dist/blob-store.js
CHANGED
|
Binary file
|
|
@@ -51,6 +51,7 @@ export declare class BucketBlobStore implements BlobStore {
|
|
|
51
51
|
*/
|
|
52
52
|
objectKeyFor(partition: string, blobId: string): string;
|
|
53
53
|
put(partition: string, blobId: string, bytes: Uint8Array, nowMs: number, mediaType?: string): Promise<BlobRecord>;
|
|
54
|
+
delete(partition: string, blobId: string): Promise<void>;
|
|
54
55
|
has(partition: string, blobId: string): Promise<boolean>;
|
|
55
56
|
get(partition: string, blobId: string): Promise<{
|
|
56
57
|
record: BlobRecord;
|
|
@@ -141,25 +141,28 @@ export class BucketBlobStore {
|
|
|
141
141
|
// blob once, and preserve the FIRST createdAtMs so the sweep's grace clock
|
|
142
142
|
// is not reset by a re-upload (§5.9.2 upload age).
|
|
143
143
|
const preexisting = await this.#bucket.head(objectKey);
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
144
|
+
const storedMeta = preexisting?.metadata[BLOB_META_KEY];
|
|
145
|
+
const existing = storedMeta === undefined
|
|
146
|
+
? undefined
|
|
147
|
+
: parseMetaJson(base64urlToUtf8(storedMeta), blobId, this.#label);
|
|
148
|
+
if (existing !== undefined &&
|
|
149
|
+
(existing.mediaType !== undefined || mediaType === undefined)) {
|
|
150
|
+
return {
|
|
151
|
+
blobId,
|
|
152
|
+
partition,
|
|
153
|
+
byteLength: existing.byteLength,
|
|
154
|
+
...(existing.mediaType !== undefined
|
|
155
|
+
? { mediaType: existing.mediaType }
|
|
156
|
+
: {}),
|
|
157
|
+
createdAtMs: existing.createdAtMs,
|
|
158
|
+
};
|
|
158
159
|
}
|
|
160
|
+
// A re-upload that names the media type an earlier upload left out
|
|
161
|
+
// rewrites the object under the same precondition, keeping its age.
|
|
159
162
|
const meta = {
|
|
160
163
|
byteLength: bytes.length,
|
|
161
164
|
...(mediaType !== undefined ? { mediaType } : {}),
|
|
162
|
-
createdAtMs: nowMs,
|
|
165
|
+
createdAtMs: existing?.createdAtMs ?? nowMs,
|
|
163
166
|
};
|
|
164
167
|
// Conditional write: of two concurrent uploads of the same blob exactly
|
|
165
168
|
// one lands, so the first createdAtMs survives and the blob counts once.
|
|
@@ -176,7 +179,10 @@ export class BucketBlobStore {
|
|
|
176
179
|
return this.put(partition, blobId, bytes, nowMs, mediaType);
|
|
177
180
|
if (preexisting === undefined)
|
|
178
181
|
await this.#bumpStats({ count: 1, bytes: bytes.length });
|
|
179
|
-
return record;
|
|
182
|
+
return { ...record, createdAtMs: meta.createdAtMs };
|
|
183
|
+
}
|
|
184
|
+
async delete(partition, blobId) {
|
|
185
|
+
await this.#bucket.delete(this.objectKeyFor(partition, blobId));
|
|
180
186
|
}
|
|
181
187
|
async has(partition, blobId) {
|
|
182
188
|
if (!BLOB_ID_PATTERN.test(blobId))
|
package/dist/d1-storage.d.ts
CHANGED
|
@@ -42,7 +42,7 @@ import type { CommitPruneQuery, CommitPruneResult } from './storage.js';
|
|
|
42
42
|
*/
|
|
43
43
|
import { type ScopeMap } from '@syncular/core';
|
|
44
44
|
import type { CompiledSchema, CompiledTable } from './schema.js';
|
|
45
|
-
import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDeclaration, ClientCursorInfo, ClientPushResult, ClientRecord, CommitDetail, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowChangeRecord, RowChangesQuery, RowListQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, ServeGate, StorageTransaction, StoredCommit, StoredCheckpoint, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
|
|
45
|
+
import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDeclaration, ClientCursorInfo, ClientPushResult, ClientRecord, CommitDetail, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowChangeRecord, RowChangesQuery, RowListQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ScopeValueHistoryQuery, ScopeValueQuery, ServerStorage, ServeGate, StorageTransaction, StoredCommit, StoredCheckpoint, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
|
|
46
46
|
export interface D1PreparedStatement {
|
|
47
47
|
bind(...values: unknown[]): D1PreparedStatement;
|
|
48
48
|
first<T = Record<string, unknown>>(): Promise<T | null>;
|
|
@@ -134,6 +134,8 @@ export declare class D1ServerStorage implements ServerStorage {
|
|
|
134
134
|
readonly scopes: Record<string, string>;
|
|
135
135
|
}[]>;
|
|
136
136
|
listReferencedBlobIds(partition: string): Promise<string[]>;
|
|
137
|
+
listScopeValueRows(partition: string, query: ScopeValueQuery): Promise<string[]>;
|
|
138
|
+
eraseScopeValueHistory(partition: string, query: ScopeValueHistoryQuery): Promise<number>;
|
|
137
139
|
listClientRecords(partition: string): Promise<ClientRecord[]>;
|
|
138
140
|
listCommitMetadata(partition: string, query: CommitMetadataQuery): Promise<CommitMetadata[]>;
|
|
139
141
|
getCommit(partition: string, commitSeq: number): Promise<CommitDetail | undefined>;
|
package/dist/d1-storage.js
CHANGED
|
@@ -47,7 +47,7 @@ import { bindAuthoritativePartition, prepareAuthoritativeQuery, } from './author
|
|
|
47
47
|
import { syncError } from './errors.js';
|
|
48
48
|
import { assertAppendOnlyMigration, assertPhysicalColumns, assertStoredLayouts, commitWindowPageSql, LATEST_SCOPED_CHANGE_SEQ_SQLITE_SQL, deleteRowSql, deleteSqliteRowScopesSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, quoteIdent, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, SYNC_COLUMN_VERSIONS_COLUMN, SYNC_PARTITION_COLUMN, SYNC_PAYLOAD_COLUMN, SYNC_ROW_ID_COLUMN, SYNC_SCOPES_COLUMN, SYNC_VERSION_COLUMN, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, tableColumnNames, toSqlValue, upsertSql, upsertValues, } from './relational-rows.js';
|
|
49
49
|
import { matchesEffective } from './scopes.js';
|
|
50
|
-
import { asUint8Array, collectCommitWindowPage, deserializePushResult, placeholders, serializePushResult, sqliteDdlStatements, toStoredChange, toStoredRow, } from './sqlite-dialect.js';
|
|
50
|
+
import { asUint8Array, collectCommitWindowPage, deserializePushResult, placeholders, ERASE_SCOPE_VALUE_HISTORY_SQLITE_SQL, eraseScopeValueHistoryParams, LIST_SCOPE_VALUE_ROWS_SQLITE_SQL, serializePushResult, sqliteDdlStatements, toStoredChange, toStoredRow, } from './sqlite-dialect.js';
|
|
51
51
|
import { isD1ConstraintError, StorageConstraintError } from './storage-errors.js';
|
|
52
52
|
import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js';
|
|
53
53
|
function toStoredReaction(record) {
|
|
@@ -1654,6 +1654,30 @@ export class D1ServerStorage {
|
|
|
1654
1654
|
.all();
|
|
1655
1655
|
return results.map((r) => r.blob_id);
|
|
1656
1656
|
}
|
|
1657
|
+
async listScopeValueRows(partition, query) {
|
|
1658
|
+
const { results } = await this.#db
|
|
1659
|
+
.prepare(LIST_SCOPE_VALUE_ROWS_SQLITE_SQL)
|
|
1660
|
+
.bind(partition, query.table, query.variable, query.value, query.limit)
|
|
1661
|
+
.all();
|
|
1662
|
+
return results.map((row) => row.row_id);
|
|
1663
|
+
}
|
|
1664
|
+
async eraseScopeValueHistory(partition, query) {
|
|
1665
|
+
if (!this.#pushApplySerialized)
|
|
1666
|
+
throw new Error('D1 erasure requires externally serialized partition writes');
|
|
1667
|
+
const params = eraseScopeValueHistoryParams(partition, query);
|
|
1668
|
+
const results = await this.#db.batch(ERASE_SCOPE_VALUE_HISTORY_SQLITE_SQL.map((sql) => this.#db.prepare(sql).bind(...params)));
|
|
1669
|
+
let removed = 0;
|
|
1670
|
+
for (const result of results) {
|
|
1671
|
+
if (typeof result !== 'object' ||
|
|
1672
|
+
result === null ||
|
|
1673
|
+
!('results' in result) ||
|
|
1674
|
+
!Array.isArray(result.results)) {
|
|
1675
|
+
throw new Error('D1 erasure returned an invalid batch result');
|
|
1676
|
+
}
|
|
1677
|
+
removed += result.results.length;
|
|
1678
|
+
}
|
|
1679
|
+
return removed;
|
|
1680
|
+
}
|
|
1657
1681
|
// -- admin/console read surface --------------------------------------------
|
|
1658
1682
|
async listClientRecords(partition) {
|
|
1659
1683
|
const { results } = await this.#db
|
package/dist/erase.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type SyncServerConfig } from './context.js';
|
|
2
|
+
export interface EraseScopeValueOptions {
|
|
3
|
+
readonly partition: string;
|
|
4
|
+
/** The scope variable as declared by the schema's scope patterns. */
|
|
5
|
+
readonly variable: string;
|
|
6
|
+
readonly value: string;
|
|
7
|
+
/**
|
|
8
|
+
* Rows deleted per call, and change records removed per table per call
|
|
9
|
+
* (default 200, at most the push operation cap).
|
|
10
|
+
*/
|
|
11
|
+
readonly maxRows?: number;
|
|
12
|
+
/** Actor recorded on the erasure commits (default `syncular.erase`). */
|
|
13
|
+
readonly actorId?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface EraseScopeValueResult {
|
|
16
|
+
/** Rows the erasure commit of this call deleted. */
|
|
17
|
+
readonly rowsErased: number;
|
|
18
|
+
/** The erasure commit of this call, when it deleted rows. */
|
|
19
|
+
readonly commitSeq?: number;
|
|
20
|
+
/** Change log records this call removed. */
|
|
21
|
+
readonly historyErased: number;
|
|
22
|
+
/** Blobs whose last reference this call removed; their bytes are gone. */
|
|
23
|
+
readonly blobsErased: readonly string[];
|
|
24
|
+
/** Nothing of the binding is left; further calls change nothing. */
|
|
25
|
+
readonly done: boolean;
|
|
26
|
+
}
|
|
27
|
+
/** Erase one scope binding of one partition, one budgeted step per call. */
|
|
28
|
+
export declare function eraseScopeValue(config: SyncServerConfig, options: EraseScopeValueOptions): Promise<EraseScopeValueResult>;
|
package/dist/erase.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scope-value erasure (SPEC.md §4.6.1): remove every row whose stored scope
|
|
3
|
+
* binds one variable to one value, the upsert change records that carried
|
|
4
|
+
* those rows, and the blob bytes no remaining row references.
|
|
5
|
+
*
|
|
6
|
+
* Live rows leave through one ordinary delete commit per call, applied on
|
|
7
|
+
* the shared push path (partition lock, tombstones, blob reference index,
|
|
8
|
+
* realtime fanout). The commit advances the binding's scoped change seq, so
|
|
9
|
+
* no cached segment taken before the erasure is reused, and every client
|
|
10
|
+
* holding the rows receives the deletes. Once no live row is left, each call
|
|
11
|
+
* scrubs the change log of the binding. Hosts loop until `done`.
|
|
12
|
+
*/
|
|
13
|
+
import { decodeRow } from '@syncular/core';
|
|
14
|
+
import { limitsOf } from './context.js';
|
|
15
|
+
import { SyncError } from './errors.js';
|
|
16
|
+
import { blobIdsInRow, processPushOperationsWithTrace } from './push.js';
|
|
17
|
+
import { compileSchema } from './schema.js';
|
|
18
|
+
import { StorageQueryError } from './storage-errors.js';
|
|
19
|
+
/** Erase one scope binding of one partition, one budgeted step per call. */
|
|
20
|
+
export async function eraseScopeValue(config, options) {
|
|
21
|
+
const { storage } = config;
|
|
22
|
+
const { partition, variable, value } = options;
|
|
23
|
+
const maxRows = options.maxRows ?? 200;
|
|
24
|
+
const listRows = storage.listScopeValueRows?.bind(storage);
|
|
25
|
+
const eraseHistory = storage.eraseScopeValueHistory?.bind(storage);
|
|
26
|
+
const listReferences = storage.listRowsReferencingBlob?.bind(storage);
|
|
27
|
+
if (listRows === undefined ||
|
|
28
|
+
eraseHistory === undefined ||
|
|
29
|
+
listReferences === undefined) {
|
|
30
|
+
throw new StorageQueryError('sync.storage.erase_unsupported');
|
|
31
|
+
}
|
|
32
|
+
const schema = compileSchema(config.schema);
|
|
33
|
+
const tables = [...schema.tables.values()].filter((table) => table.scopePatterns.some((pattern) => pattern.variable === variable));
|
|
34
|
+
if (tables.length === 0 ||
|
|
35
|
+
value.length === 0 ||
|
|
36
|
+
!Number.isSafeInteger(maxRows) ||
|
|
37
|
+
maxRows < 1 ||
|
|
38
|
+
maxRows > limitsOf(config).maxOperationsPerRequest) {
|
|
39
|
+
throw new StorageQueryError('sync.storage.invalid_erase_query');
|
|
40
|
+
}
|
|
41
|
+
if ((await storage.getPartitionLogEpoch(partition)) === undefined) {
|
|
42
|
+
return { rowsErased: 0, historyErased: 0, blobsErased: [], done: true };
|
|
43
|
+
}
|
|
44
|
+
await storage.ensureSchema(schema, config.checkpoints);
|
|
45
|
+
const targets = [];
|
|
46
|
+
for (const table of tables) {
|
|
47
|
+
const limit = maxRows - targets.length;
|
|
48
|
+
if (limit === 0)
|
|
49
|
+
break;
|
|
50
|
+
const query = { table: table.name, variable, value, limit };
|
|
51
|
+
for (const rowId of await listRows(partition, query)) {
|
|
52
|
+
targets.push({ table: table.name, rowId });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (targets.length > 0) {
|
|
56
|
+
// Erasure is the operator's act: app validators do not gate it.
|
|
57
|
+
const { validators, commitValidator, ...trusted } = config;
|
|
58
|
+
const blobIds = new Set();
|
|
59
|
+
let rowsErased = 0;
|
|
60
|
+
const processed = await processPushOperationsWithTrace({ ...trusted, partition, actorId: options.actorId ?? 'syncular.erase' }, schema, {
|
|
61
|
+
ok: true,
|
|
62
|
+
allowed: Object.fromEntries([...schema.tables.values()].flatMap((table) => table.scopePatterns.map((pattern) => [pattern.variable, ['*']]))),
|
|
63
|
+
}, 'syncular.erase', crypto.randomUUID(), async (tx) => {
|
|
64
|
+
const operations = [];
|
|
65
|
+
for (const target of targets) {
|
|
66
|
+
// Re-read under the partition lock: a row may have moved or gone.
|
|
67
|
+
const stored = await tx.getRow(target.table, target.rowId);
|
|
68
|
+
if (stored?.scopes[variable] !== value)
|
|
69
|
+
continue;
|
|
70
|
+
const table = schema.tables.get(target.table);
|
|
71
|
+
for (const blobId of blobIdsInRow(table, decodeRow(table.columns, stored.payload))) {
|
|
72
|
+
blobIds.add(blobId);
|
|
73
|
+
}
|
|
74
|
+
operations.push({
|
|
75
|
+
table: target.table,
|
|
76
|
+
op: 'delete',
|
|
77
|
+
rowId: target.rowId,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
rowsErased = operations.length;
|
|
81
|
+
return operations;
|
|
82
|
+
});
|
|
83
|
+
const { frame } = processed;
|
|
84
|
+
if (frame.status !== 'applied') {
|
|
85
|
+
const failed = frame.results.find((result) => 'code' in result);
|
|
86
|
+
throw failed !== undefined && 'code' in failed
|
|
87
|
+
? new SyncError(failed.code, failed.message)
|
|
88
|
+
: new Error('eraseScopeValue: the erasure commit was not applied');
|
|
89
|
+
}
|
|
90
|
+
const blobsErased = [];
|
|
91
|
+
for (const blobId of blobIds) {
|
|
92
|
+
if ((await listReferences(partition, blobId)).length > 0)
|
|
93
|
+
continue;
|
|
94
|
+
await config.blobs?.delete(partition, blobId);
|
|
95
|
+
blobsErased.push(blobId);
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
rowsErased,
|
|
99
|
+
...(frame.commitSeq !== undefined ? { commitSeq: frame.commitSeq } : {}),
|
|
100
|
+
historyErased: 0,
|
|
101
|
+
blobsErased,
|
|
102
|
+
done: false,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
const throughSeq = await storage.getMaxCommitSeq(partition);
|
|
106
|
+
let historyErased = 0;
|
|
107
|
+
for (const table of tables) {
|
|
108
|
+
historyErased += await eraseHistory(partition, {
|
|
109
|
+
table: table.name,
|
|
110
|
+
variable,
|
|
111
|
+
value,
|
|
112
|
+
throughSeq,
|
|
113
|
+
limit: maxRows,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
rowsErased: 0,
|
|
118
|
+
historyErased,
|
|
119
|
+
blobsErased: [],
|
|
120
|
+
done: historyErased === 0,
|
|
121
|
+
};
|
|
122
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -17,6 +17,7 @@ export * from './content-encoding.js';
|
|
|
17
17
|
export * from './context.js';
|
|
18
18
|
export * from './crdt-merger.js';
|
|
19
19
|
export * from './d1-storage.js';
|
|
20
|
+
export * from './erase.js';
|
|
20
21
|
export * from './errors.js';
|
|
21
22
|
export * from './events.js';
|
|
22
23
|
export * from './events-ring.js';
|
package/dist/index.js
CHANGED
|
@@ -17,6 +17,7 @@ export * from './content-encoding.js';
|
|
|
17
17
|
export * from './context.js';
|
|
18
18
|
export * from './crdt-merger.js';
|
|
19
19
|
export * from './d1-storage.js';
|
|
20
|
+
export * from './erase.js';
|
|
20
21
|
export * from './errors.js';
|
|
21
22
|
export * from './events.js';
|
|
22
23
|
export * from './events-ring.js';
|
|
@@ -39,7 +39,7 @@ import type { CommitPruneQuery, CommitPruneResult } from './storage.js';
|
|
|
39
39
|
import type { ScopeMap } from '@syncular/core';
|
|
40
40
|
import { type PgExecutor } from './pg-executor.js';
|
|
41
41
|
import type { CompiledSchema, CompiledTable } from './schema.js';
|
|
42
|
-
import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDeclaration, ClientCursorInfo, ClientPushResult, ClientRecord, CommitDetail, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowChangeRecord, RowChangesQuery, RowListQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, ServeGate, StorageTransaction, StoredCheckpoint, StoredCommit, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
|
|
42
|
+
import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDeclaration, ClientCursorInfo, ClientPushResult, ClientRecord, CommitDetail, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowChangeRecord, RowChangesQuery, RowListQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ScopeValueHistoryQuery, ScopeValueQuery, ServerStorage, ServeGate, StorageTransaction, StoredCheckpoint, StoredCommit, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
|
|
43
43
|
/**
|
|
44
44
|
* Schema DDL. Applied by `PostgresServerStorage.migrate()` (idempotent).
|
|
45
45
|
*
|
|
@@ -134,6 +134,8 @@ export declare class PostgresServerStorage implements ServerStorage {
|
|
|
134
134
|
readonly scopes: Record<string, string>;
|
|
135
135
|
}[]>;
|
|
136
136
|
listReferencedBlobIds(partition: string): Promise<string[]>;
|
|
137
|
+
listScopeValueRows(partition: string, query: ScopeValueQuery): Promise<string[]>;
|
|
138
|
+
eraseScopeValueHistory(partition: string, query: ScopeValueHistoryQuery): Promise<number>;
|
|
137
139
|
listClientRecords(partition: string): Promise<ClientRecord[]>;
|
|
138
140
|
listCommitMetadata(partition: string, query: CommitMetadataQuery): Promise<CommitMetadata[]>;
|
|
139
141
|
getCommit(partition: string, commitSeq: number): Promise<CommitDetail | undefined>;
|
package/dist/postgres-storage.js
CHANGED
|
@@ -1644,6 +1644,46 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
|
|
|
1644
1644
|
const { rows } = await this.#exec.query('SELECT DISTINCT blob_id FROM sync_blob_refs WHERE partition=$1', [partition]);
|
|
1645
1645
|
return rows.map((r) => r.blob_id);
|
|
1646
1646
|
}
|
|
1647
|
+
async listScopeValueRows(partition, query) {
|
|
1648
|
+
const { rows } = await this.#exec.query(`SELECT row_id FROM sync_row_scopes
|
|
1649
|
+
WHERE partition=$1 AND tbl=$2 AND var=$3 AND value=$4
|
|
1650
|
+
ORDER BY row_id LIMIT $5`, [partition, query.table, query.variable, query.value, query.limit]);
|
|
1651
|
+
return rows.map((row) => row.row_id);
|
|
1652
|
+
}
|
|
1653
|
+
async eraseScopeValueHistory(partition, query) {
|
|
1654
|
+
const params = [
|
|
1655
|
+
partition,
|
|
1656
|
+
query.table,
|
|
1657
|
+
query.variable,
|
|
1658
|
+
query.value,
|
|
1659
|
+
query.throughSeq,
|
|
1660
|
+
query.limit,
|
|
1661
|
+
];
|
|
1662
|
+
return this.#exec.transaction(async (client) => {
|
|
1663
|
+
await lockPartitionOn(client, partition);
|
|
1664
|
+
// Upsert change records of the binding, then the change scope entries
|
|
1665
|
+
// whose commit no longer holds a change of the binding.
|
|
1666
|
+
const changes = await client.query(`DELETE FROM sync_changes c USING (
|
|
1667
|
+
SELECT c2.commit_seq, c2.idx FROM sync_change_scopes s
|
|
1668
|
+
JOIN sync_changes c2 ON c2.partition=s.partition
|
|
1669
|
+
AND c2.commit_seq=s.commit_seq AND c2.tbl=s.tbl
|
|
1670
|
+
WHERE s.partition=$1 AND s.tbl=$2 AND s.var=$3 AND s.value=$4
|
|
1671
|
+
AND s.commit_seq<=$5 AND c2.op=1 AND c2.scopes->>$3=s.value
|
|
1672
|
+
LIMIT $6) d
|
|
1673
|
+
WHERE c.partition=$1 AND c.commit_seq=d.commit_seq AND c.idx=d.idx`, params);
|
|
1674
|
+
const scopes = await client.query(`DELETE FROM sync_change_scopes cs USING (
|
|
1675
|
+
SELECT s.commit_seq FROM sync_change_scopes s
|
|
1676
|
+
WHERE s.partition=$1 AND s.tbl=$2 AND s.var=$3 AND s.value=$4
|
|
1677
|
+
AND s.commit_seq<=$5 AND NOT EXISTS (
|
|
1678
|
+
SELECT 1 FROM sync_changes c
|
|
1679
|
+
WHERE c.partition=s.partition AND c.commit_seq=s.commit_seq
|
|
1680
|
+
AND c.tbl=s.tbl AND c.scopes->>$3=s.value)
|
|
1681
|
+
LIMIT $6) d
|
|
1682
|
+
WHERE cs.partition=$1 AND cs.tbl=$2 AND cs.var=$3 AND cs.value=$4
|
|
1683
|
+
AND cs.commit_seq=d.commit_seq`, params);
|
|
1684
|
+
return changes.rowCount + scopes.rowCount;
|
|
1685
|
+
});
|
|
1686
|
+
}
|
|
1647
1687
|
// -- admin/console read surface --------------------------------------------
|
|
1648
1688
|
async listClientRecords(partition) {
|
|
1649
1689
|
const { rows } = await this.#exec.query('SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=$1 ORDER BY updated_at_ms DESC', [partition]);
|
package/dist/push.d.ts
CHANGED
|
@@ -17,11 +17,17 @@
|
|
|
17
17
|
* merge); a validator throw rejects the whole commit atomically with a
|
|
18
18
|
* host code.
|
|
19
19
|
*/
|
|
20
|
-
import { type PushCommitFrame, type PushOperation, type PushResultFrame } from '@syncular/core';
|
|
20
|
+
import { type PushCommitFrame, type PushOperation, type PushResultFrame, type RowValue } from '@syncular/core';
|
|
21
21
|
import type { SyncRequestContext } from './context.js';
|
|
22
|
-
import type { CompiledSchema } from './schema.js';
|
|
22
|
+
import type { CompiledSchema, CompiledTable } from './schema.js';
|
|
23
23
|
import type { ResolvedScopes } from './scopes.js';
|
|
24
24
|
import type { StorageTransaction, StoredCommit } from './storage.js';
|
|
25
|
+
/**
|
|
26
|
+
* Extract the blobIds a decoded row references through its `blob_ref`
|
|
27
|
+
* columns (§5.9.4), skipping NULLs. Malformed BlobRefs already failed at
|
|
28
|
+
* row-codec decode (§5.9.1), so `parseBlobRef` here is total.
|
|
29
|
+
*/
|
|
30
|
+
export declare function blobIdsInRow(table: CompiledTable, values: readonly RowValue[]): string[];
|
|
25
31
|
export interface ProcessedPushCommit {
|
|
26
32
|
readonly frame: PushResultFrame;
|
|
27
33
|
/** True when this request observed an already-recorded idempotency outcome. */
|
package/dist/push.js
CHANGED
|
@@ -35,7 +35,7 @@ import { CommitValidationRejection, toValidateRow, ValidationRejection, } from '
|
|
|
35
35
|
* columns (§5.9.4), skipping NULLs. Malformed BlobRefs already failed at
|
|
36
36
|
* row-codec decode (§5.9.1), so `parseBlobRef` here is total.
|
|
37
37
|
*/
|
|
38
|
-
function blobIdsInRow(table, values) {
|
|
38
|
+
export function blobIdsInRow(table, values) {
|
|
39
39
|
const ids = [];
|
|
40
40
|
for (const index of table.blobRefColumnIndices) {
|
|
41
41
|
const value = values[index];
|
|
@@ -7,6 +7,7 @@ export declare class SqliteBlobStore implements BlobStore {
|
|
|
7
7
|
readonly db: SqliteDatabase;
|
|
8
8
|
constructor(db?: SqliteDatabase | string);
|
|
9
9
|
put(partition: string, blobId: string, bytes: Uint8Array, nowMs: number, mediaType?: string): Promise<BlobRecord>;
|
|
10
|
+
delete(partition: string, blobId: string): Promise<void>;
|
|
10
11
|
has(partition: string, blobId: string): Promise<boolean>;
|
|
11
12
|
get(partition: string, blobId: string): Promise<{
|
|
12
13
|
record: BlobRecord;
|
|
@@ -17,8 +17,15 @@ export class SqliteBlobStore {
|
|
|
17
17
|
}
|
|
18
18
|
async put(partition, blobId, bytes, nowMs, mediaType) {
|
|
19
19
|
const existing = await this.get(partition, blobId);
|
|
20
|
-
if (existing !== undefined)
|
|
21
|
-
|
|
20
|
+
if (existing !== undefined) {
|
|
21
|
+
if (existing.record.mediaType !== undefined || mediaType === undefined)
|
|
22
|
+
return existing.record;
|
|
23
|
+
// A re-upload names the media type an earlier upload left out.
|
|
24
|
+
this.db
|
|
25
|
+
.query('UPDATE sync_blobs SET media_type=? WHERE partition=? AND blob_id=? AND media_type IS NULL')
|
|
26
|
+
.run(mediaType, partition, blobId);
|
|
27
|
+
return { ...existing.record, mediaType };
|
|
28
|
+
}
|
|
22
29
|
const record = {
|
|
23
30
|
blobId,
|
|
24
31
|
partition,
|
|
@@ -33,6 +40,11 @@ export class SqliteBlobStore {
|
|
|
33
40
|
.run(partition, blobId, bytes.length, mediaType ?? null, nowMs, bytes);
|
|
34
41
|
return record;
|
|
35
42
|
}
|
|
43
|
+
async delete(partition, blobId) {
|
|
44
|
+
this.db
|
|
45
|
+
.query('DELETE FROM sync_blobs WHERE partition=? AND blob_id=?')
|
|
46
|
+
.run(partition, blobId);
|
|
47
|
+
}
|
|
36
48
|
async has(partition, blobId) {
|
|
37
49
|
const row = this.db
|
|
38
50
|
.query('SELECT 1 AS n FROM sync_blobs WHERE partition=? AND blob_id=?')
|
package/dist/sqlite-dialect.d.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* behavior is held key-for-key regardless.
|
|
17
17
|
*/
|
|
18
18
|
import type { ScopeMap } from '@syncular/core';
|
|
19
|
-
import type { StoredChange, StoredCommit, StoredPushResult, StoredRow } from './storage.js';
|
|
19
|
+
import type { ScopeValueHistoryQuery, StoredChange, StoredCommit, StoredPushResult, StoredRow } from './storage.js';
|
|
20
20
|
/**
|
|
21
21
|
* Schema DDL, one statement per `;`-delimited chunk. Native SQLite applies
|
|
22
22
|
* the whole string via `db.exec(SQLITE_DDL)`; D1 applies each statement
|
|
@@ -39,6 +39,16 @@ import type { StoredChange, StoredCommit, StoredPushResult, StoredRow } from './
|
|
|
39
39
|
export declare const SQLITE_DDL = "\nCREATE TABLE IF NOT EXISTS sync_partitions(\n partition TEXT PRIMARY KEY,\n max_commit_seq INTEGER NOT NULL DEFAULT 0,\n horizon_seq INTEGER NOT NULL DEFAULT 0\n);\nCREATE TABLE IF NOT EXISTS sync_partition_registry(\n partition TEXT PRIMARY KEY,\n log_epoch TEXT NOT NULL,\n epoch_required INTEGER NOT NULL DEFAULT 0,\n last_authenticated_at_ms INTEGER NOT NULL\n);\nCREATE TABLE IF NOT EXISTS sync_row_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, row_id)\n);\nCREATE INDEX IF NOT EXISTS sync_row_scopes_by_row\n ON sync_row_scopes(partition, tbl, row_id);\nCREATE TABLE IF NOT EXISTS sync_commits(\n partition TEXT NOT NULL, commit_seq INTEGER NOT NULL,\n client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,\n actor_id TEXT NOT NULL, created_at_ms INTEGER NOT NULL,\n writer_version INTEGER,\n PRIMARY KEY(partition, commit_seq)\n);\nCREATE INDEX IF NOT EXISTS sync_commits_by_time\n ON sync_commits(partition, created_at_ms);\nCREATE TABLE IF NOT EXISTS sync_changes(\n partition TEXT NOT NULL, commit_seq INTEGER NOT NULL, idx INTEGER NOT NULL,\n tbl TEXT NOT NULL, row_id TEXT NOT NULL, op INTEGER NOT NULL,\n row_version INTEGER, scopes TEXT NOT NULL, payload BLOB,\n PRIMARY KEY(partition, commit_seq, idx)\n);\nCREATE INDEX IF NOT EXISTS sync_changes_by_table\n ON sync_changes(partition, commit_seq, tbl, idx);\nCREATE TABLE IF NOT EXISTS sync_change_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, commit_seq INTEGER NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, commit_seq)\n);\nCREATE TABLE IF NOT EXISTS sync_push_results(\n partition TEXT NOT NULL, client_id TEXT NOT NULL,\n client_commit_id TEXT NOT NULL, result TEXT NOT NULL,\n PRIMARY KEY(partition, client_id, client_commit_id)\n);\nCREATE TABLE IF NOT EXISTS sync_reactions(\n partition TEXT NOT NULL, idempotency_key TEXT NOT NULL,\n type TEXT NOT NULL, version INTEGER NOT NULL, payload TEXT NOT NULL,\n source_client_id TEXT NOT NULL, source_client_commit_id TEXT NOT NULL,\n source_commit_seq INTEGER NOT NULL, created_at_ms INTEGER NOT NULL,\n available_at_ms INTEGER NOT NULL, status TEXT NOT NULL,\n attempts INTEGER NOT NULL, max_attempts INTEGER NOT NULL,\n lease_owner TEXT, lease_expires_at_ms INTEGER, completed_at_ms INTEGER,\n last_failure TEXT,\n PRIMARY KEY(partition, idempotency_key),\n CHECK(status IN ('pending', 'leased', 'completed', 'dead-letter'))\n);\nCREATE INDEX IF NOT EXISTS sync_reactions_due\n ON sync_reactions(partition, status, available_at_ms, created_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_lease\n ON sync_reactions(partition, status, lease_expires_at_ms);\nCREATE INDEX IF NOT EXISTS sync_reactions_completed\n ON sync_reactions(partition, status, completed_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_dead_letter\n ON sync_reactions(partition, status, available_at_ms, idempotency_key);\nCREATE TABLE IF NOT EXISTS sync_clients(\n partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,\n wire_version INTEGER NOT NULL DEFAULT 1,\n cursor INTEGER NOT NULL, subscriptions TEXT NOT NULL,\n updated_at_ms INTEGER NOT NULL,\n PRIMARY KEY(partition, client_id)\n);\nCREATE TABLE IF NOT EXISTS sync_blob_refs(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n blob_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, row_id, blob_id)\n);\nCREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob\n ON sync_blob_refs(partition, blob_id);\nCREATE TABLE IF NOT EXISTS sync_tombstones(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n commit_seq INTEGER NOT NULL,\n PRIMARY KEY(partition, tbl, row_id)\n);\nCREATE TABLE IF NOT EXISTS sync_backfill_checkpoints(\n partition TEXT NOT NULL, name TEXT NOT NULL,\n schema_version INTEGER NOT NULL,\n state TEXT NOT NULL CHECK(state IN ('declared','backfilling','activated')),\n watermark INTEGER NOT NULL DEFAULT 0,\n owner_epoch INTEGER NOT NULL DEFAULT 0,\n observed_rows INTEGER NOT NULL DEFAULT 0,\n updated_at_ms INTEGER NOT NULL,\n PRIMARY KEY(partition, name)\n);\nCREATE TABLE IF NOT EXISTS sync_writer_fence(\n partition TEXT PRIMARY KEY,\n required_writer_version INTEGER NOT NULL\n);\n";
|
|
40
40
|
/** Split the DDL into individual statements (D1 applies them one by one). */
|
|
41
41
|
export declare function sqliteDdlStatements(): string[];
|
|
42
|
+
/** §4.6.1: rowIds of one table bound to one scope value, budgeted. */
|
|
43
|
+
export declare const LIST_SCOPE_VALUE_ROWS_SQLITE_SQL = "SELECT row_id FROM sync_row_scopes WHERE partition=? AND tbl=? AND var=? AND value=? ORDER BY row_id LIMIT ?";
|
|
44
|
+
/**
|
|
45
|
+
* §4.6.1 history scrub, bound by `eraseScopeValueHistoryParams`: upsert change
|
|
46
|
+
* records of the binding first, then the change scope entries whose commit
|
|
47
|
+
* no longer holds a change of the binding. Each returns one row per removed
|
|
48
|
+
* record (D1 batch results carry no reliable change count).
|
|
49
|
+
*/
|
|
50
|
+
export declare const ERASE_SCOPE_VALUE_HISTORY_SQLITE_SQL: readonly ["DELETE FROM sync_changes WHERE rowid IN (\n SELECT c.rowid FROM sync_change_scopes s JOIN sync_changes c\n ON c.partition=s.partition AND c.commit_seq=s.commit_seq AND c.tbl=s.tbl\n WHERE s.partition=?1 AND s.tbl=?2 AND s.var=?3 AND s.value=?4\n AND s.commit_seq<=?5 AND c.op=1 AND json_extract(c.scopes, ?6)=s.value\n LIMIT ?7) RETURNING 1 AS removed", "DELETE FROM sync_change_scopes WHERE rowid IN (\n SELECT s.rowid FROM sync_change_scopes s\n WHERE s.partition=?1 AND s.tbl=?2 AND s.var=?3 AND s.value=?4\n AND s.commit_seq<=?5 AND NOT EXISTS (\n SELECT 1 FROM sync_changes c\n WHERE c.partition=s.partition AND c.commit_seq=s.commit_seq\n AND c.tbl=s.tbl AND json_extract(c.scopes, ?6)=s.value)\n LIMIT ?7) RETURNING 1 AS removed"];
|
|
51
|
+
export declare function eraseScopeValueHistoryParams(partition: string, query: ScopeValueHistoryQuery): [string, string, string, string, number, string, number];
|
|
42
52
|
/** `?,?,…` for an `IN (…)` clause of `count` positional parameters. */
|
|
43
53
|
export declare function placeholders(count: number): string;
|
|
44
54
|
/** Serialize a push result to the JSON `TEXT` stored in `sync_push_results`. */
|
package/dist/sqlite-dialect.js
CHANGED
|
@@ -124,6 +124,41 @@ export function sqliteDdlStatements() {
|
|
|
124
124
|
.map((statement) => statement.trim())
|
|
125
125
|
.filter((statement) => statement.length > 0);
|
|
126
126
|
}
|
|
127
|
+
/** §4.6.1: rowIds of one table bound to one scope value, budgeted. */
|
|
128
|
+
export const LIST_SCOPE_VALUE_ROWS_SQLITE_SQL = 'SELECT row_id FROM sync_row_scopes WHERE partition=? AND tbl=? AND var=? AND value=? ORDER BY row_id LIMIT ?';
|
|
129
|
+
/**
|
|
130
|
+
* §4.6.1 history scrub, bound by `eraseScopeValueHistoryParams`: upsert change
|
|
131
|
+
* records of the binding first, then the change scope entries whose commit
|
|
132
|
+
* no longer holds a change of the binding. Each returns one row per removed
|
|
133
|
+
* record (D1 batch results carry no reliable change count).
|
|
134
|
+
*/
|
|
135
|
+
export const ERASE_SCOPE_VALUE_HISTORY_SQLITE_SQL = [
|
|
136
|
+
`DELETE FROM sync_changes WHERE rowid IN (
|
|
137
|
+
SELECT c.rowid FROM sync_change_scopes s JOIN sync_changes c
|
|
138
|
+
ON c.partition=s.partition AND c.commit_seq=s.commit_seq AND c.tbl=s.tbl
|
|
139
|
+
WHERE s.partition=?1 AND s.tbl=?2 AND s.var=?3 AND s.value=?4
|
|
140
|
+
AND s.commit_seq<=?5 AND c.op=1 AND json_extract(c.scopes, ?6)=s.value
|
|
141
|
+
LIMIT ?7) RETURNING 1 AS removed`,
|
|
142
|
+
`DELETE FROM sync_change_scopes WHERE rowid IN (
|
|
143
|
+
SELECT s.rowid FROM sync_change_scopes s
|
|
144
|
+
WHERE s.partition=?1 AND s.tbl=?2 AND s.var=?3 AND s.value=?4
|
|
145
|
+
AND s.commit_seq<=?5 AND NOT EXISTS (
|
|
146
|
+
SELECT 1 FROM sync_changes c
|
|
147
|
+
WHERE c.partition=s.partition AND c.commit_seq=s.commit_seq
|
|
148
|
+
AND c.tbl=s.tbl AND json_extract(c.scopes, ?6)=s.value)
|
|
149
|
+
LIMIT ?7) RETURNING 1 AS removed`,
|
|
150
|
+
];
|
|
151
|
+
export function eraseScopeValueHistoryParams(partition, query) {
|
|
152
|
+
return [
|
|
153
|
+
partition,
|
|
154
|
+
query.table,
|
|
155
|
+
query.variable,
|
|
156
|
+
query.value,
|
|
157
|
+
query.throughSeq,
|
|
158
|
+
`$.${JSON.stringify(query.variable)}`,
|
|
159
|
+
query.limit,
|
|
160
|
+
];
|
|
161
|
+
}
|
|
127
162
|
/** `?,?,…` for an `IN (…)` clause of `count` positional parameters. */
|
|
128
163
|
export function placeholders(count) {
|
|
129
164
|
return new Array(count).fill('?').join(',');
|
package/dist/sqlite-storage.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { CommitPruneQuery, CommitPruneResult } from './storage.js';
|
|
|
2
2
|
import type { ScopeMap } from '@syncular/core';
|
|
3
3
|
import type { CompiledSchema, CompiledTable } from './schema.js';
|
|
4
4
|
import { type SqliteDatabase } from './sqlite-driver.js';
|
|
5
|
-
import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDeclaration, ClientCursorInfo, ClientPushResult, ClientRecord, CommitDetail, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowChangeRecord, RowChangesQuery, RowListQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, ServeGate, StorageTransaction, StoredCommit, StoredCheckpoint, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
|
|
5
|
+
import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDeclaration, ClientCursorInfo, ClientPushResult, ClientRecord, CommitDetail, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowChangeRecord, RowChangesQuery, RowListQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ScopeValueHistoryQuery, ScopeValueQuery, ServerStorage, ServeGate, StorageTransaction, StoredCommit, StoredCheckpoint, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
|
|
6
6
|
export declare class SqliteServerStorage implements ServerStorage {
|
|
7
7
|
#private;
|
|
8
8
|
readonly db: SqliteDatabase;
|
|
@@ -67,6 +67,8 @@ export declare class SqliteServerStorage implements ServerStorage {
|
|
|
67
67
|
readonly scopes: Record<string, string>;
|
|
68
68
|
}[]>;
|
|
69
69
|
listReferencedBlobIds(partition: string): Promise<string[]>;
|
|
70
|
+
listScopeValueRows(partition: string, query: ScopeValueQuery): Promise<string[]>;
|
|
71
|
+
eraseScopeValueHistory(partition: string, query: ScopeValueHistoryQuery): Promise<number>;
|
|
70
72
|
listClientRecords(partition: string): Promise<ClientRecord[]>;
|
|
71
73
|
listCommitMetadata(partition: string, query: CommitMetadataQuery): Promise<CommitMetadata[]>;
|
|
72
74
|
getCommit(partition: string, commitSeq: number): Promise<CommitDetail | undefined>;
|
package/dist/sqlite-storage.js
CHANGED
|
@@ -15,7 +15,7 @@ import { bindAuthoritativePartition, prepareAuthoritativeQuery, } from './author
|
|
|
15
15
|
import { syncError } from './errors.js';
|
|
16
16
|
import { assertPhysicalColumns, assertStoredLayouts, commitWindowPageSql, LATEST_SCOPED_CHANGE_SEQ_SQLITE_SQL, deleteRowSql, deleteSqliteRowScopesSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, quoteIdent, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, SYNC_COLUMN_VERSIONS_COLUMN, SYNC_PARTITION_COLUMN, SYNC_PAYLOAD_COLUMN, SYNC_ROW_ID_COLUMN, SYNC_SCOPES_COLUMN, SYNC_VERSION_COLUMN, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
|
|
17
17
|
import { matchesEffective } from './scopes.js';
|
|
18
|
-
import { collectCommitWindowPage, deserializePushResult, placeholders, SQLITE_DDL, serializePushResult, toStoredChange, toStoredRow, } from './sqlite-dialect.js';
|
|
18
|
+
import { collectCommitWindowPage, deserializePushResult, ERASE_SCOPE_VALUE_HISTORY_SQLITE_SQL, eraseScopeValueHistoryParams, LIST_SCOPE_VALUE_ROWS_SQLITE_SQL, placeholders, SQLITE_DDL, serializePushResult, toStoredChange, toStoredRow, } from './sqlite-dialect.js';
|
|
19
19
|
import { SqliteAdapterRequiredError, } from './sqlite-driver.js';
|
|
20
20
|
import { isSqliteConstraintError, StorageConstraintError, } from './storage-errors.js';
|
|
21
21
|
import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js';
|
|
@@ -1198,6 +1198,31 @@ END`);
|
|
|
1198
1198
|
.all(partition);
|
|
1199
1199
|
return rows.map((r) => r.blob_id);
|
|
1200
1200
|
}
|
|
1201
|
+
async listScopeValueRows(partition, query) {
|
|
1202
|
+
return this.db
|
|
1203
|
+
.query(LIST_SCOPE_VALUE_ROWS_SQLITE_SQL)
|
|
1204
|
+
.all(partition, query.table, query.variable, query.value, query.limit)
|
|
1205
|
+
.map((row) => row.row_id);
|
|
1206
|
+
}
|
|
1207
|
+
async eraseScopeValueHistory(partition, query) {
|
|
1208
|
+
const params = eraseScopeValueHistoryParams(partition, query);
|
|
1209
|
+
return this.#serializeWrite(() => {
|
|
1210
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
1211
|
+
try {
|
|
1212
|
+
let removed = 0;
|
|
1213
|
+
for (const sql of ERASE_SCOPE_VALUE_HISTORY_SQLITE_SQL) {
|
|
1214
|
+
removed += this.db.query(sql).all(...params).length;
|
|
1215
|
+
}
|
|
1216
|
+
this.db.exec('COMMIT');
|
|
1217
|
+
return removed;
|
|
1218
|
+
}
|
|
1219
|
+
catch (error) {
|
|
1220
|
+
rollbackAfterError(() => {
|
|
1221
|
+
this.db.exec('ROLLBACK');
|
|
1222
|
+
}, error);
|
|
1223
|
+
}
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1201
1226
|
// -- admin/console read surface --------------------------------------------
|
|
1202
1227
|
async listClientRecords(partition) {
|
|
1203
1228
|
const records = this.db
|