@syncular/client 0.15.9 → 0.15.11
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/README.md +34 -0
- package/dist/client.d.ts +12 -0
- package/dist/client.js +140 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/local-purge.d.ts +45 -0
- package/dist/local-purge.js +92 -0
- package/dist/worker-entry.js +1 -0
- package/dist/worker-host.d.ts +2 -0
- package/dist/worker-host.js +3 -0
- package/dist/worker-protocol.d.ts +3 -0
- package/package.json +3 -3
- package/src/client.ts +181 -0
- package/src/index.ts +1 -0
- package/src/local-purge.ts +172 -0
- package/src/worker-entry.ts +1 -0
- package/src/worker-host.ts +5 -0
- package/src/worker-protocol.ts +3 -0
package/README.md
CHANGED
|
@@ -144,6 +144,40 @@ records a sorted `changedFields` list so conflict and rejection UI knows which
|
|
|
144
144
|
fields the user intended to touch. That intent is local-only and never enters
|
|
145
145
|
`PUSH_COMMIT`; full-row `mutate` operations omit it.
|
|
146
146
|
|
|
147
|
+
## Application-authorized local security purge
|
|
148
|
+
|
|
149
|
+
`purgeLocalData({ purgeId, targets })` is the narrow local-storage primitive
|
|
150
|
+
for an application that has already validated a server-authoritative device,
|
|
151
|
+
membership, or key-revocation directive. It is available on direct clients,
|
|
152
|
+
worker handles, the normalized React client, and the Tauri bridge.
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
const result = await client.purgeLocalData({
|
|
156
|
+
purgeId: directive.id,
|
|
157
|
+
targets: [
|
|
158
|
+
{
|
|
159
|
+
table: 'patient_notes',
|
|
160
|
+
selectors: { encryption_key_id: [directive.keyVersionId] },
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
});
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
The host MUST first quarantine the affected feature and gate/remove any
|
|
167
|
+
subscription that could download the protected rows again. This method does
|
|
168
|
+
not authenticate a directive, revoke server authority, delete app-owned files,
|
|
169
|
+
or remove a key from the OS secure store.
|
|
170
|
+
|
|
171
|
+
Within one local SQLite transaction the engine deletes exactly the matching
|
|
172
|
+
synced rows, lets generated FTS triggers remove their projections, drops every
|
|
173
|
+
whole pending commit with a matching operation, restores/replays unrelated
|
|
174
|
+
optimistic state, reconciles blob references, persists the `purgeId`, and emits
|
|
175
|
+
one revisioned change batch. A retry with the same canonical plan returns
|
|
176
|
+
`alreadyApplied: true`; reusing an id with different selectors fails closed.
|
|
177
|
+
Only bounded, non-empty, code-like values on plaintext string schema columns
|
|
178
|
+
are accepted. There is intentionally no full-table mode. The result exposes
|
|
179
|
+
counts only—never row ids or selector values.
|
|
180
|
+
|
|
147
181
|
Validator rejections may include bounded `details` (`fieldPaths`, `reason`,
|
|
148
182
|
`requiredAction`, and explicitly safe `references`). The details persist with
|
|
149
183
|
the rejection. Treat every value as a machine hint: map known values to
|
package/dist/client.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type { ClientDatabase, SqlRow, SqlValue } from './database.js';
|
|
|
13
13
|
import type { EncryptionConfig } from './encryption.js';
|
|
14
14
|
import { type ClientChangeListener, type CommandResult, type InvalidationListener, type LocalRevision, type SyncIntent, type SyncStatusSnapshot } from './invalidation.js';
|
|
15
15
|
import { type LeaderLock } from './leader-lock.js';
|
|
16
|
+
import { type LocalDataPurgeInput, type LocalDataPurgeResult } from './local-purge.js';
|
|
16
17
|
import { type OutboxCommit } from './outbox.js';
|
|
17
18
|
import { type CommitOutcome, type CommitOutcomeQuery, type ConflictRecord, type RejectionRecord, type ResolveCommitOutcomeInput } from './outcomes.js';
|
|
18
19
|
import { type ClientSchema } from './schema.js';
|
|
@@ -382,6 +383,17 @@ export declare class SyncClient {
|
|
|
382
383
|
patch(table: string, rowId: string, partial: Readonly<Record<string, unknown>>, options?: {
|
|
383
384
|
readonly baseVersion?: number;
|
|
384
385
|
}): string;
|
|
386
|
+
/**
|
|
387
|
+
* Apply one host-authorized local security purge. The host must stop/gate
|
|
388
|
+
* protected subscriptions before calling this method; this operation owns
|
|
389
|
+
* local SQLite cleanup only and intentionally cannot revoke server access.
|
|
390
|
+
*
|
|
391
|
+
* Selector columns are validated as bounded plaintext strings. Targets are
|
|
392
|
+
* OR-combined and each target's selectors are AND-combined. `purgeId` is
|
|
393
|
+
* persisted with the canonical plan, making exact retries no-ops while a
|
|
394
|
+
* reused id with different selectors fails closed.
|
|
395
|
+
*/
|
|
396
|
+
purgeLocalData(input: LocalDataPurgeInput): LocalDataPurgeResult;
|
|
385
397
|
/** Host-facing patch result with explicit network work intent (§7.5). */
|
|
386
398
|
patchCommand(table: string, rowId: string, partial: Readonly<Record<string, unknown>>, options?: {
|
|
387
399
|
readonly baseVersion?: number;
|
package/dist/client.js
CHANGED
|
@@ -14,6 +14,7 @@ import { registerDevtools } from './devtools.js';
|
|
|
14
14
|
import { ClientSyncError } from './errors.js';
|
|
15
15
|
import { ChangeAccumulator, ChangeEmitter, InvalidationEmitter, invalidationFromChange, } from './invalidation.js';
|
|
16
16
|
import { singleOwnerLock, } from './leader-lock.js';
|
|
17
|
+
import { compileLocalDataPurge, localDataPurgeMetaKey, localDataPurgeTargetMatches, } from './local-purge.js';
|
|
17
18
|
import { appendOutboxCommit, deleteOutboxCommit, dropOutboxCommitsInScope, encodeOutboxCommit, listOutbox, listOutboxBeforeImages, OutboxEncodeError, replaceOutboxBeforeImages, } from './outbox.js';
|
|
18
19
|
import { activeFailureRecords, listCommitOutcomes, persistCommitOutcomeResolution, pruneCommitOutcomes, commitOutcome as readCommitOutcome, recordCommitOutcome, } from './outcomes.js';
|
|
19
20
|
import { assertReadOnlyQuery } from './query-guard.js';
|
|
@@ -1066,6 +1067,145 @@ export class SyncClient {
|
|
|
1066
1067
|
},
|
|
1067
1068
|
], [[...normalizedPartial.keys()].sort()]);
|
|
1068
1069
|
}
|
|
1070
|
+
/**
|
|
1071
|
+
* Apply one host-authorized local security purge. The host must stop/gate
|
|
1072
|
+
* protected subscriptions before calling this method; this operation owns
|
|
1073
|
+
* local SQLite cleanup only and intentionally cannot revoke server access.
|
|
1074
|
+
*
|
|
1075
|
+
* Selector columns are validated as bounded plaintext strings. Targets are
|
|
1076
|
+
* OR-combined and each target's selectors are AND-combined. `purgeId` is
|
|
1077
|
+
* persisted with the canonical plan, making exact retries no-ops while a
|
|
1078
|
+
* reused id with different selectors fails closed.
|
|
1079
|
+
*/
|
|
1080
|
+
purgeLocalData(input) {
|
|
1081
|
+
this.#requireStarted();
|
|
1082
|
+
const purge = compileLocalDataPurge(this.#schema, input);
|
|
1083
|
+
const metaKey = localDataPurgeMetaKey(purge.purgeId);
|
|
1084
|
+
const appliedPlan = getMeta(this.#db, metaKey);
|
|
1085
|
+
if (appliedPlan !== undefined) {
|
|
1086
|
+
if (appliedPlan !== purge.canonicalPlan) {
|
|
1087
|
+
throw new ClientSyncError('sync.invalid_request', `local purge id ${JSON.stringify(purge.purgeId)} was already used with a different plan`);
|
|
1088
|
+
}
|
|
1089
|
+
return { alreadyApplied: true, purgedRows: 0, droppedCommits: 0 };
|
|
1090
|
+
}
|
|
1091
|
+
const rejectionCount = this.#rejections.length;
|
|
1092
|
+
try {
|
|
1093
|
+
return this.#applyBatch((batch) => {
|
|
1094
|
+
const initialRowIds = this.#localPurgeRowIds(purge);
|
|
1095
|
+
const targetsByTable = this.#localPurgeTargetsByTable(purge);
|
|
1096
|
+
const doomed = listOutbox(this.#db)
|
|
1097
|
+
.filter((commit) => {
|
|
1098
|
+
const images = new Map(listOutboxBeforeImages(this.#db, commit.clientCommitId).map((image) => [image.opIndex, image]));
|
|
1099
|
+
return commit.operations.some((operation, opIndex) => {
|
|
1100
|
+
const targets = targetsByTable.get(operation.table);
|
|
1101
|
+
if (targets === undefined)
|
|
1102
|
+
return false;
|
|
1103
|
+
if (initialRowIds.get(operation.table)?.has(operation.rowId) ===
|
|
1104
|
+
true) {
|
|
1105
|
+
return true;
|
|
1106
|
+
}
|
|
1107
|
+
if (operation.values !== undefined &&
|
|
1108
|
+
targets.some((target) => localDataPurgeTargetMatches(target, operation.values ?? {}))) {
|
|
1109
|
+
return true;
|
|
1110
|
+
}
|
|
1111
|
+
const beforeValues = images.get(opIndex)?.values;
|
|
1112
|
+
return (beforeValues !== undefined &&
|
|
1113
|
+
targets.some((target) => localDataPurgeTargetMatches(target, beforeValues)));
|
|
1114
|
+
});
|
|
1115
|
+
})
|
|
1116
|
+
// Reverse order is essential: each rollback restores its before-image;
|
|
1117
|
+
// removing newest-first prevents an older doomed write reappearing.
|
|
1118
|
+
.sort((a, b) => b.seq - a.seq);
|
|
1119
|
+
for (const commit of doomed) {
|
|
1120
|
+
this.#rollbackFailedCommit(commit, batch);
|
|
1121
|
+
}
|
|
1122
|
+
// Rollback may reveal a target row hidden by an optimistic delete or
|
|
1123
|
+
// move, so select the final base/visible set only after doomed commits
|
|
1124
|
+
// have been removed.
|
|
1125
|
+
const rowIds = this.#localPurgeRowIds(purge);
|
|
1126
|
+
let purgedRows = 0;
|
|
1127
|
+
for (const [tableName, ids] of rowIds) {
|
|
1128
|
+
if (ids.size === 0)
|
|
1129
|
+
continue;
|
|
1130
|
+
const table = this.#table(tableName);
|
|
1131
|
+
const values = [...ids];
|
|
1132
|
+
purgedRows += values.length;
|
|
1133
|
+
batch.table(tableName);
|
|
1134
|
+
for (let offset = 0; offset < values.length; offset += 400) {
|
|
1135
|
+
const chunk = values.slice(offset, offset + 400);
|
|
1136
|
+
this.#db.exec(`DELETE FROM ${quoteIdent(tableName)} WHERE ${quoteIdent(table.primaryKey)} IN (${chunk.map(() => '?').join(', ')})`, chunk);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
if (doomed.length > 0) {
|
|
1140
|
+
for (const commit of doomed) {
|
|
1141
|
+
const results = commit.operations.map((operation, opIndex) => {
|
|
1142
|
+
const rejection = {
|
|
1143
|
+
clientCommitId: commit.clientCommitId,
|
|
1144
|
+
opIndex,
|
|
1145
|
+
code: 'client.local_data_purged',
|
|
1146
|
+
message: 'the commit was dropped by an application-authorized local data purge',
|
|
1147
|
+
retryable: false,
|
|
1148
|
+
operation,
|
|
1149
|
+
};
|
|
1150
|
+
this.#rejections.push(rejection);
|
|
1151
|
+
return { status: 'error', rejection };
|
|
1152
|
+
});
|
|
1153
|
+
recordCommitOutcome(this.#db, {
|
|
1154
|
+
clientCommitId: commit.clientCommitId,
|
|
1155
|
+
status: 'rejected',
|
|
1156
|
+
recordedAtMs: this.#now(),
|
|
1157
|
+
results,
|
|
1158
|
+
operations: commit.operations,
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
1162
|
+
batch.status();
|
|
1163
|
+
batch.rejections();
|
|
1164
|
+
batch.outcomes();
|
|
1165
|
+
}
|
|
1166
|
+
this.#reconcileBlobs(true);
|
|
1167
|
+
setMeta(this.#db, metaKey, purge.canonicalPlan);
|
|
1168
|
+
return {
|
|
1169
|
+
alreadyApplied: false,
|
|
1170
|
+
purgedRows,
|
|
1171
|
+
droppedCommits: doomed.length,
|
|
1172
|
+
};
|
|
1173
|
+
});
|
|
1174
|
+
}
|
|
1175
|
+
catch (error) {
|
|
1176
|
+
// SQLite rolls back through #applyBatch; mirror that rollback for the
|
|
1177
|
+
// in-memory rejection cache before surfacing the storage failure.
|
|
1178
|
+
this.#rejections.length = rejectionCount;
|
|
1179
|
+
throw error;
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
#localPurgeTargetsByTable(purge) {
|
|
1183
|
+
const byTable = new Map();
|
|
1184
|
+
for (const target of purge.targets) {
|
|
1185
|
+
const targets = byTable.get(target.table.name) ?? [];
|
|
1186
|
+
targets.push(target);
|
|
1187
|
+
byTable.set(target.table.name, targets);
|
|
1188
|
+
}
|
|
1189
|
+
return byTable;
|
|
1190
|
+
}
|
|
1191
|
+
#localPurgeRowIds(purge) {
|
|
1192
|
+
const byTable = new Map();
|
|
1193
|
+
for (const target of purge.targets) {
|
|
1194
|
+
const ids = byTable.get(target.table.name) ?? new Set();
|
|
1195
|
+
const clauses = [];
|
|
1196
|
+
const params = [];
|
|
1197
|
+
for (const selector of target.selectors) {
|
|
1198
|
+
clauses.push(`${quoteIdent(selector.column)} IN (${selector.values.map(() => '?').join(', ')})`);
|
|
1199
|
+
params.push(...selector.values);
|
|
1200
|
+
}
|
|
1201
|
+
for (const row of this.#db.query(`SELECT CAST(${quoteIdent(target.table.primaryKey)} AS TEXT) AS id FROM ${quoteIdent(target.table.name)} WHERE ${clauses.join(' AND ')}`, params)) {
|
|
1202
|
+
if (typeof row.id === 'string')
|
|
1203
|
+
ids.add(row.id);
|
|
1204
|
+
}
|
|
1205
|
+
byTable.set(target.table.name, ids);
|
|
1206
|
+
}
|
|
1207
|
+
return byTable;
|
|
1208
|
+
}
|
|
1069
1209
|
/** Host-facing patch result with explicit network work intent (§7.5). */
|
|
1070
1210
|
patchCommand(table, rowId, partial, options) {
|
|
1071
1211
|
return {
|
package/dist/index.d.ts
CHANGED
|
@@ -19,6 +19,7 @@ export * from './errors.js';
|
|
|
19
19
|
export * from './http.js';
|
|
20
20
|
export * from './invalidation.js';
|
|
21
21
|
export * from './leader-lock.js';
|
|
22
|
+
export * from './local-purge.js';
|
|
22
23
|
export * from './multi-tab.js';
|
|
23
24
|
export * from './naming.js';
|
|
24
25
|
export * from './outbox.js';
|
package/dist/index.js
CHANGED
|
@@ -19,6 +19,7 @@ export * from './errors.js';
|
|
|
19
19
|
export * from './http.js';
|
|
20
20
|
export * from './invalidation.js';
|
|
21
21
|
export * from './leader-lock.js';
|
|
22
|
+
export * from './local-purge.js';
|
|
22
23
|
export * from './multi-tab.js';
|
|
23
24
|
export * from './naming.js';
|
|
24
25
|
export * from './outbox.js';
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Application-authorized local data purge.
|
|
3
|
+
*
|
|
4
|
+
* Syncular deliberately does not decide *why* a device lost access. The host
|
|
5
|
+
* validates the signed/replayed server directive, gates the corresponding
|
|
6
|
+
* subscriptions, and then hands this bounded plaintext-selector plan to the
|
|
7
|
+
* local engine. The engine owns the atomic SQLite consequences: synced rows,
|
|
8
|
+
* generated FTS projections, doomed optimistic commits, and blob references.
|
|
9
|
+
*/
|
|
10
|
+
import type { CompiledClientSchema, CompiledClientTable } from './schema.js';
|
|
11
|
+
/** One AND-combined selector set. Targets are OR-combined. */
|
|
12
|
+
export interface LocalDataPurgeTarget {
|
|
13
|
+
readonly table: string;
|
|
14
|
+
readonly selectors: Readonly<Record<string, readonly string[]>>;
|
|
15
|
+
}
|
|
16
|
+
/** A durable idempotency key plus one or more exact local routing targets. */
|
|
17
|
+
export interface LocalDataPurgeInput {
|
|
18
|
+
readonly purgeId: string;
|
|
19
|
+
readonly targets: readonly LocalDataPurgeTarget[];
|
|
20
|
+
}
|
|
21
|
+
/** Counts only; row ids and selector values never leave the local engine. */
|
|
22
|
+
export interface LocalDataPurgeResult {
|
|
23
|
+
readonly alreadyApplied: boolean;
|
|
24
|
+
readonly purgedRows: number;
|
|
25
|
+
readonly droppedCommits: number;
|
|
26
|
+
}
|
|
27
|
+
export interface CompiledLocalDataPurgeSelector {
|
|
28
|
+
readonly column: string;
|
|
29
|
+
readonly values: readonly string[];
|
|
30
|
+
}
|
|
31
|
+
export interface CompiledLocalDataPurgeTarget {
|
|
32
|
+
readonly table: CompiledClientTable;
|
|
33
|
+
readonly selectors: readonly CompiledLocalDataPurgeSelector[];
|
|
34
|
+
}
|
|
35
|
+
export interface CompiledLocalDataPurge {
|
|
36
|
+
readonly purgeId: string;
|
|
37
|
+
readonly targets: readonly CompiledLocalDataPurgeTarget[];
|
|
38
|
+
/** Stable JSON persisted beside the purge id for collision detection. */
|
|
39
|
+
readonly canonicalPlan: string;
|
|
40
|
+
}
|
|
41
|
+
/** Validate and canonicalize before any transaction is entered. */
|
|
42
|
+
export declare function compileLocalDataPurge(schema: CompiledClientSchema, input: LocalDataPurgeInput): CompiledLocalDataPurge;
|
|
43
|
+
/** Exact AND match for one target; targets themselves are OR-combined. */
|
|
44
|
+
export declare function localDataPurgeTargetMatches(target: CompiledLocalDataPurgeTarget, values: Readonly<Record<string, unknown>>): boolean;
|
|
45
|
+
export declare function localDataPurgeMetaKey(purgeId: string): string;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Application-authorized local data purge.
|
|
3
|
+
*
|
|
4
|
+
* Syncular deliberately does not decide *why* a device lost access. The host
|
|
5
|
+
* validates the signed/replayed server directive, gates the corresponding
|
|
6
|
+
* subscriptions, and then hands this bounded plaintext-selector plan to the
|
|
7
|
+
* local engine. The engine owns the atomic SQLite consequences: synced rows,
|
|
8
|
+
* generated FTS projections, doomed optimistic commits, and blob references.
|
|
9
|
+
*/
|
|
10
|
+
import { ClientSyncError } from './errors.js';
|
|
11
|
+
const MAX_TARGETS = 64;
|
|
12
|
+
const MAX_SELECTORS_PER_TARGET = 8;
|
|
13
|
+
const MAX_VALUES_PER_SELECTOR = 128;
|
|
14
|
+
const MAX_ROUTING_VALUE_LENGTH = 256;
|
|
15
|
+
const CODE_LIKE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
|
|
16
|
+
function compareCodeLike(left, right) {
|
|
17
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
18
|
+
}
|
|
19
|
+
function invalid(message) {
|
|
20
|
+
throw new ClientSyncError('sync.invalid_request', message);
|
|
21
|
+
}
|
|
22
|
+
/** Validate and canonicalize before any transaction is entered. */
|
|
23
|
+
export function compileLocalDataPurge(schema, input) {
|
|
24
|
+
if (input.purgeId.length === 0 ||
|
|
25
|
+
input.purgeId.length > 128 ||
|
|
26
|
+
!CODE_LIKE_VALUE.test(input.purgeId)) {
|
|
27
|
+
invalid('local purge purgeId must be a 1–128 character code-like identifier');
|
|
28
|
+
}
|
|
29
|
+
if (input.targets.length === 0 || input.targets.length > MAX_TARGETS) {
|
|
30
|
+
invalid(`local purge needs between 1 and ${MAX_TARGETS} targets`);
|
|
31
|
+
}
|
|
32
|
+
const deduplicated = new Map();
|
|
33
|
+
for (const target of input.targets) {
|
|
34
|
+
const table = schema.tables.get(target.table);
|
|
35
|
+
if (table === undefined) {
|
|
36
|
+
invalid(`local purge names unknown table ${JSON.stringify(target.table)}`);
|
|
37
|
+
}
|
|
38
|
+
const entries = Object.entries(target.selectors);
|
|
39
|
+
if (entries.length === 0 || entries.length > MAX_SELECTORS_PER_TARGET) {
|
|
40
|
+
invalid(`local purge target ${JSON.stringify(target.table)} needs between 1 and ${MAX_SELECTORS_PER_TARGET} selectors`);
|
|
41
|
+
}
|
|
42
|
+
const selectors = entries
|
|
43
|
+
.map(([columnName, rawValues]) => {
|
|
44
|
+
const column = table.columns.find((candidate) => candidate.name === columnName);
|
|
45
|
+
if (column === undefined) {
|
|
46
|
+
invalid(`local purge target ${JSON.stringify(target.table)} names unknown column ${JSON.stringify(columnName)}`);
|
|
47
|
+
}
|
|
48
|
+
if (column.type !== 'string' || column.encrypted === true) {
|
|
49
|
+
invalid(`local purge selector ${JSON.stringify(target.table)}.${JSON.stringify(columnName)} must be a plaintext string column`);
|
|
50
|
+
}
|
|
51
|
+
if (rawValues.length === 0 ||
|
|
52
|
+
rawValues.length > MAX_VALUES_PER_SELECTOR) {
|
|
53
|
+
invalid(`local purge selector ${JSON.stringify(target.table)}.${JSON.stringify(columnName)} needs between 1 and ${MAX_VALUES_PER_SELECTOR} values`);
|
|
54
|
+
}
|
|
55
|
+
const values = [...new Set(rawValues)];
|
|
56
|
+
for (const value of values) {
|
|
57
|
+
if (typeof value !== 'string' ||
|
|
58
|
+
value.length === 0 ||
|
|
59
|
+
value.length > MAX_ROUTING_VALUE_LENGTH ||
|
|
60
|
+
!CODE_LIKE_VALUE.test(value)) {
|
|
61
|
+
invalid(`local purge selector values must be 1–${MAX_ROUTING_VALUE_LENGTH} character code-like identifiers`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
values.sort();
|
|
65
|
+
return { column: columnName, values };
|
|
66
|
+
})
|
|
67
|
+
.sort((a, b) => compareCodeLike(a.column, b.column));
|
|
68
|
+
const canonicalTarget = {
|
|
69
|
+
table: table.name,
|
|
70
|
+
selectors: Object.fromEntries(selectors.map((selector) => [selector.column, selector.values])),
|
|
71
|
+
};
|
|
72
|
+
deduplicated.set(JSON.stringify(canonicalTarget), { table, selectors });
|
|
73
|
+
}
|
|
74
|
+
const targets = [...deduplicated.entries()]
|
|
75
|
+
.sort(([a], [b]) => compareCodeLike(a, b))
|
|
76
|
+
.map(([, target]) => target);
|
|
77
|
+
const canonicalPlan = JSON.stringify(targets.map((target) => ({
|
|
78
|
+
table: target.table.name,
|
|
79
|
+
selectors: Object.fromEntries(target.selectors.map((selector) => [selector.column, selector.values])),
|
|
80
|
+
})));
|
|
81
|
+
return { purgeId: input.purgeId, targets, canonicalPlan };
|
|
82
|
+
}
|
|
83
|
+
/** Exact AND match for one target; targets themselves are OR-combined. */
|
|
84
|
+
export function localDataPurgeTargetMatches(target, values) {
|
|
85
|
+
return target.selectors.every((selector) => {
|
|
86
|
+
const value = values[selector.column];
|
|
87
|
+
return typeof value === 'string' && selector.values.includes(value);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
export function localDataPurgeMetaKey(purgeId) {
|
|
91
|
+
return `localPurge:${purgeId}`;
|
|
92
|
+
}
|
package/dist/worker-entry.js
CHANGED
|
@@ -276,6 +276,7 @@ export function startSyncWorker(overrides = {}) {
|
|
|
276
276
|
consumeEffects(result.effects);
|
|
277
277
|
return result.value;
|
|
278
278
|
},
|
|
279
|
+
purgeLocalData: (input) => requireClient().purgeLocalData(input),
|
|
279
280
|
sync: () => {
|
|
280
281
|
const running = requireClient();
|
|
281
282
|
return serializedSync(() => running.sync());
|
package/dist/worker-host.d.ts
CHANGED
|
@@ -28,6 +28,7 @@ import type { SqlRow, SqlValue } from './database.js';
|
|
|
28
28
|
import type { EncryptionKeyringConfig } from './encryption.js';
|
|
29
29
|
import { ChangeEmitter, type ClientChangeListener, InvalidationEmitter, type InvalidationListener, type LocalRevision, type SyncStatusSnapshot } from './invalidation.js';
|
|
30
30
|
import { type LeaderLease, type LeaderLock } from './leader-lock.js';
|
|
31
|
+
import type { LocalDataPurgeInput, LocalDataPurgeResult } from './local-purge.js';
|
|
31
32
|
import { type CrossTabChannel, FollowerLink, LeaderBridge } from './multi-tab.js';
|
|
32
33
|
import type { OutboxCommit } from './outbox.js';
|
|
33
34
|
import type { CommitOutcome, CommitOutcomeQuery, ResolveCommitOutcomeInput } from './outcomes.js';
|
|
@@ -145,6 +146,7 @@ export declare class SyncClientHandle {
|
|
|
145
146
|
patch(table: string, rowId: string, partial: Readonly<Record<string, unknown>>, options?: {
|
|
146
147
|
readonly baseVersion?: number;
|
|
147
148
|
}): Promise<string>;
|
|
149
|
+
purgeLocalData(input: LocalDataPurgeInput): Promise<LocalDataPurgeResult>;
|
|
148
150
|
sync(): Promise<SyncSummary>;
|
|
149
151
|
syncUntilIdle(maxRounds?: number): Promise<SyncSummary>;
|
|
150
152
|
query(sql: string, params?: readonly SqlValue[]): Promise<SqlRow[]>;
|
package/dist/worker-host.js
CHANGED
|
@@ -162,6 +162,9 @@ export class SyncClientHandle {
|
|
|
162
162
|
patch(table, rowId, partial, options) {
|
|
163
163
|
return this.#call('patch', [table, rowId, partial, options]);
|
|
164
164
|
}
|
|
165
|
+
purgeLocalData(input) {
|
|
166
|
+
return this.#call('purgeLocalData', [input]);
|
|
167
|
+
}
|
|
165
168
|
sync() {
|
|
166
169
|
return this.#call('sync', []);
|
|
167
170
|
}
|
|
@@ -22,6 +22,7 @@ import type { ConflictRecord, LeaseState, MutationInput, PresencePeer, QueryRead
|
|
|
22
22
|
import type { SqlRow, SqlValue } from './database.js';
|
|
23
23
|
import type { EncryptionKeyringConfig } from './encryption.js';
|
|
24
24
|
import type { ClientChangeBatch, LocalRevision, SyncStatusSnapshot } from './invalidation.js';
|
|
25
|
+
import type { LocalDataPurgeInput, LocalDataPurgeResult } from './local-purge.js';
|
|
25
26
|
import type { OutboxCommit } from './outbox.js';
|
|
26
27
|
import type { CommitOutcome, CommitOutcomeQuery, ResolveCommitOutcomeInput } from './outcomes.js';
|
|
27
28
|
import type { ClientSchema } from './schema.js';
|
|
@@ -89,6 +90,8 @@ export interface WorkerApi {
|
|
|
89
90
|
patch(table: string, rowId: string, partial: Readonly<Record<string, unknown>>, options?: {
|
|
90
91
|
readonly baseVersion?: number;
|
|
91
92
|
}): string;
|
|
93
|
+
/** Application-authorized, idempotent local security purge. */
|
|
94
|
+
purgeLocalData(input: LocalDataPurgeInput): LocalDataPurgeResult;
|
|
92
95
|
sync(): Promise<SyncSummary>;
|
|
93
96
|
syncUntilIdle(maxRounds?: number): Promise<SyncSummary>;
|
|
94
97
|
query(sql: string, params?: readonly SqlValue[]): SqlRow[];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.11",
|
|
4
4
|
"description": "Syncular TypeScript client core — offline-first sync over SQLite (WASM/OPFS, Bun, Node)",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
},
|
|
82
82
|
"dependencies": {
|
|
83
83
|
"@sqlite.org/sqlite-wasm": "^3.53.0-build1",
|
|
84
|
-
"@syncular/core": "0.15.
|
|
84
|
+
"@syncular/core": "0.15.11"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
87
|
"better-sqlite3": ">=11"
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
}
|
|
93
93
|
},
|
|
94
94
|
"devDependencies": {
|
|
95
|
-
"@syncular/server": "0.15.
|
|
95
|
+
"@syncular/server": "0.15.11",
|
|
96
96
|
"@types/better-sqlite3": "^7.6.13",
|
|
97
97
|
"better-sqlite3": "^12.11.1"
|
|
98
98
|
}
|
package/src/client.ts
CHANGED
|
@@ -80,6 +80,15 @@ import {
|
|
|
80
80
|
type LeaderLock,
|
|
81
81
|
singleOwnerLock,
|
|
82
82
|
} from './leader-lock';
|
|
83
|
+
import {
|
|
84
|
+
type CompiledLocalDataPurge,
|
|
85
|
+
type CompiledLocalDataPurgeTarget,
|
|
86
|
+
compileLocalDataPurge,
|
|
87
|
+
type LocalDataPurgeInput,
|
|
88
|
+
type LocalDataPurgeResult,
|
|
89
|
+
localDataPurgeMetaKey,
|
|
90
|
+
localDataPurgeTargetMatches,
|
|
91
|
+
} from './local-purge';
|
|
83
92
|
import {
|
|
84
93
|
appendOutboxCommit,
|
|
85
94
|
deleteOutboxCommit,
|
|
@@ -1642,6 +1651,178 @@ export class SyncClient {
|
|
|
1642
1651
|
);
|
|
1643
1652
|
}
|
|
1644
1653
|
|
|
1654
|
+
/**
|
|
1655
|
+
* Apply one host-authorized local security purge. The host must stop/gate
|
|
1656
|
+
* protected subscriptions before calling this method; this operation owns
|
|
1657
|
+
* local SQLite cleanup only and intentionally cannot revoke server access.
|
|
1658
|
+
*
|
|
1659
|
+
* Selector columns are validated as bounded plaintext strings. Targets are
|
|
1660
|
+
* OR-combined and each target's selectors are AND-combined. `purgeId` is
|
|
1661
|
+
* persisted with the canonical plan, making exact retries no-ops while a
|
|
1662
|
+
* reused id with different selectors fails closed.
|
|
1663
|
+
*/
|
|
1664
|
+
purgeLocalData(input: LocalDataPurgeInput): LocalDataPurgeResult {
|
|
1665
|
+
this.#requireStarted();
|
|
1666
|
+
const purge = compileLocalDataPurge(this.#schema, input);
|
|
1667
|
+
const metaKey = localDataPurgeMetaKey(purge.purgeId);
|
|
1668
|
+
const appliedPlan = getMeta(this.#db, metaKey);
|
|
1669
|
+
if (appliedPlan !== undefined) {
|
|
1670
|
+
if (appliedPlan !== purge.canonicalPlan) {
|
|
1671
|
+
throw new ClientSyncError(
|
|
1672
|
+
'sync.invalid_request',
|
|
1673
|
+
`local purge id ${JSON.stringify(purge.purgeId)} was already used with a different plan`,
|
|
1674
|
+
);
|
|
1675
|
+
}
|
|
1676
|
+
return { alreadyApplied: true, purgedRows: 0, droppedCommits: 0 };
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
const rejectionCount = this.#rejections.length;
|
|
1680
|
+
try {
|
|
1681
|
+
return this.#applyBatch((batch) => {
|
|
1682
|
+
const initialRowIds = this.#localPurgeRowIds(purge);
|
|
1683
|
+
const targetsByTable = this.#localPurgeTargetsByTable(purge);
|
|
1684
|
+
const doomed = listOutbox(this.#db)
|
|
1685
|
+
.filter((commit) => {
|
|
1686
|
+
const images = new Map(
|
|
1687
|
+
listOutboxBeforeImages(this.#db, commit.clientCommitId).map(
|
|
1688
|
+
(image) => [image.opIndex, image],
|
|
1689
|
+
),
|
|
1690
|
+
);
|
|
1691
|
+
return commit.operations.some((operation, opIndex) => {
|
|
1692
|
+
const targets = targetsByTable.get(operation.table);
|
|
1693
|
+
if (targets === undefined) return false;
|
|
1694
|
+
if (
|
|
1695
|
+
initialRowIds.get(operation.table)?.has(operation.rowId) ===
|
|
1696
|
+
true
|
|
1697
|
+
) {
|
|
1698
|
+
return true;
|
|
1699
|
+
}
|
|
1700
|
+
if (
|
|
1701
|
+
operation.values !== undefined &&
|
|
1702
|
+
targets.some((target) =>
|
|
1703
|
+
localDataPurgeTargetMatches(target, operation.values ?? {}),
|
|
1704
|
+
)
|
|
1705
|
+
) {
|
|
1706
|
+
return true;
|
|
1707
|
+
}
|
|
1708
|
+
const beforeValues = images.get(opIndex)?.values;
|
|
1709
|
+
return (
|
|
1710
|
+
beforeValues !== undefined &&
|
|
1711
|
+
targets.some((target) =>
|
|
1712
|
+
localDataPurgeTargetMatches(target, beforeValues),
|
|
1713
|
+
)
|
|
1714
|
+
);
|
|
1715
|
+
});
|
|
1716
|
+
})
|
|
1717
|
+
// Reverse order is essential: each rollback restores its before-image;
|
|
1718
|
+
// removing newest-first prevents an older doomed write reappearing.
|
|
1719
|
+
.sort((a, b) => b.seq - a.seq);
|
|
1720
|
+
|
|
1721
|
+
for (const commit of doomed) {
|
|
1722
|
+
this.#rollbackFailedCommit(commit, batch);
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
// Rollback may reveal a target row hidden by an optimistic delete or
|
|
1726
|
+
// move, so select the final base/visible set only after doomed commits
|
|
1727
|
+
// have been removed.
|
|
1728
|
+
const rowIds = this.#localPurgeRowIds(purge);
|
|
1729
|
+
let purgedRows = 0;
|
|
1730
|
+
for (const [tableName, ids] of rowIds) {
|
|
1731
|
+
if (ids.size === 0) continue;
|
|
1732
|
+
const table = this.#table(tableName);
|
|
1733
|
+
const values = [...ids];
|
|
1734
|
+
purgedRows += values.length;
|
|
1735
|
+
batch.table(tableName);
|
|
1736
|
+
for (let offset = 0; offset < values.length; offset += 400) {
|
|
1737
|
+
const chunk = values.slice(offset, offset + 400);
|
|
1738
|
+
this.#db.exec(
|
|
1739
|
+
`DELETE FROM ${quoteIdent(tableName)} WHERE ${quoteIdent(table.primaryKey)} IN (${chunk.map(() => '?').join(', ')})`,
|
|
1740
|
+
chunk,
|
|
1741
|
+
);
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
if (doomed.length > 0) {
|
|
1746
|
+
for (const commit of doomed) {
|
|
1747
|
+
const results: CommitOperationOutcome[] = commit.operations.map(
|
|
1748
|
+
(operation, opIndex) => {
|
|
1749
|
+
const rejection: RejectionRecord = {
|
|
1750
|
+
clientCommitId: commit.clientCommitId,
|
|
1751
|
+
opIndex,
|
|
1752
|
+
code: 'client.local_data_purged',
|
|
1753
|
+
message:
|
|
1754
|
+
'the commit was dropped by an application-authorized local data purge',
|
|
1755
|
+
retryable: false,
|
|
1756
|
+
operation,
|
|
1757
|
+
};
|
|
1758
|
+
this.#rejections.push(rejection);
|
|
1759
|
+
return { status: 'error', rejection };
|
|
1760
|
+
},
|
|
1761
|
+
);
|
|
1762
|
+
recordCommitOutcome(this.#db, {
|
|
1763
|
+
clientCommitId: commit.clientCommitId,
|
|
1764
|
+
status: 'rejected',
|
|
1765
|
+
recordedAtMs: this.#now(),
|
|
1766
|
+
results,
|
|
1767
|
+
operations: commit.operations,
|
|
1768
|
+
});
|
|
1769
|
+
}
|
|
1770
|
+
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
1771
|
+
batch.status();
|
|
1772
|
+
batch.rejections();
|
|
1773
|
+
batch.outcomes();
|
|
1774
|
+
}
|
|
1775
|
+
this.#reconcileBlobs(true);
|
|
1776
|
+
setMeta(this.#db, metaKey, purge.canonicalPlan);
|
|
1777
|
+
return {
|
|
1778
|
+
alreadyApplied: false,
|
|
1779
|
+
purgedRows,
|
|
1780
|
+
droppedCommits: doomed.length,
|
|
1781
|
+
};
|
|
1782
|
+
});
|
|
1783
|
+
} catch (error) {
|
|
1784
|
+
// SQLite rolls back through #applyBatch; mirror that rollback for the
|
|
1785
|
+
// in-memory rejection cache before surfacing the storage failure.
|
|
1786
|
+
this.#rejections.length = rejectionCount;
|
|
1787
|
+
throw error;
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
#localPurgeTargetsByTable(
|
|
1792
|
+
purge: CompiledLocalDataPurge,
|
|
1793
|
+
): Map<string, CompiledLocalDataPurgeTarget[]> {
|
|
1794
|
+
const byTable = new Map<string, CompiledLocalDataPurgeTarget[]>();
|
|
1795
|
+
for (const target of purge.targets) {
|
|
1796
|
+
const targets = byTable.get(target.table.name) ?? [];
|
|
1797
|
+
targets.push(target);
|
|
1798
|
+
byTable.set(target.table.name, targets);
|
|
1799
|
+
}
|
|
1800
|
+
return byTable;
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
#localPurgeRowIds(purge: CompiledLocalDataPurge): Map<string, Set<string>> {
|
|
1804
|
+
const byTable = new Map<string, Set<string>>();
|
|
1805
|
+
for (const target of purge.targets) {
|
|
1806
|
+
const ids = byTable.get(target.table.name) ?? new Set<string>();
|
|
1807
|
+
const clauses: string[] = [];
|
|
1808
|
+
const params: string[] = [];
|
|
1809
|
+
for (const selector of target.selectors) {
|
|
1810
|
+
clauses.push(
|
|
1811
|
+
`${quoteIdent(selector.column)} IN (${selector.values.map(() => '?').join(', ')})`,
|
|
1812
|
+
);
|
|
1813
|
+
params.push(...selector.values);
|
|
1814
|
+
}
|
|
1815
|
+
for (const row of this.#db.query(
|
|
1816
|
+
`SELECT CAST(${quoteIdent(target.table.primaryKey)} AS TEXT) AS id FROM ${quoteIdent(target.table.name)} WHERE ${clauses.join(' AND ')}`,
|
|
1817
|
+
params,
|
|
1818
|
+
)) {
|
|
1819
|
+
if (typeof row.id === 'string') ids.add(row.id);
|
|
1820
|
+
}
|
|
1821
|
+
byTable.set(target.table.name, ids);
|
|
1822
|
+
}
|
|
1823
|
+
return byTable;
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1645
1826
|
/** Host-facing patch result with explicit network work intent (§7.5). */
|
|
1646
1827
|
patchCommand(
|
|
1647
1828
|
table: string,
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Application-authorized local data purge.
|
|
3
|
+
*
|
|
4
|
+
* Syncular deliberately does not decide *why* a device lost access. The host
|
|
5
|
+
* validates the signed/replayed server directive, gates the corresponding
|
|
6
|
+
* subscriptions, and then hands this bounded plaintext-selector plan to the
|
|
7
|
+
* local engine. The engine owns the atomic SQLite consequences: synced rows,
|
|
8
|
+
* generated FTS projections, doomed optimistic commits, and blob references.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { ClientSyncError } from './errors';
|
|
12
|
+
import type { CompiledClientSchema, CompiledClientTable } from './schema';
|
|
13
|
+
|
|
14
|
+
const MAX_TARGETS = 64;
|
|
15
|
+
const MAX_SELECTORS_PER_TARGET = 8;
|
|
16
|
+
const MAX_VALUES_PER_SELECTOR = 128;
|
|
17
|
+
const MAX_ROUTING_VALUE_LENGTH = 256;
|
|
18
|
+
const CODE_LIKE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
|
|
19
|
+
|
|
20
|
+
function compareCodeLike(left: string, right: string): number {
|
|
21
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** One AND-combined selector set. Targets are OR-combined. */
|
|
25
|
+
export interface LocalDataPurgeTarget {
|
|
26
|
+
readonly table: string;
|
|
27
|
+
readonly selectors: Readonly<Record<string, readonly string[]>>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A durable idempotency key plus one or more exact local routing targets. */
|
|
31
|
+
export interface LocalDataPurgeInput {
|
|
32
|
+
readonly purgeId: string;
|
|
33
|
+
readonly targets: readonly LocalDataPurgeTarget[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Counts only; row ids and selector values never leave the local engine. */
|
|
37
|
+
export interface LocalDataPurgeResult {
|
|
38
|
+
readonly alreadyApplied: boolean;
|
|
39
|
+
readonly purgedRows: number;
|
|
40
|
+
readonly droppedCommits: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface CompiledLocalDataPurgeSelector {
|
|
44
|
+
readonly column: string;
|
|
45
|
+
readonly values: readonly string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface CompiledLocalDataPurgeTarget {
|
|
49
|
+
readonly table: CompiledClientTable;
|
|
50
|
+
readonly selectors: readonly CompiledLocalDataPurgeSelector[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface CompiledLocalDataPurge {
|
|
54
|
+
readonly purgeId: string;
|
|
55
|
+
readonly targets: readonly CompiledLocalDataPurgeTarget[];
|
|
56
|
+
/** Stable JSON persisted beside the purge id for collision detection. */
|
|
57
|
+
readonly canonicalPlan: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function invalid(message: string): never {
|
|
61
|
+
throw new ClientSyncError('sync.invalid_request', message);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Validate and canonicalize before any transaction is entered. */
|
|
65
|
+
export function compileLocalDataPurge(
|
|
66
|
+
schema: CompiledClientSchema,
|
|
67
|
+
input: LocalDataPurgeInput,
|
|
68
|
+
): CompiledLocalDataPurge {
|
|
69
|
+
if (
|
|
70
|
+
input.purgeId.length === 0 ||
|
|
71
|
+
input.purgeId.length > 128 ||
|
|
72
|
+
!CODE_LIKE_VALUE.test(input.purgeId)
|
|
73
|
+
) {
|
|
74
|
+
invalid(
|
|
75
|
+
'local purge purgeId must be a 1–128 character code-like identifier',
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
if (input.targets.length === 0 || input.targets.length > MAX_TARGETS) {
|
|
79
|
+
invalid(`local purge needs between 1 and ${MAX_TARGETS} targets`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const deduplicated = new Map<string, CompiledLocalDataPurgeTarget>();
|
|
83
|
+
for (const target of input.targets) {
|
|
84
|
+
const table = schema.tables.get(target.table);
|
|
85
|
+
if (table === undefined) {
|
|
86
|
+
invalid(
|
|
87
|
+
`local purge names unknown table ${JSON.stringify(target.table)}`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
const entries = Object.entries(target.selectors);
|
|
91
|
+
if (entries.length === 0 || entries.length > MAX_SELECTORS_PER_TARGET) {
|
|
92
|
+
invalid(
|
|
93
|
+
`local purge target ${JSON.stringify(target.table)} needs between 1 and ${MAX_SELECTORS_PER_TARGET} selectors`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
const selectors: CompiledLocalDataPurgeSelector[] = entries
|
|
97
|
+
.map(([columnName, rawValues]) => {
|
|
98
|
+
const column = table.columns.find(
|
|
99
|
+
(candidate) => candidate.name === columnName,
|
|
100
|
+
);
|
|
101
|
+
if (column === undefined) {
|
|
102
|
+
invalid(
|
|
103
|
+
`local purge target ${JSON.stringify(target.table)} names unknown column ${JSON.stringify(columnName)}`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
if (column.type !== 'string' || column.encrypted === true) {
|
|
107
|
+
invalid(
|
|
108
|
+
`local purge selector ${JSON.stringify(target.table)}.${JSON.stringify(columnName)} must be a plaintext string column`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
if (
|
|
112
|
+
rawValues.length === 0 ||
|
|
113
|
+
rawValues.length > MAX_VALUES_PER_SELECTOR
|
|
114
|
+
) {
|
|
115
|
+
invalid(
|
|
116
|
+
`local purge selector ${JSON.stringify(target.table)}.${JSON.stringify(columnName)} needs between 1 and ${MAX_VALUES_PER_SELECTOR} values`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
const values = [...new Set(rawValues)];
|
|
120
|
+
for (const value of values) {
|
|
121
|
+
if (
|
|
122
|
+
typeof value !== 'string' ||
|
|
123
|
+
value.length === 0 ||
|
|
124
|
+
value.length > MAX_ROUTING_VALUE_LENGTH ||
|
|
125
|
+
!CODE_LIKE_VALUE.test(value)
|
|
126
|
+
) {
|
|
127
|
+
invalid(
|
|
128
|
+
`local purge selector values must be 1–${MAX_ROUTING_VALUE_LENGTH} character code-like identifiers`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
values.sort();
|
|
133
|
+
return { column: columnName, values };
|
|
134
|
+
})
|
|
135
|
+
.sort((a, b) => compareCodeLike(a.column, b.column));
|
|
136
|
+
const canonicalTarget = {
|
|
137
|
+
table: table.name,
|
|
138
|
+
selectors: Object.fromEntries(
|
|
139
|
+
selectors.map((selector) => [selector.column, selector.values]),
|
|
140
|
+
),
|
|
141
|
+
};
|
|
142
|
+
deduplicated.set(JSON.stringify(canonicalTarget), { table, selectors });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const targets = [...deduplicated.entries()]
|
|
146
|
+
.sort(([a], [b]) => compareCodeLike(a, b))
|
|
147
|
+
.map(([, target]) => target);
|
|
148
|
+
const canonicalPlan = JSON.stringify(
|
|
149
|
+
targets.map((target) => ({
|
|
150
|
+
table: target.table.name,
|
|
151
|
+
selectors: Object.fromEntries(
|
|
152
|
+
target.selectors.map((selector) => [selector.column, selector.values]),
|
|
153
|
+
),
|
|
154
|
+
})),
|
|
155
|
+
);
|
|
156
|
+
return { purgeId: input.purgeId, targets, canonicalPlan };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Exact AND match for one target; targets themselves are OR-combined. */
|
|
160
|
+
export function localDataPurgeTargetMatches(
|
|
161
|
+
target: CompiledLocalDataPurgeTarget,
|
|
162
|
+
values: Readonly<Record<string, unknown>>,
|
|
163
|
+
): boolean {
|
|
164
|
+
return target.selectors.every((selector) => {
|
|
165
|
+
const value = values[selector.column];
|
|
166
|
+
return typeof value === 'string' && selector.values.includes(value);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function localDataPurgeMetaKey(purgeId: string): string {
|
|
171
|
+
return `localPurge:${purgeId}`;
|
|
172
|
+
}
|
package/src/worker-entry.ts
CHANGED
|
@@ -368,6 +368,7 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
368
368
|
consumeEffects(result.effects);
|
|
369
369
|
return result.value;
|
|
370
370
|
},
|
|
371
|
+
purgeLocalData: (input) => requireClient().purgeLocalData(input),
|
|
371
372
|
sync: () => {
|
|
372
373
|
const running = requireClient();
|
|
373
374
|
return serializedSync(() => running.sync());
|
package/src/worker-host.ts
CHANGED
|
@@ -56,6 +56,7 @@ import {
|
|
|
56
56
|
singleOwnerLock,
|
|
57
57
|
webLocksLeaderLock,
|
|
58
58
|
} from './leader-lock';
|
|
59
|
+
import type { LocalDataPurgeInput, LocalDataPurgeResult } from './local-purge';
|
|
59
60
|
import {
|
|
60
61
|
broadcastChannelFactory,
|
|
61
62
|
type CrossTabChannel,
|
|
@@ -359,6 +360,10 @@ export class SyncClientHandle {
|
|
|
359
360
|
return this.#call('patch', [table, rowId, partial, options]);
|
|
360
361
|
}
|
|
361
362
|
|
|
363
|
+
purgeLocalData(input: LocalDataPurgeInput): Promise<LocalDataPurgeResult> {
|
|
364
|
+
return this.#call('purgeLocalData', [input]);
|
|
365
|
+
}
|
|
366
|
+
|
|
362
367
|
sync(): Promise<SyncSummary> {
|
|
363
368
|
return this.#call('sync', []);
|
|
364
369
|
}
|
package/src/worker-protocol.ts
CHANGED
|
@@ -39,6 +39,7 @@ import type {
|
|
|
39
39
|
LocalRevision,
|
|
40
40
|
SyncStatusSnapshot,
|
|
41
41
|
} from './invalidation';
|
|
42
|
+
import type { LocalDataPurgeInput, LocalDataPurgeResult } from './local-purge';
|
|
42
43
|
import type { OutboxCommit } from './outbox';
|
|
43
44
|
import type {
|
|
44
45
|
CommitOutcome,
|
|
@@ -133,6 +134,8 @@ export interface WorkerApi {
|
|
|
133
134
|
partial: Readonly<Record<string, unknown>>,
|
|
134
135
|
options?: { readonly baseVersion?: number },
|
|
135
136
|
): string;
|
|
137
|
+
/** Application-authorized, idempotent local security purge. */
|
|
138
|
+
purgeLocalData(input: LocalDataPurgeInput): LocalDataPurgeResult;
|
|
136
139
|
sync(): Promise<SyncSummary>;
|
|
137
140
|
syncUntilIdle(maxRounds?: number): Promise<SyncSummary>;
|
|
138
141
|
query(sql: string, params?: readonly SqlValue[]): SqlRow[];
|