@syncular/tauri 0.30.15 → 0.31.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/README.md +12 -5
- package/dist/authority.d.ts +10 -0
- package/dist/authority.js +26 -0
- package/dist/index.d.ts +25 -4
- package/dist/index.js +135 -42
- package/package.json +12 -4
- package/src/authority.ts +51 -0
- package/src/index.ts +200 -37
package/README.md
CHANGED
|
@@ -154,7 +154,8 @@ available. The native database opens and migrates, but query/snapshot, mutation,
|
|
|
154
154
|
subscription, sync, realtime, presence, blob, and automatic retry work fails
|
|
155
155
|
with `client.security_preflight_required`. Status, local revision, lifecycle,
|
|
156
156
|
and `purgeLocalData` remain available. A creation-time `defineAuthorityReads`
|
|
157
|
-
policy
|
|
157
|
+
policy on `createTauriAuthoritySyncClient` enables the read-only
|
|
158
|
+
`authoritySnapshot()` described below.
|
|
158
159
|
|
|
159
160
|
```ts
|
|
160
161
|
const client = await createTauriSyncClient({
|
|
@@ -184,16 +185,17 @@ registration and local progress reads throw that code. `close()` is idempotent.
|
|
|
184
185
|
## Authority evidence before activation
|
|
185
186
|
|
|
186
187
|
Declare authority reads at client creation with `defineAuthorityReads` from
|
|
187
|
-
`@syncular/client`. Each table declares plain columns, including
|
|
188
|
-
key and scope columns, and concrete scope selectors. These selectors remain
|
|
188
|
+
`@syncular/client/authority`. Each table declares plain columns, including
|
|
189
|
+
its primary key and scope columns, and concrete scope selectors. These selectors remain
|
|
189
190
|
fixed for that client. A declaration cannot include encrypted, bytes, blob,
|
|
190
191
|
CRDT or internal columns. Choose only authority fields; clinical fields and
|
|
191
192
|
credentials do not belong in this policy.
|
|
192
193
|
|
|
193
194
|
```ts
|
|
194
|
-
import { defineAuthorityReads } from '@syncular/client';
|
|
195
|
+
import { defineAuthorityReads } from '@syncular/client/authority';
|
|
196
|
+
import { createTauriAuthoritySyncClient } from '@syncular/tauri/authority';
|
|
195
197
|
|
|
196
|
-
const client = await
|
|
198
|
+
const client = await createTauriAuthoritySyncClient({
|
|
197
199
|
schema,
|
|
198
200
|
securityPreflight: true,
|
|
199
201
|
transportEnabled: false,
|
|
@@ -206,6 +208,11 @@ const client = await createTauriSyncClient({
|
|
|
206
208
|
const evidence = await client.authoritySnapshot();
|
|
207
209
|
```
|
|
208
210
|
|
|
211
|
+
Use `createTauriAuthoritySyncClient` for the bridge that exposes
|
|
212
|
+
`authoritySnapshot()`. The ordinary `createTauriSyncClient` class does not
|
|
213
|
+
contain that method or its reply decoder. Upgrade 0.30.15 callers to these
|
|
214
|
+
explicit subpaths; the snapshot and native security checks keep their semantics.
|
|
215
|
+
|
|
209
216
|
The native application must also set an independent ceiling at plugin creation:
|
|
210
217
|
|
|
211
218
|
```rust
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { AuthorityReadPolicy, AuthoritySnapshot } from '@syncular/client/authority';
|
|
2
|
+
import { TauriSyncClient, type TauriSyncClientConfig } from './index.js';
|
|
3
|
+
/** Explicit native bridge with the zero-argument preflight authority read. */
|
|
4
|
+
export declare class TauriAuthoritySyncClient extends TauriSyncClient {
|
|
5
|
+
authoritySnapshot(): Promise<AuthoritySnapshot>;
|
|
6
|
+
}
|
|
7
|
+
/** Construct an authority-enabled bridge; Rust still enforces its independent ceiling. */
|
|
8
|
+
export declare function createTauriAuthoritySyncClient(config: TauriSyncClientConfig & {
|
|
9
|
+
readonly authorityReads: AuthorityReadPolicy;
|
|
10
|
+
}): Promise<TauriAuthoritySyncClient>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createTauriSyncClient, decodeRow, TauriSyncClient, TauriSyncError, } from './index.js';
|
|
2
|
+
/** Explicit native bridge with the zero-argument preflight authority read. */
|
|
3
|
+
export class TauriAuthoritySyncClient extends TauriSyncClient {
|
|
4
|
+
async authoritySnapshot() {
|
|
5
|
+
this.requireOpen();
|
|
6
|
+
if (arguments.length)
|
|
7
|
+
throw new TauriSyncError('client.authority_read_forbidden', 'authoritySnapshot accepts no arguments');
|
|
8
|
+
const result = (await this.command('authoritySnapshot', {}));
|
|
9
|
+
this.requireOpen();
|
|
10
|
+
return {
|
|
11
|
+
...result,
|
|
12
|
+
revision: BigInt(result.revision),
|
|
13
|
+
tables: result.tables.map((table) => ({
|
|
14
|
+
...table,
|
|
15
|
+
rows: table.rows.map((row) => ({
|
|
16
|
+
...row,
|
|
17
|
+
values: decodeRow(row.values),
|
|
18
|
+
})),
|
|
19
|
+
})),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** Construct an authority-enabled bridge; Rust still enforces its independent ceiling. */
|
|
24
|
+
export function createTauriAuthoritySyncClient(config) {
|
|
25
|
+
return createTauriSyncClient(config, (...args) => new TauriAuthoritySyncClient(...args));
|
|
26
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -28,12 +28,20 @@ import type { ClientSnapshotMethods, PromiseMethods } from '@syncular/client';
|
|
|
28
28
|
* `invoke`/`listen` either from its ESM entry points, from the ambient
|
|
29
29
|
* `window.__TAURI__`, or via injected doubles (tests).
|
|
30
30
|
*/
|
|
31
|
-
import type { AuthorityReadPolicy,
|
|
31
|
+
import type { AuthorityReadPolicy, ClientChangeListener, ClientDiagnosticsListener, ClientDiagnosticsRequest, ClientDiagnosticsSnapshot, CommitOutcome, CommitOutcomeQuery, ConflictRecord, EncryptionKeyringConfig, InvalidationListener, LocalDataPurgeInput, LocalDataPurgeResult, LocalDataRebootstrapInput, LocalDataRebootstrapResult, MutationInput, PresencePeer, PreviousVersionAudit, PreviousVersionReadSpec, PreviousVersionSnapshot, QueryReadSpec, QuerySnapshot, RejectionRecord, ResolveCommitOutcomeInput, SecurityLifecycle, SnapshotRead, SnapshotReadRequest, SqlRow, SqlValue, SyncClientConfig, SyncStatusSnapshot, WindowBase, WindowState } from '@syncular/client';
|
|
32
32
|
/** One event pushed on `syncular://event` (the derived client-observable set). */
|
|
33
33
|
interface SyncularEvent {
|
|
34
34
|
readonly type: string;
|
|
35
35
|
readonly [key: string]: unknown;
|
|
36
36
|
}
|
|
37
|
+
/** §7.5: one read-only statement in an atomic sidecar snapshot read. */
|
|
38
|
+
export type { SnapshotStatement } from '@syncular/client';
|
|
39
|
+
/** §7.5: the catch-up state of one subscription from the sidecar. */
|
|
40
|
+
export type { SubscriptionCatchup } from '@syncular/client';
|
|
41
|
+
/** §7.5: delivery status for one client commit id. */
|
|
42
|
+
export type { CommitDelivery } from '@syncular/client';
|
|
43
|
+
/** §7.5: one atomic sidecar snapshot read result with decoded rows. */
|
|
44
|
+
export type SnapshotReadResult = SnapshotRead;
|
|
37
45
|
/** The two Tauri primitives the bridge needs — injectable for tests. */
|
|
38
46
|
export interface TauriApi {
|
|
39
47
|
invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T>;
|
|
@@ -97,6 +105,8 @@ export interface TauriSyncClientConfig {
|
|
|
97
105
|
export type BytesEnvelope = {
|
|
98
106
|
readonly $bytes: string;
|
|
99
107
|
};
|
|
108
|
+
/** @internal Shared native row codec. */
|
|
109
|
+
export declare function decodeRow(row: Record<string, unknown>): SqlRow;
|
|
100
110
|
/**
|
|
101
111
|
* The webview-side proxy implementing `SyncClientLike` over the plugin. Every
|
|
102
112
|
* snapshot method returns a promise from an IPC round trip. React hooks accept
|
|
@@ -106,6 +116,10 @@ export declare class TauriSyncClient implements PromiseMethods<ClientSnapshotMet
|
|
|
106
116
|
#private;
|
|
107
117
|
/** @internal — use {@link createTauriSyncClient}. */
|
|
108
118
|
constructor(tauri: TauriApi, unlisten: () => void, securityLifecycle?: SecurityLifecycle);
|
|
119
|
+
/** @internal Shared command gate for explicit bridge extensions. */
|
|
120
|
+
protected command(method: string, params: Record<string, unknown>): Promise<unknown>;
|
|
121
|
+
/** @internal Recheck local close after an extension's asynchronous reply. */
|
|
122
|
+
protected requireOpen(): void;
|
|
109
123
|
/** @internal — fan an incoming plugin event out to the local listeners. */
|
|
110
124
|
__dispatchEvent(event: SyncularEvent): void;
|
|
111
125
|
securityLifecycle(): Promise<SecurityLifecycle>;
|
|
@@ -128,9 +142,14 @@ export declare class TauriSyncClient implements PromiseMethods<ClientSnapshotMet
|
|
|
128
142
|
onDiagnostics(listener: ClientDiagnosticsListener): () => void;
|
|
129
143
|
onPresence(listener: (scopeKey: string) => void): () => void;
|
|
130
144
|
query(sql: string, params?: readonly SqlValue[]): Promise<SqlRow[]>;
|
|
131
|
-
authoritySnapshot(): Promise<AuthoritySnapshot>;
|
|
132
145
|
querySnapshot<Row = SqlRow>(spec: QueryReadSpec): Promise<QuerySnapshot<Row>>;
|
|
133
146
|
localRevision(): Promise<bigint>;
|
|
147
|
+
/**
|
|
148
|
+
* §7.5: one atomic read over the file-backed sidecar. Every statement and
|
|
149
|
+
* every catch-up/delivery read shares one revision (a `bigint`, matching
|
|
150
|
+
* `querySnapshot`) and one read transaction; every query row is decoded.
|
|
151
|
+
*/
|
|
152
|
+
snapshotRead(request: SnapshotReadRequest): Promise<SnapshotRead>;
|
|
134
153
|
statusSnapshot(): Promise<SyncStatusSnapshot>;
|
|
135
154
|
diagnosticsSnapshot(request?: ClientDiagnosticsRequest): Promise<ClientDiagnosticsSnapshot>;
|
|
136
155
|
mutate(mutations: readonly MutationInput[]): Promise<string>;
|
|
@@ -195,7 +214,8 @@ export declare class TauriSyncClient implements PromiseMethods<ClientSnapshotMet
|
|
|
195
214
|
export declare class TauriSyncError extends Error {
|
|
196
215
|
readonly code: string;
|
|
197
216
|
readonly details: Readonly<Record<string, unknown>> | undefined;
|
|
198
|
-
|
|
217
|
+
readonly retryable: boolean;
|
|
218
|
+
constructor(code: string, message: string, details?: Readonly<Record<string, unknown>>, retryable?: boolean);
|
|
199
219
|
}
|
|
200
220
|
/**
|
|
201
221
|
* Construct the bridge and issue the native `create` (opening/attaching the
|
|
@@ -204,4 +224,5 @@ export declare class TauriSyncError extends Error {
|
|
|
204
224
|
* React `<SyncProvider client={…}>`.
|
|
205
225
|
*/
|
|
206
226
|
export declare function createTauriSyncClient(config: TauriSyncClientConfig): Promise<TauriSyncClient>;
|
|
207
|
-
|
|
227
|
+
/** @internal Explicit bridge extension used by the authority entry. */
|
|
228
|
+
export declare function createTauriSyncClient<Client extends TauriSyncClient>(config: TauriSyncClientConfig, createClient: (...args: ConstructorParameters<typeof TauriSyncClient>) => Client): Promise<Client>;
|
package/dist/index.js
CHANGED
|
@@ -7,9 +7,23 @@ import { decodeLocalDataRebootstrapResult, decodePreviousVersionAuditResult, dec
|
|
|
7
7
|
export const SYNCULAR_EVENT = 'syncular://event';
|
|
8
8
|
const PLUGIN = 'plugin:syncular|';
|
|
9
9
|
function isBytesEnvelope(value) {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
if (typeof value !== 'object' || value === null)
|
|
11
|
+
return false;
|
|
12
|
+
const record = value;
|
|
13
|
+
return (Object.keys(record).length === 1 &&
|
|
14
|
+
typeof record.$bytes === 'string' &&
|
|
15
|
+
/^(?:[0-9a-fA-F]{2})*$/.test(record.$bytes));
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* §7.1: a host reply that carries an envelope-shaped object with a malformed
|
|
19
|
+
* body is refused, never padded into different bytes. Objects without a
|
|
20
|
+
* `$bytes` key keep the defensive JSON text round-trip below.
|
|
21
|
+
*/
|
|
22
|
+
function assertBytesEnvelope(value) {
|
|
23
|
+
if (typeof value !== 'object' || value === null || !('$bytes' in value))
|
|
24
|
+
return;
|
|
25
|
+
if (!isBytesEnvelope(value))
|
|
26
|
+
throw new TauriSyncError('sync.invalid_request', 'a $bytes envelope requires exactly one $bytes key holding an even-length hexadecimal string');
|
|
13
27
|
}
|
|
14
28
|
function isBigIntEnvelope(value) {
|
|
15
29
|
return (typeof value === 'object' &&
|
|
@@ -24,10 +38,11 @@ function bytesToHex(bytes) {
|
|
|
24
38
|
return out;
|
|
25
39
|
}
|
|
26
40
|
function hexToBytes(hex) {
|
|
27
|
-
|
|
28
|
-
|
|
41
|
+
if (!/^(?:[0-9a-fA-F]{2})*$/.test(hex))
|
|
42
|
+
throw new TauriSyncError('sync.invalid_request', 'a $bytes envelope requires an even-length hexadecimal string');
|
|
43
|
+
const out = new Uint8Array(hex.length / 2);
|
|
29
44
|
for (let i = 0; i < out.length; i++) {
|
|
30
|
-
out[i] = Number.parseInt(
|
|
45
|
+
out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
31
46
|
}
|
|
32
47
|
return out;
|
|
33
48
|
}
|
|
@@ -68,6 +83,7 @@ function encodeEncryption(config) {
|
|
|
68
83
|
}
|
|
69
84
|
/** Decode one query-result cell back to an `SqlValue` (`{$bytes}` → bytes). */
|
|
70
85
|
function decodeCell(value) {
|
|
86
|
+
assertBytesEnvelope(value);
|
|
71
87
|
if (isBytesEnvelope(value))
|
|
72
88
|
return hexToBytes(value.$bytes);
|
|
73
89
|
if (isBigIntEnvelope(value))
|
|
@@ -82,7 +98,65 @@ function decodeCell(value) {
|
|
|
82
98
|
// string (SQLite json columns arrive as text already; this is defensive).
|
|
83
99
|
return JSON.stringify(value);
|
|
84
100
|
}
|
|
85
|
-
|
|
101
|
+
/** Decode one native outcome row image. Only the `{$bytes}` envelope is
|
|
102
|
+
* honored; operation `values` stay `JsonRowValue` and are not passed here. */
|
|
103
|
+
function decodeOutcomeRow(row) {
|
|
104
|
+
const out = {};
|
|
105
|
+
for (const [key, value] of Object.entries(row)) {
|
|
106
|
+
// Reserved `_sync_*` columns stay engine-internal, matching `decodeRow`.
|
|
107
|
+
if (key.startsWith('_sync_'))
|
|
108
|
+
continue;
|
|
109
|
+
assertBytesEnvelope(value);
|
|
110
|
+
if (value === null ||
|
|
111
|
+
typeof value === 'string' ||
|
|
112
|
+
typeof value === 'number' ||
|
|
113
|
+
typeof value === 'boolean') {
|
|
114
|
+
out[key] = value;
|
|
115
|
+
}
|
|
116
|
+
else if (isBytesEnvelope(value)) {
|
|
117
|
+
out[key] = hexToBytes(value.$bytes);
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
out[key] = JSON.stringify(value);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
/** Decode the row images a native `CommitOutcome` carries: conflict server
|
|
126
|
+
* rows and retained-row images. */
|
|
127
|
+
function decodeCommitOutcome(outcome) {
|
|
128
|
+
return {
|
|
129
|
+
...outcome,
|
|
130
|
+
results: outcome.results.map((result) => result.status === 'conflict'
|
|
131
|
+
? {
|
|
132
|
+
...result,
|
|
133
|
+
conflict: {
|
|
134
|
+
...result.conflict,
|
|
135
|
+
serverRow: decodeOutcomeRow(result.conflict.serverRow),
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
: result),
|
|
139
|
+
...(outcome.retainedRows !== undefined
|
|
140
|
+
? {
|
|
141
|
+
retainedRows: outcome.retainedRows.map((row) => ({
|
|
142
|
+
...row,
|
|
143
|
+
localRow: row.localRow === null ? null : decodeOutcomeRow(row.localRow),
|
|
144
|
+
serverRow: row.serverRow === null ? null : decodeOutcomeRow(row.serverRow),
|
|
145
|
+
...(row.uniqueConflicts !== undefined
|
|
146
|
+
? {
|
|
147
|
+
uniqueConflicts: row.uniqueConflicts.map((conflict) => ({
|
|
148
|
+
...conflict,
|
|
149
|
+
serverRow: decodeOutcomeRow(conflict.serverRow),
|
|
150
|
+
})),
|
|
151
|
+
}
|
|
152
|
+
: {}),
|
|
153
|
+
})),
|
|
154
|
+
}
|
|
155
|
+
: {}),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
/** @internal Shared native row codec. */
|
|
159
|
+
export function decodeRow(row) {
|
|
86
160
|
const out = {};
|
|
87
161
|
for (const [key, value] of Object.entries(row)) {
|
|
88
162
|
// Reserved `_sync_*` columns stay engine-internal (parity with the
|
|
@@ -178,7 +252,7 @@ export class TauriSyncClient {
|
|
|
178
252
|
}
|
|
179
253
|
const reply = await this.#tauri.invoke(`${PLUGIN}syncular_command`, { command: { method, params } });
|
|
180
254
|
if (reply.error !== undefined) {
|
|
181
|
-
throw new TauriSyncError(reply.error.code, reply.error.message, reply.error.details ?? undefined);
|
|
255
|
+
throw new TauriSyncError(reply.error.code, reply.error.message, reply.error.details ?? undefined, reply.error.retryable ?? false);
|
|
182
256
|
}
|
|
183
257
|
return reply.result;
|
|
184
258
|
}
|
|
@@ -190,6 +264,14 @@ export class TauriSyncClient {
|
|
|
190
264
|
throw new TauriSyncError('client.closed', 'the Tauri sync client is closed');
|
|
191
265
|
}
|
|
192
266
|
}
|
|
267
|
+
/** @internal Shared command gate for explicit bridge extensions. */
|
|
268
|
+
command(method, params) {
|
|
269
|
+
return this.#command(method, params);
|
|
270
|
+
}
|
|
271
|
+
/** @internal Recheck local close after an extension's asynchronous reply. */
|
|
272
|
+
requireOpen() {
|
|
273
|
+
this.#requireOpen();
|
|
274
|
+
}
|
|
193
275
|
#requireActive() {
|
|
194
276
|
this.#requireOpen();
|
|
195
277
|
if (this.#securityLifecycle === 'preflight')
|
|
@@ -360,29 +442,11 @@ export class TauriSyncClient {
|
|
|
360
442
|
this.#requireActive();
|
|
361
443
|
const reply = await this.#tauri.invoke(`${PLUGIN}syncular_query`, { sql, params: (params ?? []).map(encodeParam) });
|
|
362
444
|
if (reply.error !== undefined) {
|
|
363
|
-
throw new TauriSyncError(reply.error.code, reply.error.message, reply.error.details ?? undefined);
|
|
445
|
+
throw new TauriSyncError(reply.error.code, reply.error.message, reply.error.details ?? undefined, reply.error.retryable ?? false);
|
|
364
446
|
}
|
|
365
447
|
const rows = reply.result.rows ?? [];
|
|
366
448
|
return rows.map((r) => decodeRow(r));
|
|
367
449
|
}
|
|
368
|
-
async authoritySnapshot() {
|
|
369
|
-
this.#requireOpen();
|
|
370
|
-
if (arguments.length)
|
|
371
|
-
throw new TauriSyncError('client.authority_read_forbidden', 'authoritySnapshot accepts no arguments');
|
|
372
|
-
const result = (await this.#command('authoritySnapshot', {}));
|
|
373
|
-
this.#requireOpen();
|
|
374
|
-
return {
|
|
375
|
-
...result,
|
|
376
|
-
revision: BigInt(result.revision),
|
|
377
|
-
tables: result.tables.map((table) => ({
|
|
378
|
-
...table,
|
|
379
|
-
rows: table.rows.map((row) => ({
|
|
380
|
-
...row,
|
|
381
|
-
values: decodeRow(row.values),
|
|
382
|
-
})),
|
|
383
|
-
})),
|
|
384
|
-
};
|
|
385
|
-
}
|
|
386
450
|
async querySnapshot(spec) {
|
|
387
451
|
this.#requireActive();
|
|
388
452
|
const reply = await this.#tauri.invoke(`${PLUGIN}syncular_query_snapshot`, {
|
|
@@ -392,7 +456,7 @@ export class TauriSyncClient {
|
|
|
392
456
|
...(spec.owner !== undefined ? { owner: spec.owner } : {}),
|
|
393
457
|
});
|
|
394
458
|
if (reply.error !== undefined) {
|
|
395
|
-
throw new TauriSyncError(reply.error.code, reply.error.message, reply.error.details ?? undefined);
|
|
459
|
+
throw new TauriSyncError(reply.error.code, reply.error.message, reply.error.details ?? undefined, reply.error.retryable ?? false);
|
|
396
460
|
}
|
|
397
461
|
const result = reply.result;
|
|
398
462
|
return {
|
|
@@ -405,6 +469,37 @@ export class TauriSyncClient {
|
|
|
405
469
|
const result = (await this.#command('localRevision', {}));
|
|
406
470
|
return BigInt(result.revision);
|
|
407
471
|
}
|
|
472
|
+
/**
|
|
473
|
+
* §7.5: one atomic read over the file-backed sidecar. Every statement and
|
|
474
|
+
* every catch-up/delivery read shares one revision (a `bigint`, matching
|
|
475
|
+
* `querySnapshot`) and one read transaction; every query row is decoded.
|
|
476
|
+
*/
|
|
477
|
+
async snapshotRead(request) {
|
|
478
|
+
this.#requireActive();
|
|
479
|
+
const reply = await this.#tauri.invoke(`${PLUGIN}syncular_snapshot_read`, {
|
|
480
|
+
statements: request.statements.map((statement) => ({
|
|
481
|
+
sql: statement.sql,
|
|
482
|
+
params: (statement.params ?? []).map(encodeParam),
|
|
483
|
+
})),
|
|
484
|
+
coverage: request.coverage ?? [],
|
|
485
|
+
subscriptions: request.subscriptions ?? [],
|
|
486
|
+
commitIds: request.commitIds ?? [],
|
|
487
|
+
...(request.owner !== undefined ? { owner: request.owner } : {}),
|
|
488
|
+
});
|
|
489
|
+
if (reply.error !== undefined) {
|
|
490
|
+
throw new TauriSyncError(reply.error.code, reply.error.message, reply.error.details ?? undefined, reply.error.retryable ?? false);
|
|
491
|
+
}
|
|
492
|
+
const result = reply.result;
|
|
493
|
+
return {
|
|
494
|
+
revision: BigInt(result.revision),
|
|
495
|
+
queries: result.queries.map((rows) => rows.map(decodeRow)),
|
|
496
|
+
coverage: result.coverage,
|
|
497
|
+
subscriptions: result.subscriptions,
|
|
498
|
+
deliveries: result.deliveries.map((delivery) => delivery.status === 'known'
|
|
499
|
+
? { ...delivery, outcome: decodeCommitOutcome(delivery.outcome) }
|
|
500
|
+
: delivery),
|
|
501
|
+
};
|
|
502
|
+
}
|
|
408
503
|
async statusSnapshot() {
|
|
409
504
|
return (await this.#command('statusSnapshot', {}));
|
|
410
505
|
}
|
|
@@ -470,7 +565,7 @@ export class TauriSyncClient {
|
|
|
470
565
|
this.#requireActive();
|
|
471
566
|
const reply = await this.#tauri.invoke(`${PLUGIN}syncular_set_headers`, { headers });
|
|
472
567
|
if (reply.error !== undefined) {
|
|
473
|
-
throw new TauriSyncError(reply.error.code, reply.error.message, reply.error.details ?? undefined);
|
|
568
|
+
throw new TauriSyncError(reply.error.code, reply.error.message, reply.error.details ?? undefined, reply.error.retryable ?? false);
|
|
474
569
|
}
|
|
475
570
|
}
|
|
476
571
|
// -- Native CRDT (SPEC.md §5.10.5; needs the plugin `crdt-yjs` feature) ------
|
|
@@ -564,17 +659,19 @@ export class TauriSyncClient {
|
|
|
564
659
|
const result = (await this.#command('commitOutcome', {
|
|
565
660
|
clientCommitId,
|
|
566
661
|
}));
|
|
567
|
-
return result.outcome
|
|
662
|
+
return result.outcome === undefined
|
|
663
|
+
? undefined
|
|
664
|
+
: decodeCommitOutcome(result.outcome);
|
|
568
665
|
}
|
|
569
666
|
async commitOutcomes(query = {}) {
|
|
570
667
|
const result = (await this.#command('commitOutcomes', { query }));
|
|
571
|
-
return result.outcomes;
|
|
668
|
+
return result.outcomes.map(decodeCommitOutcome);
|
|
572
669
|
}
|
|
573
670
|
async resolveCommitOutcome(input) {
|
|
574
671
|
const result = (await this.#command('resolveCommitOutcome', {
|
|
575
672
|
input,
|
|
576
673
|
}));
|
|
577
|
-
return result.outcome;
|
|
674
|
+
return decodeCommitOutcome(result.outcome);
|
|
578
675
|
}
|
|
579
676
|
async pendingCommits() {
|
|
580
677
|
const result = (await this.#command('pendingCommitIds', {}));
|
|
@@ -629,11 +726,13 @@ function decodeDiagnosticsSnapshot(value, kind) {
|
|
|
629
726
|
export class TauriSyncError extends Error {
|
|
630
727
|
code;
|
|
631
728
|
details;
|
|
632
|
-
|
|
729
|
+
retryable;
|
|
730
|
+
constructor(code, message, details, retryable = false) {
|
|
633
731
|
super(message);
|
|
634
732
|
this.name = 'TauriSyncError';
|
|
635
733
|
this.code = code;
|
|
636
734
|
this.details = details;
|
|
735
|
+
this.retryable = retryable;
|
|
637
736
|
}
|
|
638
737
|
}
|
|
639
738
|
function toStringSet(value) {
|
|
@@ -720,13 +819,7 @@ function encodeMutation(mutation) {
|
|
|
720
819
|
}
|
|
721
820
|
return { ...mutation, values };
|
|
722
821
|
}
|
|
723
|
-
|
|
724
|
-
* Construct the bridge and issue the native `create` (opening/attaching the
|
|
725
|
-
* file DB on the Rust side per the plugin config). Returns a ready
|
|
726
|
-
* `TauriSyncClient` that satisfies `SyncClientLike` — pass it straight to the
|
|
727
|
-
* React `<SyncProvider client={…}>`.
|
|
728
|
-
*/
|
|
729
|
-
export async function createTauriSyncClient(config) {
|
|
822
|
+
export async function createTauriSyncClient(config, createClient = (...args) => new TauriSyncClient(...args)) {
|
|
730
823
|
const tauri = await resolveTauri(config.tauri);
|
|
731
824
|
// Wire the event stream BEFORE create, so no early change batch is missed.
|
|
732
825
|
const clientRef = {
|
|
@@ -739,7 +832,7 @@ export async function createTauriSyncClient(config) {
|
|
|
739
832
|
unlisten();
|
|
740
833
|
throw new TauriSyncError('sync.invalid_request', 'securityPreflight and encryption are mutually exclusive; install keys with activateSecurity after preflight');
|
|
741
834
|
}
|
|
742
|
-
const client =
|
|
835
|
+
const client = createClient(tauri, unlisten, config.securityPreflight === true ? 'preflight' : 'active');
|
|
743
836
|
clientRef.client = client;
|
|
744
837
|
// The native side owns every path (plugin config); the JS side names a
|
|
745
838
|
// database and supplies the schema, clientId, and limits.
|
|
@@ -777,7 +870,7 @@ export async function createTauriSyncClient(config) {
|
|
|
777
870
|
});
|
|
778
871
|
if (reply.error !== undefined) {
|
|
779
872
|
unlisten();
|
|
780
|
-
throw new TauriSyncError(reply.error.code, reply.error.message, reply.error.details ?? undefined);
|
|
873
|
+
throw new TauriSyncError(reply.error.code, reply.error.message, reply.error.details ?? undefined, reply.error.retryable ?? false);
|
|
781
874
|
}
|
|
782
875
|
return client;
|
|
783
876
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/tauri",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.0",
|
|
4
4
|
"description": "Tauri integration for the Syncular client",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -33,6 +33,14 @@
|
|
|
33
33
|
"types": "./dist/index.d.ts",
|
|
34
34
|
"default": "./dist/index.js"
|
|
35
35
|
}
|
|
36
|
+
},
|
|
37
|
+
"./authority": {
|
|
38
|
+
"bun": "./src/authority.ts",
|
|
39
|
+
"browser": "./dist/authority.js",
|
|
40
|
+
"import": {
|
|
41
|
+
"types": "./dist/authority.d.ts",
|
|
42
|
+
"default": "./dist/authority.js"
|
|
43
|
+
}
|
|
36
44
|
}
|
|
37
45
|
},
|
|
38
46
|
"files": [
|
|
@@ -48,13 +56,13 @@
|
|
|
48
56
|
"test": "bun test"
|
|
49
57
|
},
|
|
50
58
|
"dependencies": {
|
|
51
|
-
"@syncular/client": "0.
|
|
59
|
+
"@syncular/client": "0.31.0"
|
|
52
60
|
},
|
|
53
61
|
"peerDependencies": {
|
|
54
62
|
"@tauri-apps/api": ">=2.0.0"
|
|
55
63
|
},
|
|
56
64
|
"devDependencies": {
|
|
57
|
-
"@syncular/react": "0.
|
|
58
|
-
"@syncular/server": "0.
|
|
65
|
+
"@syncular/react": "0.31.0",
|
|
66
|
+
"@syncular/server": "0.31.0"
|
|
59
67
|
}
|
|
60
68
|
}
|
package/src/authority.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AuthorityReadPolicy,
|
|
3
|
+
AuthoritySnapshot,
|
|
4
|
+
} from '@syncular/client/authority';
|
|
5
|
+
import {
|
|
6
|
+
createTauriSyncClient,
|
|
7
|
+
decodeRow,
|
|
8
|
+
TauriSyncClient,
|
|
9
|
+
TauriSyncError,
|
|
10
|
+
type TauriSyncClientConfig,
|
|
11
|
+
} from './index';
|
|
12
|
+
|
|
13
|
+
/** Explicit native bridge with the zero-argument preflight authority read. */
|
|
14
|
+
export class TauriAuthoritySyncClient extends TauriSyncClient {
|
|
15
|
+
async authoritySnapshot(): Promise<AuthoritySnapshot> {
|
|
16
|
+
this.requireOpen();
|
|
17
|
+
if (arguments.length)
|
|
18
|
+
throw new TauriSyncError(
|
|
19
|
+
'client.authority_read_forbidden',
|
|
20
|
+
'authoritySnapshot accepts no arguments',
|
|
21
|
+
);
|
|
22
|
+
const result = (await this.command('authoritySnapshot', {})) as Omit<
|
|
23
|
+
AuthoritySnapshot,
|
|
24
|
+
'revision'
|
|
25
|
+
> & { revision: string };
|
|
26
|
+
this.requireOpen();
|
|
27
|
+
return {
|
|
28
|
+
...result,
|
|
29
|
+
revision: BigInt(result.revision),
|
|
30
|
+
tables: result.tables.map((table) => ({
|
|
31
|
+
...table,
|
|
32
|
+
rows: table.rows.map((row) => ({
|
|
33
|
+
...row,
|
|
34
|
+
values: decodeRow(row.values),
|
|
35
|
+
})),
|
|
36
|
+
})),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Construct an authority-enabled bridge; Rust still enforces its independent ceiling. */
|
|
42
|
+
export function createTauriAuthoritySyncClient(
|
|
43
|
+
config: TauriSyncClientConfig & {
|
|
44
|
+
readonly authorityReads: AuthorityReadPolicy;
|
|
45
|
+
},
|
|
46
|
+
): Promise<TauriAuthoritySyncClient> {
|
|
47
|
+
return createTauriSyncClient(
|
|
48
|
+
config,
|
|
49
|
+
(...args) => new TauriAuthoritySyncClient(...args),
|
|
50
|
+
);
|
|
51
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -35,7 +35,6 @@ import type { ClientSnapshotMethods, PromiseMethods } from '@syncular/client';
|
|
|
35
35
|
|
|
36
36
|
import type {
|
|
37
37
|
AuthorityReadPolicy,
|
|
38
|
-
AuthoritySnapshot,
|
|
39
38
|
ClientChangeBatch,
|
|
40
39
|
ClientChangeListener,
|
|
41
40
|
ClientDiagnosticsListener,
|
|
@@ -61,6 +60,8 @@ import type {
|
|
|
61
60
|
RejectionRecord,
|
|
62
61
|
ResolveCommitOutcomeInput,
|
|
63
62
|
SecurityLifecycle,
|
|
63
|
+
SnapshotRead,
|
|
64
|
+
SnapshotReadRequest,
|
|
64
65
|
SqlRow,
|
|
65
66
|
SqlValue,
|
|
66
67
|
SyncClientConfig,
|
|
@@ -87,6 +88,7 @@ interface CommandReply {
|
|
|
87
88
|
readonly code: string;
|
|
88
89
|
readonly message: string;
|
|
89
90
|
readonly details?: Readonly<Record<string, unknown>> | null;
|
|
91
|
+
readonly retryable?: boolean;
|
|
90
92
|
};
|
|
91
93
|
}
|
|
92
94
|
|
|
@@ -96,6 +98,18 @@ interface SyncularEvent {
|
|
|
96
98
|
readonly [key: string]: unknown;
|
|
97
99
|
}
|
|
98
100
|
|
|
101
|
+
/** §7.5: one read-only statement in an atomic sidecar snapshot read. */
|
|
102
|
+
export type { SnapshotStatement } from '@syncular/client';
|
|
103
|
+
|
|
104
|
+
/** §7.5: the catch-up state of one subscription from the sidecar. */
|
|
105
|
+
export type { SubscriptionCatchup } from '@syncular/client';
|
|
106
|
+
|
|
107
|
+
/** §7.5: delivery status for one client commit id. */
|
|
108
|
+
export type { CommitDelivery } from '@syncular/client';
|
|
109
|
+
|
|
110
|
+
/** §7.5: one atomic sidecar snapshot read result with decoded rows. */
|
|
111
|
+
export type SnapshotReadResult = SnapshotRead;
|
|
112
|
+
|
|
99
113
|
/** The two Tauri primitives the bridge needs — injectable for tests. */
|
|
100
114
|
export interface TauriApi {
|
|
101
115
|
invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T>;
|
|
@@ -166,13 +180,30 @@ export type BytesEnvelope = { readonly $bytes: string };
|
|
|
166
180
|
type BigIntEnvelope = { readonly $bigint: string };
|
|
167
181
|
|
|
168
182
|
function isBytesEnvelope(value: unknown): value is BytesEnvelope {
|
|
183
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
184
|
+
const record = value as Record<string, unknown>;
|
|
169
185
|
return (
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
186
|
+
Object.keys(record).length === 1 &&
|
|
187
|
+
typeof record.$bytes === 'string' &&
|
|
188
|
+
/^(?:[0-9a-fA-F]{2})*$/.test(record.$bytes)
|
|
173
189
|
);
|
|
174
190
|
}
|
|
175
191
|
|
|
192
|
+
/**
|
|
193
|
+
* §7.1: a host reply that carries an envelope-shaped object with a malformed
|
|
194
|
+
* body is refused, never padded into different bytes. Objects without a
|
|
195
|
+
* `$bytes` key keep the defensive JSON text round-trip below.
|
|
196
|
+
*/
|
|
197
|
+
function assertBytesEnvelope(value: unknown): void {
|
|
198
|
+
if (typeof value !== 'object' || value === null || !('$bytes' in value))
|
|
199
|
+
return;
|
|
200
|
+
if (!isBytesEnvelope(value))
|
|
201
|
+
throw new TauriSyncError(
|
|
202
|
+
'sync.invalid_request',
|
|
203
|
+
'a $bytes envelope requires exactly one $bytes key holding an even-length hexadecimal string',
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
176
207
|
function isBigIntEnvelope(value: unknown): value is BigIntEnvelope {
|
|
177
208
|
return (
|
|
178
209
|
typeof value === 'object' &&
|
|
@@ -190,10 +221,14 @@ function bytesToHex(bytes: Uint8Array): string {
|
|
|
190
221
|
}
|
|
191
222
|
|
|
192
223
|
function hexToBytes(hex: string): Uint8Array {
|
|
193
|
-
|
|
194
|
-
|
|
224
|
+
if (!/^(?:[0-9a-fA-F]{2})*$/.test(hex))
|
|
225
|
+
throw new TauriSyncError(
|
|
226
|
+
'sync.invalid_request',
|
|
227
|
+
'a $bytes envelope requires an even-length hexadecimal string',
|
|
228
|
+
);
|
|
229
|
+
const out = new Uint8Array(hex.length / 2);
|
|
195
230
|
for (let i = 0; i < out.length; i++) {
|
|
196
|
-
out[i] = Number.parseInt(
|
|
231
|
+
out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
197
232
|
}
|
|
198
233
|
return out;
|
|
199
234
|
}
|
|
@@ -235,6 +270,7 @@ function encodeEncryption(config: EncryptionKeyringConfig): unknown {
|
|
|
235
270
|
|
|
236
271
|
/** Decode one query-result cell back to an `SqlValue` (`{$bytes}` → bytes). */
|
|
237
272
|
function decodeCell(value: unknown): SqlValue {
|
|
273
|
+
assertBytesEnvelope(value);
|
|
238
274
|
if (isBytesEnvelope(value)) return hexToBytes(value.$bytes);
|
|
239
275
|
if (isBigIntEnvelope(value)) return BigInt(value.$bigint);
|
|
240
276
|
if (
|
|
@@ -250,7 +286,76 @@ function decodeCell(value: unknown): SqlValue {
|
|
|
250
286
|
return JSON.stringify(value);
|
|
251
287
|
}
|
|
252
288
|
|
|
253
|
-
|
|
289
|
+
/** A decoded outcome row cell. Structurally the shared `RowValue` (bytes are
|
|
290
|
+
* `Uint8Array`); native outcome rows emit plain JSON numbers, never `$bigint`. */
|
|
291
|
+
type OutcomeRowValue = ConflictRecord['serverRow'][string];
|
|
292
|
+
|
|
293
|
+
/** Decode one native outcome row image. Only the `{$bytes}` envelope is
|
|
294
|
+
* honored; operation `values` stay `JsonRowValue` and are not passed here. */
|
|
295
|
+
function decodeOutcomeRow(
|
|
296
|
+
row: Readonly<Record<string, unknown>>,
|
|
297
|
+
): Record<string, OutcomeRowValue> {
|
|
298
|
+
const out: Record<string, OutcomeRowValue> = {};
|
|
299
|
+
for (const [key, value] of Object.entries(row)) {
|
|
300
|
+
// Reserved `_sync_*` columns stay engine-internal, matching `decodeRow`.
|
|
301
|
+
if (key.startsWith('_sync_')) continue;
|
|
302
|
+
assertBytesEnvelope(value);
|
|
303
|
+
if (
|
|
304
|
+
value === null ||
|
|
305
|
+
typeof value === 'string' ||
|
|
306
|
+
typeof value === 'number' ||
|
|
307
|
+
typeof value === 'boolean'
|
|
308
|
+
) {
|
|
309
|
+
out[key] = value;
|
|
310
|
+
} else if (isBytesEnvelope(value)) {
|
|
311
|
+
out[key] = hexToBytes(value.$bytes);
|
|
312
|
+
} else {
|
|
313
|
+
out[key] = JSON.stringify(value);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return out;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Decode the row images a native `CommitOutcome` carries: conflict server
|
|
320
|
+
* rows and retained-row images. */
|
|
321
|
+
function decodeCommitOutcome(outcome: CommitOutcome): CommitOutcome {
|
|
322
|
+
return {
|
|
323
|
+
...outcome,
|
|
324
|
+
results: outcome.results.map((result) =>
|
|
325
|
+
result.status === 'conflict'
|
|
326
|
+
? {
|
|
327
|
+
...result,
|
|
328
|
+
conflict: {
|
|
329
|
+
...result.conflict,
|
|
330
|
+
serverRow: decodeOutcomeRow(result.conflict.serverRow),
|
|
331
|
+
},
|
|
332
|
+
}
|
|
333
|
+
: result,
|
|
334
|
+
),
|
|
335
|
+
...(outcome.retainedRows !== undefined
|
|
336
|
+
? {
|
|
337
|
+
retainedRows: outcome.retainedRows.map((row) => ({
|
|
338
|
+
...row,
|
|
339
|
+
localRow:
|
|
340
|
+
row.localRow === null ? null : decodeOutcomeRow(row.localRow),
|
|
341
|
+
serverRow:
|
|
342
|
+
row.serverRow === null ? null : decodeOutcomeRow(row.serverRow),
|
|
343
|
+
...(row.uniqueConflicts !== undefined
|
|
344
|
+
? {
|
|
345
|
+
uniqueConflicts: row.uniqueConflicts.map((conflict) => ({
|
|
346
|
+
...conflict,
|
|
347
|
+
serverRow: decodeOutcomeRow(conflict.serverRow),
|
|
348
|
+
})),
|
|
349
|
+
}
|
|
350
|
+
: {}),
|
|
351
|
+
})),
|
|
352
|
+
}
|
|
353
|
+
: {}),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** @internal Shared native row codec. */
|
|
358
|
+
export function decodeRow(row: Record<string, unknown>): SqlRow {
|
|
254
359
|
const out: SqlRow = {};
|
|
255
360
|
for (const [key, value] of Object.entries(row)) {
|
|
256
361
|
// Reserved `_sync_*` columns stay engine-internal (parity with the
|
|
@@ -373,6 +478,7 @@ export class TauriSyncClient implements PromiseMethods<ClientSnapshotMethods> {
|
|
|
373
478
|
reply.error.code,
|
|
374
479
|
reply.error.message,
|
|
375
480
|
reply.error.details ?? undefined,
|
|
481
|
+
reply.error.retryable ?? false,
|
|
376
482
|
);
|
|
377
483
|
}
|
|
378
484
|
return reply.result;
|
|
@@ -394,6 +500,19 @@ export class TauriSyncClient implements PromiseMethods<ClientSnapshotMethods> {
|
|
|
394
500
|
}
|
|
395
501
|
}
|
|
396
502
|
|
|
503
|
+
/** @internal Shared command gate for explicit bridge extensions. */
|
|
504
|
+
protected command(
|
|
505
|
+
method: string,
|
|
506
|
+
params: Record<string, unknown>,
|
|
507
|
+
): Promise<unknown> {
|
|
508
|
+
return this.#command(method, params);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** @internal Recheck local close after an extension's asynchronous reply. */
|
|
512
|
+
protected requireOpen(): void {
|
|
513
|
+
this.#requireOpen();
|
|
514
|
+
}
|
|
515
|
+
|
|
397
516
|
#requireActive(): void {
|
|
398
517
|
this.#requireOpen();
|
|
399
518
|
if (this.#securityLifecycle === 'preflight') this.#throwSecurityPreflight();
|
|
@@ -587,37 +706,13 @@ export class TauriSyncClient implements PromiseMethods<ClientSnapshotMethods> {
|
|
|
587
706
|
reply.error.code,
|
|
588
707
|
reply.error.message,
|
|
589
708
|
reply.error.details ?? undefined,
|
|
709
|
+
reply.error.retryable ?? false,
|
|
590
710
|
);
|
|
591
711
|
}
|
|
592
712
|
const rows = (reply.result as { rows?: unknown[] }).rows ?? [];
|
|
593
713
|
return rows.map((r) => decodeRow(r as Record<string, unknown>));
|
|
594
714
|
}
|
|
595
715
|
|
|
596
|
-
async authoritySnapshot(): Promise<AuthoritySnapshot> {
|
|
597
|
-
this.#requireOpen();
|
|
598
|
-
if (arguments.length)
|
|
599
|
-
throw new TauriSyncError(
|
|
600
|
-
'client.authority_read_forbidden',
|
|
601
|
-
'authoritySnapshot accepts no arguments',
|
|
602
|
-
);
|
|
603
|
-
const result = (await this.#command('authoritySnapshot', {})) as Omit<
|
|
604
|
-
AuthoritySnapshot,
|
|
605
|
-
'revision'
|
|
606
|
-
> & { revision: string };
|
|
607
|
-
this.#requireOpen();
|
|
608
|
-
return {
|
|
609
|
-
...result,
|
|
610
|
-
revision: BigInt(result.revision),
|
|
611
|
-
tables: result.tables.map((table) => ({
|
|
612
|
-
...table,
|
|
613
|
-
rows: table.rows.map((row) => ({
|
|
614
|
-
...row,
|
|
615
|
-
values: decodeRow(row.values),
|
|
616
|
-
})),
|
|
617
|
-
})),
|
|
618
|
-
};
|
|
619
|
-
}
|
|
620
|
-
|
|
621
716
|
async querySnapshot<Row = SqlRow>(
|
|
622
717
|
spec: QueryReadSpec,
|
|
623
718
|
): Promise<QuerySnapshot<Row>> {
|
|
@@ -636,6 +731,7 @@ export class TauriSyncClient implements PromiseMethods<ClientSnapshotMethods> {
|
|
|
636
731
|
reply.error.code,
|
|
637
732
|
reply.error.message,
|
|
638
733
|
reply.error.details ?? undefined,
|
|
734
|
+
reply.error.retryable ?? false,
|
|
639
735
|
);
|
|
640
736
|
}
|
|
641
737
|
const result = reply.result as {
|
|
@@ -657,6 +753,54 @@ export class TauriSyncClient implements PromiseMethods<ClientSnapshotMethods> {
|
|
|
657
753
|
return BigInt(result.revision);
|
|
658
754
|
}
|
|
659
755
|
|
|
756
|
+
/**
|
|
757
|
+
* §7.5: one atomic read over the file-backed sidecar. Every statement and
|
|
758
|
+
* every catch-up/delivery read shares one revision (a `bigint`, matching
|
|
759
|
+
* `querySnapshot`) and one read transaction; every query row is decoded.
|
|
760
|
+
*/
|
|
761
|
+
async snapshotRead(request: SnapshotReadRequest): Promise<SnapshotRead> {
|
|
762
|
+
this.#requireActive();
|
|
763
|
+
const reply = await this.#tauri.invoke<CommandReply>(
|
|
764
|
+
`${PLUGIN}syncular_snapshot_read`,
|
|
765
|
+
{
|
|
766
|
+
statements: request.statements.map((statement) => ({
|
|
767
|
+
sql: statement.sql,
|
|
768
|
+
params: (statement.params ?? []).map(encodeParam),
|
|
769
|
+
})),
|
|
770
|
+
coverage: request.coverage ?? [],
|
|
771
|
+
subscriptions: request.subscriptions ?? [],
|
|
772
|
+
commitIds: request.commitIds ?? [],
|
|
773
|
+
...(request.owner !== undefined ? { owner: request.owner } : {}),
|
|
774
|
+
},
|
|
775
|
+
);
|
|
776
|
+
if (reply.error !== undefined) {
|
|
777
|
+
throw new TauriSyncError(
|
|
778
|
+
reply.error.code,
|
|
779
|
+
reply.error.message,
|
|
780
|
+
reply.error.details ?? undefined,
|
|
781
|
+
reply.error.retryable ?? false,
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
const result = reply.result as {
|
|
785
|
+
revision: string;
|
|
786
|
+
queries: readonly (readonly Record<string, unknown>[])[];
|
|
787
|
+
coverage: SnapshotRead['coverage'];
|
|
788
|
+
subscriptions: SnapshotRead['subscriptions'];
|
|
789
|
+
deliveries: SnapshotRead['deliveries'];
|
|
790
|
+
};
|
|
791
|
+
return {
|
|
792
|
+
revision: BigInt(result.revision),
|
|
793
|
+
queries: result.queries.map((rows) => rows.map(decodeRow)),
|
|
794
|
+
coverage: result.coverage,
|
|
795
|
+
subscriptions: result.subscriptions,
|
|
796
|
+
deliveries: result.deliveries.map((delivery) =>
|
|
797
|
+
delivery.status === 'known'
|
|
798
|
+
? { ...delivery, outcome: decodeCommitOutcome(delivery.outcome) }
|
|
799
|
+
: delivery,
|
|
800
|
+
),
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
|
|
660
804
|
async statusSnapshot(): Promise<SyncStatusSnapshot> {
|
|
661
805
|
return (await this.#command('statusSnapshot', {})) as SyncStatusSnapshot;
|
|
662
806
|
}
|
|
@@ -766,6 +910,7 @@ export class TauriSyncClient implements PromiseMethods<ClientSnapshotMethods> {
|
|
|
766
910
|
reply.error.code,
|
|
767
911
|
reply.error.message,
|
|
768
912
|
reply.error.details ?? undefined,
|
|
913
|
+
reply.error.retryable ?? false,
|
|
769
914
|
);
|
|
770
915
|
}
|
|
771
916
|
}
|
|
@@ -909,7 +1054,9 @@ export class TauriSyncClient implements PromiseMethods<ClientSnapshotMethods> {
|
|
|
909
1054
|
const result = (await this.#command('commitOutcome', {
|
|
910
1055
|
clientCommitId,
|
|
911
1056
|
})) as { outcome?: CommitOutcome };
|
|
912
|
-
return result.outcome
|
|
1057
|
+
return result.outcome === undefined
|
|
1058
|
+
? undefined
|
|
1059
|
+
: decodeCommitOutcome(result.outcome);
|
|
913
1060
|
}
|
|
914
1061
|
|
|
915
1062
|
async commitOutcomes(
|
|
@@ -918,7 +1065,7 @@ export class TauriSyncClient implements PromiseMethods<ClientSnapshotMethods> {
|
|
|
918
1065
|
const result = (await this.#command('commitOutcomes', { query })) as {
|
|
919
1066
|
outcomes: CommitOutcome[];
|
|
920
1067
|
};
|
|
921
|
-
return result.outcomes;
|
|
1068
|
+
return result.outcomes.map(decodeCommitOutcome);
|
|
922
1069
|
}
|
|
923
1070
|
|
|
924
1071
|
async resolveCommitOutcome(
|
|
@@ -927,7 +1074,7 @@ export class TauriSyncClient implements PromiseMethods<ClientSnapshotMethods> {
|
|
|
927
1074
|
const result = (await this.#command('resolveCommitOutcome', {
|
|
928
1075
|
input,
|
|
929
1076
|
})) as { outcome: CommitOutcome };
|
|
930
|
-
return result.outcome;
|
|
1077
|
+
return decodeCommitOutcome(result.outcome);
|
|
931
1078
|
}
|
|
932
1079
|
|
|
933
1080
|
async pendingCommits(): Promise<unknown[]> {
|
|
@@ -996,15 +1143,18 @@ function decodeDiagnosticsSnapshot(
|
|
|
996
1143
|
export class TauriSyncError extends Error {
|
|
997
1144
|
readonly code: string;
|
|
998
1145
|
readonly details: Readonly<Record<string, unknown>> | undefined;
|
|
1146
|
+
readonly retryable: boolean;
|
|
999
1147
|
constructor(
|
|
1000
1148
|
code: string,
|
|
1001
1149
|
message: string,
|
|
1002
1150
|
details?: Readonly<Record<string, unknown>>,
|
|
1151
|
+
retryable = false,
|
|
1003
1152
|
) {
|
|
1004
1153
|
super(message);
|
|
1005
1154
|
this.name = 'TauriSyncError';
|
|
1006
1155
|
this.code = code;
|
|
1007
1156
|
this.details = details;
|
|
1157
|
+
this.retryable = retryable;
|
|
1008
1158
|
}
|
|
1009
1159
|
}
|
|
1010
1160
|
|
|
@@ -1097,8 +1247,20 @@ function encodeMutation(mutation: MutationInput): unknown {
|
|
|
1097
1247
|
* `TauriSyncClient` that satisfies `SyncClientLike` — pass it straight to the
|
|
1098
1248
|
* React `<SyncProvider client={…}>`.
|
|
1099
1249
|
*/
|
|
1250
|
+
export function createTauriSyncClient(
|
|
1251
|
+
config: TauriSyncClientConfig,
|
|
1252
|
+
): Promise<TauriSyncClient>;
|
|
1253
|
+
/** @internal Explicit bridge extension used by the authority entry. */
|
|
1254
|
+
export function createTauriSyncClient<Client extends TauriSyncClient>(
|
|
1255
|
+
config: TauriSyncClientConfig,
|
|
1256
|
+
createClient: (
|
|
1257
|
+
...args: ConstructorParameters<typeof TauriSyncClient>
|
|
1258
|
+
) => Client,
|
|
1259
|
+
): Promise<Client>;
|
|
1100
1260
|
export async function createTauriSyncClient(
|
|
1101
1261
|
config: TauriSyncClientConfig,
|
|
1262
|
+
createClient = (...args: ConstructorParameters<typeof TauriSyncClient>) =>
|
|
1263
|
+
new TauriSyncClient(...args),
|
|
1102
1264
|
): Promise<TauriSyncClient> {
|
|
1103
1265
|
const tauri = await resolveTauri(config.tauri);
|
|
1104
1266
|
|
|
@@ -1121,7 +1283,7 @@ export async function createTauriSyncClient(
|
|
|
1121
1283
|
);
|
|
1122
1284
|
}
|
|
1123
1285
|
|
|
1124
|
-
const client =
|
|
1286
|
+
const client = createClient(
|
|
1125
1287
|
tauri,
|
|
1126
1288
|
unlisten,
|
|
1127
1289
|
config.securityPreflight === true ? 'preflight' : 'active',
|
|
@@ -1168,6 +1330,7 @@ export async function createTauriSyncClient(
|
|
|
1168
1330
|
reply.error.code,
|
|
1169
1331
|
reply.error.message,
|
|
1170
1332
|
reply.error.details ?? undefined,
|
|
1333
|
+
reply.error.retryable ?? false,
|
|
1171
1334
|
);
|
|
1172
1335
|
}
|
|
1173
1336
|
|