@syncular/tauri 0.30.16 → 0.32.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 +2 -2
- package/dist/index.d.ts +17 -3
- package/dist/index.js +123 -15
- package/package.json +4 -4
- package/src/index.ts +172 -9
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ bun add @syncular/tauri @tauri-apps/api
|
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
The bridge works with any frontend framework. Generate the schema module using
|
|
12
|
-
the [existing-project setup](https://syncular.dev/
|
|
12
|
+
the [existing-project setup](https://syncular.dev/add-to-existing-app/),
|
|
13
13
|
then create one shared client in the webview:
|
|
14
14
|
|
|
15
15
|
```ts
|
|
@@ -307,7 +307,7 @@ The snapshot API revision removes the individual `schemaFloor`, `leaseState`,
|
|
|
307
307
|
`upgrading`, and `syncNeeded` methods. Read those fields from
|
|
308
308
|
`await client.statusSnapshot()`. Collection and outcome reads remain methods.
|
|
309
309
|
React accepts the bridge directly. See the
|
|
310
|
-
[client migration](https://syncular.dev/platform-web/#
|
|
310
|
+
[client migration](https://syncular.dev/platform-web-reads-writes/#reading-local-state).
|
|
311
311
|
|
|
312
312
|
|
|
313
313
|
## Atomic sparse writes and retained conflicts
|
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, ClientChangeListener, ClientDiagnosticsListener, ClientDiagnosticsRequest, ClientDiagnosticsSnapshot, CommitOutcome, CommitOutcomeQuery, ConflictRecord, EncryptionKeyringConfig, InvalidationListener, LocalDataPurgeInput, LocalDataPurgeResult, LocalDataRebootstrapInput, LocalDataRebootstrapResult, MutationInput, PresencePeer, PreviousVersionAudit, PreviousVersionReadSpec, PreviousVersionSnapshot, QueryReadSpec, QuerySnapshot, RejectionRecord, ResolveCommitOutcomeInput, SecurityLifecycle, SqlRow, SqlValue, SyncClientConfig, SyncStatusSnapshot, WindowBase, WindowState } from '@syncular/client';
|
|
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>;
|
|
@@ -136,6 +144,12 @@ export declare class TauriSyncClient implements PromiseMethods<ClientSnapshotMet
|
|
|
136
144
|
query(sql: string, params?: readonly SqlValue[]): Promise<SqlRow[]>;
|
|
137
145
|
querySnapshot<Row = SqlRow>(spec: QueryReadSpec): Promise<QuerySnapshot<Row>>;
|
|
138
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>;
|
|
139
153
|
statusSnapshot(): Promise<SyncStatusSnapshot>;
|
|
140
154
|
diagnosticsSnapshot(request?: ClientDiagnosticsRequest): Promise<ClientDiagnosticsSnapshot>;
|
|
141
155
|
mutate(mutations: readonly MutationInput[]): Promise<string>;
|
|
@@ -200,7 +214,8 @@ export declare class TauriSyncClient implements PromiseMethods<ClientSnapshotMet
|
|
|
200
214
|
export declare class TauriSyncError extends Error {
|
|
201
215
|
readonly code: string;
|
|
202
216
|
readonly details: Readonly<Record<string, unknown>> | undefined;
|
|
203
|
-
|
|
217
|
+
readonly retryable: boolean;
|
|
218
|
+
constructor(code: string, message: string, details?: Readonly<Record<string, unknown>>, retryable?: boolean);
|
|
204
219
|
}
|
|
205
220
|
/**
|
|
206
221
|
* Construct the bridge and issue the native `create` (opening/attaching the
|
|
@@ -211,4 +226,3 @@ export declare class TauriSyncError extends Error {
|
|
|
211
226
|
export declare function createTauriSyncClient(config: TauriSyncClientConfig): Promise<TauriSyncClient>;
|
|
212
227
|
/** @internal Explicit bridge extension used by the authority entry. */
|
|
213
228
|
export declare function createTauriSyncClient<Client extends TauriSyncClient>(config: TauriSyncClientConfig, createClient: (...args: ConstructorParameters<typeof TauriSyncClient>) => Client): Promise<Client>;
|
|
214
|
-
export {};
|
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,6 +98,63 @@ function decodeCell(value) {
|
|
|
82
98
|
// string (SQLite json columns arrive as text already; this is defensive).
|
|
83
99
|
return JSON.stringify(value);
|
|
84
100
|
}
|
|
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
|
+
}
|
|
85
158
|
/** @internal Shared native row codec. */
|
|
86
159
|
export function decodeRow(row) {
|
|
87
160
|
const out = {};
|
|
@@ -179,7 +252,7 @@ export class TauriSyncClient {
|
|
|
179
252
|
}
|
|
180
253
|
const reply = await this.#tauri.invoke(`${PLUGIN}syncular_command`, { command: { method, params } });
|
|
181
254
|
if (reply.error !== undefined) {
|
|
182
|
-
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);
|
|
183
256
|
}
|
|
184
257
|
return reply.result;
|
|
185
258
|
}
|
|
@@ -369,7 +442,7 @@ export class TauriSyncClient {
|
|
|
369
442
|
this.#requireActive();
|
|
370
443
|
const reply = await this.#tauri.invoke(`${PLUGIN}syncular_query`, { sql, params: (params ?? []).map(encodeParam) });
|
|
371
444
|
if (reply.error !== undefined) {
|
|
372
|
-
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);
|
|
373
446
|
}
|
|
374
447
|
const rows = reply.result.rows ?? [];
|
|
375
448
|
return rows.map((r) => decodeRow(r));
|
|
@@ -383,7 +456,7 @@ export class TauriSyncClient {
|
|
|
383
456
|
...(spec.owner !== undefined ? { owner: spec.owner } : {}),
|
|
384
457
|
});
|
|
385
458
|
if (reply.error !== undefined) {
|
|
386
|
-
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);
|
|
387
460
|
}
|
|
388
461
|
const result = reply.result;
|
|
389
462
|
return {
|
|
@@ -396,6 +469,37 @@ export class TauriSyncClient {
|
|
|
396
469
|
const result = (await this.#command('localRevision', {}));
|
|
397
470
|
return BigInt(result.revision);
|
|
398
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
|
+
}
|
|
399
503
|
async statusSnapshot() {
|
|
400
504
|
return (await this.#command('statusSnapshot', {}));
|
|
401
505
|
}
|
|
@@ -461,7 +565,7 @@ export class TauriSyncClient {
|
|
|
461
565
|
this.#requireActive();
|
|
462
566
|
const reply = await this.#tauri.invoke(`${PLUGIN}syncular_set_headers`, { headers });
|
|
463
567
|
if (reply.error !== undefined) {
|
|
464
|
-
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);
|
|
465
569
|
}
|
|
466
570
|
}
|
|
467
571
|
// -- Native CRDT (SPEC.md §5.10.5; needs the plugin `crdt-yjs` feature) ------
|
|
@@ -555,17 +659,19 @@ export class TauriSyncClient {
|
|
|
555
659
|
const result = (await this.#command('commitOutcome', {
|
|
556
660
|
clientCommitId,
|
|
557
661
|
}));
|
|
558
|
-
return result.outcome
|
|
662
|
+
return result.outcome === undefined
|
|
663
|
+
? undefined
|
|
664
|
+
: decodeCommitOutcome(result.outcome);
|
|
559
665
|
}
|
|
560
666
|
async commitOutcomes(query = {}) {
|
|
561
667
|
const result = (await this.#command('commitOutcomes', { query }));
|
|
562
|
-
return result.outcomes;
|
|
668
|
+
return result.outcomes.map(decodeCommitOutcome);
|
|
563
669
|
}
|
|
564
670
|
async resolveCommitOutcome(input) {
|
|
565
671
|
const result = (await this.#command('resolveCommitOutcome', {
|
|
566
672
|
input,
|
|
567
673
|
}));
|
|
568
|
-
return result.outcome;
|
|
674
|
+
return decodeCommitOutcome(result.outcome);
|
|
569
675
|
}
|
|
570
676
|
async pendingCommits() {
|
|
571
677
|
const result = (await this.#command('pendingCommitIds', {}));
|
|
@@ -620,11 +726,13 @@ function decodeDiagnosticsSnapshot(value, kind) {
|
|
|
620
726
|
export class TauriSyncError extends Error {
|
|
621
727
|
code;
|
|
622
728
|
details;
|
|
623
|
-
|
|
729
|
+
retryable;
|
|
730
|
+
constructor(code, message, details, retryable = false) {
|
|
624
731
|
super(message);
|
|
625
732
|
this.name = 'TauriSyncError';
|
|
626
733
|
this.code = code;
|
|
627
734
|
this.details = details;
|
|
735
|
+
this.retryable = retryable;
|
|
628
736
|
}
|
|
629
737
|
}
|
|
630
738
|
function toStringSet(value) {
|
|
@@ -762,7 +870,7 @@ export async function createTauriSyncClient(config, createClient = (...args) =>
|
|
|
762
870
|
});
|
|
763
871
|
if (reply.error !== undefined) {
|
|
764
872
|
unlisten();
|
|
765
|
-
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);
|
|
766
874
|
}
|
|
767
875
|
return client;
|
|
768
876
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/tauri",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.0",
|
|
4
4
|
"description": "Tauri integration for the Syncular client",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -56,13 +56,13 @@
|
|
|
56
56
|
"test": "bun test"
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
|
59
|
-
"@syncular/client": "0.
|
|
59
|
+
"@syncular/client": "0.32.0"
|
|
60
60
|
},
|
|
61
61
|
"peerDependencies": {
|
|
62
62
|
"@tauri-apps/api": ">=2.0.0"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
|
-
"@syncular/react": "0.
|
|
66
|
-
"@syncular/server": "0.
|
|
65
|
+
"@syncular/react": "0.32.0",
|
|
66
|
+
"@syncular/server": "0.32.0"
|
|
67
67
|
}
|
|
68
68
|
}
|
package/src/index.ts
CHANGED
|
@@ -60,6 +60,8 @@ import type {
|
|
|
60
60
|
RejectionRecord,
|
|
61
61
|
ResolveCommitOutcomeInput,
|
|
62
62
|
SecurityLifecycle,
|
|
63
|
+
SnapshotRead,
|
|
64
|
+
SnapshotReadRequest,
|
|
63
65
|
SqlRow,
|
|
64
66
|
SqlValue,
|
|
65
67
|
SyncClientConfig,
|
|
@@ -86,6 +88,7 @@ interface CommandReply {
|
|
|
86
88
|
readonly code: string;
|
|
87
89
|
readonly message: string;
|
|
88
90
|
readonly details?: Readonly<Record<string, unknown>> | null;
|
|
91
|
+
readonly retryable?: boolean;
|
|
89
92
|
};
|
|
90
93
|
}
|
|
91
94
|
|
|
@@ -95,6 +98,18 @@ interface SyncularEvent {
|
|
|
95
98
|
readonly [key: string]: unknown;
|
|
96
99
|
}
|
|
97
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
|
+
|
|
98
113
|
/** The two Tauri primitives the bridge needs — injectable for tests. */
|
|
99
114
|
export interface TauriApi {
|
|
100
115
|
invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T>;
|
|
@@ -165,13 +180,30 @@ export type BytesEnvelope = { readonly $bytes: string };
|
|
|
165
180
|
type BigIntEnvelope = { readonly $bigint: string };
|
|
166
181
|
|
|
167
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>;
|
|
168
185
|
return (
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
186
|
+
Object.keys(record).length === 1 &&
|
|
187
|
+
typeof record.$bytes === 'string' &&
|
|
188
|
+
/^(?:[0-9a-fA-F]{2})*$/.test(record.$bytes)
|
|
172
189
|
);
|
|
173
190
|
}
|
|
174
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
|
+
|
|
175
207
|
function isBigIntEnvelope(value: unknown): value is BigIntEnvelope {
|
|
176
208
|
return (
|
|
177
209
|
typeof value === 'object' &&
|
|
@@ -189,10 +221,14 @@ function bytesToHex(bytes: Uint8Array): string {
|
|
|
189
221
|
}
|
|
190
222
|
|
|
191
223
|
function hexToBytes(hex: string): Uint8Array {
|
|
192
|
-
|
|
193
|
-
|
|
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);
|
|
194
230
|
for (let i = 0; i < out.length; i++) {
|
|
195
|
-
out[i] = Number.parseInt(
|
|
231
|
+
out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
196
232
|
}
|
|
197
233
|
return out;
|
|
198
234
|
}
|
|
@@ -234,6 +270,7 @@ function encodeEncryption(config: EncryptionKeyringConfig): unknown {
|
|
|
234
270
|
|
|
235
271
|
/** Decode one query-result cell back to an `SqlValue` (`{$bytes}` → bytes). */
|
|
236
272
|
function decodeCell(value: unknown): SqlValue {
|
|
273
|
+
assertBytesEnvelope(value);
|
|
237
274
|
if (isBytesEnvelope(value)) return hexToBytes(value.$bytes);
|
|
238
275
|
if (isBigIntEnvelope(value)) return BigInt(value.$bigint);
|
|
239
276
|
if (
|
|
@@ -249,6 +286,74 @@ function decodeCell(value: unknown): SqlValue {
|
|
|
249
286
|
return JSON.stringify(value);
|
|
250
287
|
}
|
|
251
288
|
|
|
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
|
+
|
|
252
357
|
/** @internal Shared native row codec. */
|
|
253
358
|
export function decodeRow(row: Record<string, unknown>): SqlRow {
|
|
254
359
|
const out: SqlRow = {};
|
|
@@ -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;
|
|
@@ -600,6 +706,7 @@ export class TauriSyncClient implements PromiseMethods<ClientSnapshotMethods> {
|
|
|
600
706
|
reply.error.code,
|
|
601
707
|
reply.error.message,
|
|
602
708
|
reply.error.details ?? undefined,
|
|
709
|
+
reply.error.retryable ?? false,
|
|
603
710
|
);
|
|
604
711
|
}
|
|
605
712
|
const rows = (reply.result as { rows?: unknown[] }).rows ?? [];
|
|
@@ -624,6 +731,7 @@ export class TauriSyncClient implements PromiseMethods<ClientSnapshotMethods> {
|
|
|
624
731
|
reply.error.code,
|
|
625
732
|
reply.error.message,
|
|
626
733
|
reply.error.details ?? undefined,
|
|
734
|
+
reply.error.retryable ?? false,
|
|
627
735
|
);
|
|
628
736
|
}
|
|
629
737
|
const result = reply.result as {
|
|
@@ -645,6 +753,54 @@ export class TauriSyncClient implements PromiseMethods<ClientSnapshotMethods> {
|
|
|
645
753
|
return BigInt(result.revision);
|
|
646
754
|
}
|
|
647
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
|
+
|
|
648
804
|
async statusSnapshot(): Promise<SyncStatusSnapshot> {
|
|
649
805
|
return (await this.#command('statusSnapshot', {})) as SyncStatusSnapshot;
|
|
650
806
|
}
|
|
@@ -754,6 +910,7 @@ export class TauriSyncClient implements PromiseMethods<ClientSnapshotMethods> {
|
|
|
754
910
|
reply.error.code,
|
|
755
911
|
reply.error.message,
|
|
756
912
|
reply.error.details ?? undefined,
|
|
913
|
+
reply.error.retryable ?? false,
|
|
757
914
|
);
|
|
758
915
|
}
|
|
759
916
|
}
|
|
@@ -897,7 +1054,9 @@ export class TauriSyncClient implements PromiseMethods<ClientSnapshotMethods> {
|
|
|
897
1054
|
const result = (await this.#command('commitOutcome', {
|
|
898
1055
|
clientCommitId,
|
|
899
1056
|
})) as { outcome?: CommitOutcome };
|
|
900
|
-
return result.outcome
|
|
1057
|
+
return result.outcome === undefined
|
|
1058
|
+
? undefined
|
|
1059
|
+
: decodeCommitOutcome(result.outcome);
|
|
901
1060
|
}
|
|
902
1061
|
|
|
903
1062
|
async commitOutcomes(
|
|
@@ -906,7 +1065,7 @@ export class TauriSyncClient implements PromiseMethods<ClientSnapshotMethods> {
|
|
|
906
1065
|
const result = (await this.#command('commitOutcomes', { query })) as {
|
|
907
1066
|
outcomes: CommitOutcome[];
|
|
908
1067
|
};
|
|
909
|
-
return result.outcomes;
|
|
1068
|
+
return result.outcomes.map(decodeCommitOutcome);
|
|
910
1069
|
}
|
|
911
1070
|
|
|
912
1071
|
async resolveCommitOutcome(
|
|
@@ -915,7 +1074,7 @@ export class TauriSyncClient implements PromiseMethods<ClientSnapshotMethods> {
|
|
|
915
1074
|
const result = (await this.#command('resolveCommitOutcome', {
|
|
916
1075
|
input,
|
|
917
1076
|
})) as { outcome: CommitOutcome };
|
|
918
|
-
return result.outcome;
|
|
1077
|
+
return decodeCommitOutcome(result.outcome);
|
|
919
1078
|
}
|
|
920
1079
|
|
|
921
1080
|
async pendingCommits(): Promise<unknown[]> {
|
|
@@ -984,15 +1143,18 @@ function decodeDiagnosticsSnapshot(
|
|
|
984
1143
|
export class TauriSyncError extends Error {
|
|
985
1144
|
readonly code: string;
|
|
986
1145
|
readonly details: Readonly<Record<string, unknown>> | undefined;
|
|
1146
|
+
readonly retryable: boolean;
|
|
987
1147
|
constructor(
|
|
988
1148
|
code: string,
|
|
989
1149
|
message: string,
|
|
990
1150
|
details?: Readonly<Record<string, unknown>>,
|
|
1151
|
+
retryable = false,
|
|
991
1152
|
) {
|
|
992
1153
|
super(message);
|
|
993
1154
|
this.name = 'TauriSyncError';
|
|
994
1155
|
this.code = code;
|
|
995
1156
|
this.details = details;
|
|
1157
|
+
this.retryable = retryable;
|
|
996
1158
|
}
|
|
997
1159
|
}
|
|
998
1160
|
|
|
@@ -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
|
|