@syncular/client 0.1.3 → 0.2.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 +227 -709
- package/dist/apply.d.ts +80 -0
- package/dist/apply.js +301 -0
- package/dist/blob.d.ts +127 -0
- package/dist/blob.js +201 -0
- package/dist/bun-database.d.ts +22 -0
- package/dist/bun-database.js +61 -0
- package/dist/client.d.ts +320 -69
- package/dist/client.js +1627 -163
- package/dist/content-type.d.ts +2 -0
- package/dist/content-type.js +2 -0
- package/dist/database.d.ts +35 -80
- package/dist/database.js +32 -947
- package/dist/encryption.d.ts +47 -0
- package/dist/encryption.js +75 -0
- package/dist/errors.d.ts +8 -22
- package/dist/errors.js +10 -207
- package/dist/http.d.ts +32 -0
- package/dist/http.js +196 -0
- package/dist/index.d.ts +28 -16
- package/dist/index.js +25 -12
- package/dist/invalidation.d.ts +69 -0
- package/dist/invalidation.js +84 -0
- package/dist/leader-lock.d.ts +28 -0
- package/dist/leader-lock.js +38 -0
- package/dist/multi-tab.d.ts +134 -0
- package/dist/multi-tab.js +399 -0
- package/dist/node-database.d.ts +41 -0
- package/dist/node-database.js +133 -0
- package/dist/outbox.d.ts +56 -0
- package/dist/outbox.js +140 -0
- package/dist/schema.d.ts +107 -0
- package/dist/schema.js +267 -0
- package/dist/state.d.ts +40 -0
- package/dist/state.js +77 -0
- package/dist/transport.d.ts +44 -0
- package/dist/transport.js +7 -0
- package/dist/wasm-database.d.ts +30 -0
- package/dist/wasm-database.js +170 -0
- package/dist/window.d.ts +64 -0
- package/dist/window.js +0 -0
- package/dist/worker-entry.d.ts +16 -2
- package/dist/worker-entry.js +300 -456
- package/dist/worker-host.d.ts +174 -0
- package/dist/worker-host.js +536 -0
- package/dist/worker-protocol.d.ts +173 -61
- package/dist/worker-protocol.js +7 -16
- package/package.json +45 -112
- package/src/apply.ts +435 -0
- package/src/blob.ts +333 -0
- package/src/bun-database.ts +75 -0
- package/src/client.ts +2247 -277
- package/src/content-type.ts +2 -0
- package/src/database.ts +63 -1389
- package/src/encryption.ts +123 -0
- package/src/errors.ts +11 -265
- package/src/http.ts +254 -0
- package/src/index.ts +25 -20
- package/src/invalidation.ts +128 -0
- package/src/leader-lock.ts +68 -0
- package/src/multi-tab.ts +550 -0
- package/src/node-database.ts +180 -0
- package/src/outbox.ts +206 -0
- package/src/schema.ts +397 -0
- package/src/state.ts +126 -0
- package/src/transport.ts +52 -0
- package/src/wasm-database.ts +279 -0
- package/src/window.ts +0 -0
- package/src/worker-entry.ts +391 -545
- package/src/worker-host.ts +800 -0
- package/src/worker-protocol.ts +204 -99
- package/dist/auth-leases.d.ts +0 -11
- package/dist/auth-leases.d.ts.map +0 -1
- package/dist/auth-leases.js +0 -140
- package/dist/auth-leases.js.map +0 -1
- package/dist/blob-limits.d.ts +0 -15
- package/dist/blob-limits.d.ts.map +0 -1
- package/dist/blob-limits.js +0 -66
- package/dist/blob-limits.js.map +0 -1
- package/dist/bridge-client.d.ts +0 -69
- package/dist/bridge-client.d.ts.map +0 -1
- package/dist/bridge-client.js +0 -282
- package/dist/bridge-client.js.map +0 -1
- package/dist/client-config.d.ts +0 -6
- package/dist/client-config.d.ts.map +0 -1
- package/dist/client-config.js +0 -18
- package/dist/client-config.js.map +0 -1
- package/dist/client.d.ts.map +0 -1
- package/dist/client.js.map +0 -1
- package/dist/command-history.d.ts +0 -35
- package/dist/command-history.d.ts.map +0 -1
- package/dist/command-history.js +0 -378
- package/dist/command-history.js.map +0 -1
- package/dist/console-diagnostics.d.ts +0 -33
- package/dist/console-diagnostics.d.ts.map +0 -1
- package/dist/console-diagnostics.js +0 -496
- package/dist/console-diagnostics.js.map +0 -1
- package/dist/crdt-yjs/index.d.ts +0 -4
- package/dist/crdt-yjs/index.d.ts.map +0 -1
- package/dist/crdt-yjs/index.js +0 -4
- package/dist/crdt-yjs/index.js.map +0 -1
- package/dist/crdt-yjs/webview-host-facade.d.ts +0 -126
- package/dist/crdt-yjs/webview-host-facade.d.ts.map +0 -1
- package/dist/crdt-yjs/webview-host-facade.js +0 -284
- package/dist/crdt-yjs/webview-host-facade.js.map +0 -1
- package/dist/crdt-yjs/yjs-document-field-adapter.d.ts +0 -153
- package/dist/crdt-yjs/yjs-document-field-adapter.d.ts.map +0 -1
- package/dist/crdt-yjs/yjs-document-field-adapter.js +0 -406
- package/dist/crdt-yjs/yjs-document-field-adapter.js.map +0 -1
- package/dist/crdt-yjs/yjs-prosemirror-bridge.d.ts +0 -73
- package/dist/crdt-yjs/yjs-prosemirror-bridge.d.ts.map +0 -1
- package/dist/crdt-yjs/yjs-prosemirror-bridge.js +0 -169
- package/dist/crdt-yjs/yjs-prosemirror-bridge.js.map +0 -1
- package/dist/database.d.ts.map +0 -1
- package/dist/database.js.map +0 -1
- package/dist/diagnostics.d.ts +0 -10
- package/dist/diagnostics.d.ts.map +0 -1
- package/dist/diagnostics.js +0 -83
- package/dist/diagnostics.js.map +0 -1
- package/dist/errors.d.ts.map +0 -1
- package/dist/errors.js.map +0 -1
- package/dist/generated-bridge.d.ts +0 -365
- package/dist/generated-bridge.d.ts.map +0 -1
- package/dist/generated-bridge.js +0 -250
- package/dist/generated-bridge.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/mutations.d.ts +0 -72
- package/dist/mutations.d.ts.map +0 -1
- package/dist/mutations.js +0 -63
- package/dist/mutations.js.map +0 -1
- package/dist/network.d.ts +0 -3
- package/dist/network.d.ts.map +0 -1
- package/dist/network.js +0 -17
- package/dist/network.js.map +0 -1
- package/dist/react/index.d.ts +0 -169
- package/dist/react/index.d.ts.map +0 -1
- package/dist/react/index.js +0 -628
- package/dist/react/index.js.map +0 -1
- package/dist/react-native/index.d.ts +0 -35
- package/dist/react-native/index.d.ts.map +0 -1
- package/dist/react-native/index.js +0 -49
- package/dist/react-native/index.js.map +0 -1
- package/dist/runtime-contract.d.ts +0 -13
- package/dist/runtime-contract.d.ts.map +0 -1
- package/dist/runtime-contract.js +0 -24
- package/dist/runtime-contract.js.map +0 -1
- package/dist/rust-client.d.ts +0 -106
- package/dist/rust-client.d.ts.map +0 -1
- package/dist/rust-client.js +0 -938
- package/dist/rust-client.js.map +0 -1
- package/dist/sentry.d.ts +0 -35
- package/dist/sentry.d.ts.map +0 -1
- package/dist/sentry.js +0 -155
- package/dist/sentry.js.map +0 -1
- package/dist/sql-safety.d.ts +0 -3
- package/dist/sql-safety.d.ts.map +0 -1
- package/dist/sql-safety.js +0 -62
- package/dist/sql-safety.js.map +0 -1
- package/dist/syncular-runtime-artifacts.json +0 -61
- package/dist/tauri/index.d.ts +0 -35
- package/dist/tauri/index.d.ts.map +0 -1
- package/dist/tauri/index.js +0 -114
- package/dist/tauri/index.js.map +0 -1
- package/dist/types.d.ts +0 -1096
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -2
- package/dist/types.js.map +0 -1
- package/dist/wasm/.syncular-wasm-profile +0 -1
- package/dist/wasm/syncular-runtime-artifact.json +0 -21
- package/dist/wasm/syncular.d.ts +0 -207
- package/dist/wasm/syncular.js +0 -2341
- package/dist/wasm/syncular_bg.wasm +0 -0
- package/dist/wasm/syncular_bg.wasm.d.ts +0 -97
- package/dist/wasm-bindings/runtime-contract.d.ts +0 -22
- package/dist/wasm-bindings/runtime-contract.d.ts.map +0 -1
- package/dist/wasm-bindings/runtime-contract.js +0 -112
- package/dist/wasm-bindings/runtime-contract.js.map +0 -1
- package/dist/wasm-core/.syncular-wasm-profile +0 -1
- package/dist/wasm-core/syncular-runtime-artifact.json +0 -17
- package/dist/wasm-core/syncular.d.ts +0 -162
- package/dist/wasm-core/syncular.js +0 -1847
- package/dist/wasm-core/syncular_bg.wasm +0 -0
- package/dist/wasm-core/syncular_bg.wasm.d.ts +0 -77
- package/dist/wasm-perf/.syncular-wasm-profile +0 -1
- package/dist/wasm-perf/syncular-runtime-artifact.json +0 -21
- package/dist/wasm-perf/syncular.d.ts +0 -207
- package/dist/wasm-perf/syncular.js +0 -2341
- package/dist/wasm-perf/syncular_bg.wasm +0 -0
- package/dist/wasm-perf/syncular_bg.wasm.d.ts +0 -97
- package/dist/wasm-runtime.d.ts +0 -23
- package/dist/wasm-runtime.d.ts.map +0 -1
- package/dist/wasm-runtime.js +0 -69
- package/dist/wasm-runtime.js.map +0 -1
- package/dist/worker-client.d.ts +0 -123
- package/dist/worker-client.d.ts.map +0 -1
- package/dist/worker-client.js +0 -1735
- package/dist/worker-client.js.map +0 -1
- package/dist/worker-entry.d.ts.map +0 -1
- package/dist/worker-entry.js.map +0 -1
- package/dist/worker-protocol.d.ts.map +0 -1
- package/dist/worker-protocol.js.map +0 -1
- package/dist/worker-realtime.d.ts +0 -39
- package/dist/worker-realtime.d.ts.map +0 -1
- package/dist/worker-realtime.js +0 -677
- package/dist/worker-realtime.js.map +0 -1
- package/src/auth-leases.ts +0 -251
- package/src/blob-limits.ts +0 -98
- package/src/bridge-client.ts +0 -512
- package/src/client-config.ts +0 -29
- package/src/command-history.ts +0 -623
- package/src/console-diagnostics.ts +0 -617
- package/src/crdt-yjs/index.ts +0 -3
- package/src/crdt-yjs/webview-host-facade.ts +0 -477
- package/src/crdt-yjs/yjs-document-field-adapter.ts +0 -733
- package/src/crdt-yjs/yjs-prosemirror-bridge.ts +0 -272
- package/src/diagnostics.ts +0 -116
- package/src/generated-bridge.ts +0 -741
- package/src/mutations.ts +0 -168
- package/src/network.ts +0 -32
- package/src/react/index.ts +0 -1036
- package/src/react-native/index.ts +0 -152
- package/src/runtime-contract.ts +0 -48
- package/src/rust-client.ts +0 -1491
- package/src/sentry.ts +0 -215
- package/src/sql-safety.ts +0 -61
- package/src/tauri/index.ts +0 -211
- package/src/types.ts +0 -1397
- package/src/wasm-bindings/generated-wasm-bindings.d.ts +0 -70
- package/src/wasm-bindings/runtime-contract.ts +0 -158
- package/src/wasm-runtime.ts +0 -145
- package/src/worker-client.ts +0 -2289
- package/src/worker-realtime.ts +0 -843
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side encryption wiring (SPEC.md §5.11).
|
|
3
|
+
*
|
|
4
|
+
* The wire-boundary seam: encrypt configured columns when the outbox encodes
|
|
5
|
+
* a commit for send, decrypt them when a COMMIT/segment applies. The local
|
|
6
|
+
* SQLite mirror stays plaintext; the row codec (§2.4) is untouched — it only
|
|
7
|
+
* ever sees a `bytes` value for an encrypted column. This module is the
|
|
8
|
+
* bridge between the positional `RowValue[]` and the `@syncular/core` §5.11
|
|
9
|
+
* envelope primitives.
|
|
10
|
+
*/
|
|
11
|
+
import {
|
|
12
|
+
type DeclaredType,
|
|
13
|
+
DecryptError,
|
|
14
|
+
decryptValue,
|
|
15
|
+
encryptValue,
|
|
16
|
+
type NonceSource,
|
|
17
|
+
type PlainValue,
|
|
18
|
+
type RowColumn,
|
|
19
|
+
type RowValue,
|
|
20
|
+
} from '@syncular/core';
|
|
21
|
+
import type { CompiledClientTable } from './schema';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* App-supplied key material and selection. `keyProvider` maps a key-id to its
|
|
25
|
+
* 32-byte key; `keyIdFor` names the key for a given write (default:
|
|
26
|
+
* per-table, `keyId = table`). Keys travel as raw bytes — the app owns key
|
|
27
|
+
* storage, rotation, and distribution (§5.11; the docs give the synced
|
|
28
|
+
* wrapped-keys recipe).
|
|
29
|
+
*/
|
|
30
|
+
export interface EncryptionConfig {
|
|
31
|
+
/** `keyId → 32-byte key`, or `undefined` if unknown (decrypt fails loud). */
|
|
32
|
+
readonly keyProvider: (keyId: string) => Uint8Array | undefined;
|
|
33
|
+
/** Choose the key-id for an encrypt. Default: per-table (`table`). */
|
|
34
|
+
readonly keyIdFor?: (table: string, rowId: string) => string;
|
|
35
|
+
/**
|
|
36
|
+
* Nonce source (§5.11). Production omits this (secure RNG). ONLY the crypto
|
|
37
|
+
* golden-vector generator injects a fixed nonce — never a production path.
|
|
38
|
+
*/
|
|
39
|
+
readonly nonceSource?: NonceSource;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function declaredTypeOf(column: RowColumn): DeclaredType {
|
|
43
|
+
// An encrypted column always carries declaredType (typegen guarantees it;
|
|
44
|
+
// §5.11). Fall back to the wire type defensively.
|
|
45
|
+
return (column.declaredType ?? column.type) as DeclaredType;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Encrypt the encrypted columns of a positional row value array in place-safe
|
|
50
|
+
* fashion (returns a new array). Called at the outbox encode-at-send seam
|
|
51
|
+
* (§6.1) BEFORE the row codec serializes: an encrypted column's plaintext
|
|
52
|
+
* value becomes the §5.11 ciphertext-envelope `bytes`. NULLs pass through
|
|
53
|
+
* unencrypted (§5.11).
|
|
54
|
+
*/
|
|
55
|
+
export async function encryptRowValues(
|
|
56
|
+
config: EncryptionConfig,
|
|
57
|
+
table: CompiledClientTable,
|
|
58
|
+
rowId: string,
|
|
59
|
+
values: readonly RowValue[],
|
|
60
|
+
): Promise<RowValue[]> {
|
|
61
|
+
if (!table.hasEncryptedColumns) return values.slice();
|
|
62
|
+
const keyIdFor = config.keyIdFor ?? ((t: string) => t);
|
|
63
|
+
const out = values.slice();
|
|
64
|
+
for (let i = 0; i < table.columns.length; i++) {
|
|
65
|
+
const column = table.columns[i];
|
|
66
|
+
if (column === undefined || !column.encrypted) continue;
|
|
67
|
+
const value = out[i];
|
|
68
|
+
if (value === null || value === undefined) continue; // NULL stays NULL
|
|
69
|
+
const keyId = keyIdFor(table.name, rowId);
|
|
70
|
+
const key = config.keyProvider(keyId);
|
|
71
|
+
if (key === undefined) {
|
|
72
|
+
throw new DecryptError(
|
|
73
|
+
`no encryption key for keyId ${JSON.stringify(keyId)} (table ${table.name})`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
out[i] = await encryptValue(
|
|
77
|
+
declaredTypeOf(column),
|
|
78
|
+
value as PlainValue,
|
|
79
|
+
keyId,
|
|
80
|
+
key,
|
|
81
|
+
config.nonceSource,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Decrypt the encrypted columns of a decoded positional row value array
|
|
89
|
+
* (returns a new array). Called at the apply seam (§4.5 COMMIT, §5.6 rows
|
|
90
|
+
* segment) AFTER the row codec decodes: an encrypted column's ciphertext
|
|
91
|
+
* envelope `bytes` becomes its plaintext declared-type value for the local
|
|
92
|
+
* mirror. A wrong/missing key or malformed envelope throws
|
|
93
|
+
* {@link DecryptError} (`client.decrypt_failed`, §5.11).
|
|
94
|
+
*/
|
|
95
|
+
export async function decryptRowValues(
|
|
96
|
+
config: EncryptionConfig,
|
|
97
|
+
table: CompiledClientTable,
|
|
98
|
+
values: readonly RowValue[],
|
|
99
|
+
): Promise<RowValue[]> {
|
|
100
|
+
if (!table.hasEncryptedColumns) return values.slice();
|
|
101
|
+
const out = values.slice();
|
|
102
|
+
for (let i = 0; i < table.columns.length; i++) {
|
|
103
|
+
const column = table.columns[i];
|
|
104
|
+
if (column === undefined || !column.encrypted) continue;
|
|
105
|
+
const value = out[i];
|
|
106
|
+
if (value === null || value === undefined) continue;
|
|
107
|
+
if (!(value instanceof Uint8Array)) {
|
|
108
|
+
throw new DecryptError(
|
|
109
|
+
`encrypted column ${column.name} decoded to a non-bytes value`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
out[i] = deserializeGuard(
|
|
113
|
+
await decryptValue(declaredTypeOf(column), value, config.keyProvider),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// decryptValue already returns a PlainValue; RowValue is a superset, so this
|
|
120
|
+
// is just the type widening (kept explicit for clarity at the seam).
|
|
121
|
+
function deserializeGuard(value: PlainValue): RowValue {
|
|
122
|
+
return value;
|
|
123
|
+
}
|
package/src/errors.ts
CHANGED
|
@@ -1,269 +1,15 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
export interface SyncularErrorEnvelope {
|
|
9
|
-
code: SyncularErrorCode;
|
|
10
|
-
message: string;
|
|
11
|
-
category: SyncularErrorCategory;
|
|
12
|
-
retryable: boolean;
|
|
13
|
-
recommendedAction: SyncularErrorRecommendedAction;
|
|
14
|
-
details?: Record<string, unknown>;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export class SyncularClientError extends Error {
|
|
18
|
-
readonly code: SyncularErrorCode;
|
|
19
|
-
readonly category: SyncularErrorCategory;
|
|
1
|
+
/**
|
|
2
|
+
* Client-side errors. Protocol codes come from the SPEC.md §10 catalog;
|
|
3
|
+
* the client never invents wire codes, it surfaces them.
|
|
4
|
+
*/
|
|
5
|
+
export class ClientSyncError extends Error {
|
|
6
|
+
override readonly name = 'ClientSyncError';
|
|
7
|
+
readonly code: string;
|
|
20
8
|
readonly retryable: boolean;
|
|
21
|
-
readonly recommendedAction: SyncularErrorRecommendedAction;
|
|
22
|
-
readonly details: Record<string, unknown> | undefined;
|
|
23
|
-
|
|
24
|
-
constructor(envelope: SyncularErrorEnvelope, options?: ErrorOptions) {
|
|
25
|
-
super(envelope.message, options);
|
|
26
|
-
this.name = 'SyncularClientError';
|
|
27
|
-
this.code = envelope.code;
|
|
28
|
-
this.category = envelope.category;
|
|
29
|
-
this.retryable = envelope.retryable;
|
|
30
|
-
this.recommendedAction = envelope.recommendedAction;
|
|
31
|
-
this.details = envelope.details;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export function toSyncularClientError(error: unknown): Error {
|
|
36
|
-
if (error instanceof SyncularClientError) return error;
|
|
37
|
-
const message = syncularErrorMessage(error);
|
|
38
|
-
const details = syncularErrorDetails(error);
|
|
39
|
-
const classification = classifySyncularError(error, message, details);
|
|
40
|
-
if (!classification) {
|
|
41
|
-
return error instanceof Error ? error : new Error(message);
|
|
42
|
-
}
|
|
43
|
-
return new SyncularClientError(
|
|
44
|
-
{
|
|
45
|
-
...classification,
|
|
46
|
-
message,
|
|
47
|
-
...(details ? { details } : {}),
|
|
48
|
-
},
|
|
49
|
-
error instanceof Error ? { cause: error } : undefined
|
|
50
|
-
);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export function syncularErrorMessage(error: unknown): string {
|
|
54
|
-
return error instanceof Error ? error.message : String(error);
|
|
55
|
-
}
|
|
56
9
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (!(error instanceof Error)) return undefined;
|
|
62
|
-
const serverError = syncularServerErrorFromMessage(error.message);
|
|
63
|
-
const details: Record<string, unknown> = {
|
|
64
|
-
...(httpStatusFromMessage(error.message) ?? {}),
|
|
65
|
-
...(serverError
|
|
66
|
-
? {
|
|
67
|
-
serverErrorCode: serverError.code,
|
|
68
|
-
serverErrorCategory: serverError.category,
|
|
69
|
-
serverRetryable: serverError.retryable,
|
|
70
|
-
serverRecommendedAction: serverError.recommendedAction,
|
|
71
|
-
}
|
|
72
|
-
: {}),
|
|
73
|
-
...(serverError?.details ? { serverDetails: serverError.details } : {}),
|
|
74
|
-
...(error.message.includes('full snapshot resync required')
|
|
75
|
-
? { resyncRequired: true }
|
|
76
|
-
: {}),
|
|
77
|
-
...(syncularKindFromError(error)
|
|
78
|
-
? { syncularKind: syncularKindFromError(error) }
|
|
79
|
-
: {}),
|
|
80
|
-
...(syncularDebugFromError(error)
|
|
81
|
-
? { syncularDebug: syncularDebugFromError(error) }
|
|
82
|
-
: {}),
|
|
83
|
-
};
|
|
84
|
-
return Object.keys(details).length > 0 ? details : undefined;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export function classifySyncularError(
|
|
88
|
-
error: unknown,
|
|
89
|
-
message = syncularErrorMessage(error),
|
|
90
|
-
details: Record<string, unknown> | undefined = syncularErrorDetails(error)
|
|
91
|
-
): Omit<SyncularErrorEnvelope, 'message' | 'details'> | null {
|
|
92
|
-
const serverError = syncularServerErrorFromMessage(message);
|
|
93
|
-
if (serverError) {
|
|
94
|
-
return {
|
|
95
|
-
code: serverError.code,
|
|
96
|
-
category: serverError.category,
|
|
97
|
-
retryable: serverError.retryable,
|
|
98
|
-
recommendedAction: serverError.recommendedAction,
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const status =
|
|
103
|
-
details &&
|
|
104
|
-
'status' in details &&
|
|
105
|
-
(details.status === 401 || details.status === 403)
|
|
106
|
-
? details.status
|
|
107
|
-
: httpStatusFromMessage(message)?.status;
|
|
108
|
-
if (status === 401) {
|
|
109
|
-
return {
|
|
110
|
-
code: 'sync.auth_required',
|
|
111
|
-
category: 'auth-required',
|
|
112
|
-
retryable: true,
|
|
113
|
-
recommendedAction: 'refreshAuth',
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
if (status === 403) {
|
|
117
|
-
return {
|
|
118
|
-
code: 'sync.forbidden',
|
|
119
|
-
category: 'forbidden',
|
|
120
|
-
retryable: false,
|
|
121
|
-
recommendedAction: 'checkPermissions',
|
|
122
|
-
};
|
|
10
|
+
constructor(code: string, message: string, retryable = false) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.code = code;
|
|
13
|
+
this.retryable = retryable;
|
|
123
14
|
}
|
|
124
|
-
|
|
125
|
-
const syncularKind =
|
|
126
|
-
details && typeof details.syncularKind === 'string'
|
|
127
|
-
? details.syncularKind
|
|
128
|
-
: syncularKindFromError(error);
|
|
129
|
-
const debug =
|
|
130
|
-
details && typeof details.syncularDebug === 'string'
|
|
131
|
-
? details.syncularDebug
|
|
132
|
-
: syncularDebugFromError(error);
|
|
133
|
-
const haystack = `${message}\n${debug ?? ''}`;
|
|
134
|
-
|
|
135
|
-
if (/\boffline\b|network is unreachable/i.test(haystack)) {
|
|
136
|
-
return {
|
|
137
|
-
code: 'sync.offline',
|
|
138
|
-
category: 'offline',
|
|
139
|
-
retryable: true,
|
|
140
|
-
recommendedAction: 'retryLater',
|
|
141
|
-
};
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
if (syncularKind === 'Schema' || /\bschema version\b/i.test(haystack)) {
|
|
145
|
-
return {
|
|
146
|
-
code: 'sync.schema_mismatch',
|
|
147
|
-
category: 'schema-mismatch',
|
|
148
|
-
retryable: false,
|
|
149
|
-
recommendedAction: 'regenerateClient',
|
|
150
|
-
};
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
if (
|
|
154
|
-
syncularKind === 'Protocol' &&
|
|
155
|
-
/(hash mismatch|sha256 mismatch|byte length mismatch|manifest .*mismatch|integrity|chain root|commit root|verified root)/i.test(
|
|
156
|
-
haystack
|
|
157
|
-
)
|
|
158
|
-
) {
|
|
159
|
-
return {
|
|
160
|
-
code: 'sync.integrity_rejected',
|
|
161
|
-
category: 'integrity-rejected',
|
|
162
|
-
retryable: false,
|
|
163
|
-
recommendedAction: 'forceResync',
|
|
164
|
-
};
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
return null;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
export function isSyncularOfflineError(error: unknown): boolean {
|
|
171
|
-
if (error instanceof SyncularClientError) {
|
|
172
|
-
return error.code === 'sync.offline' || error.category === 'offline';
|
|
173
|
-
}
|
|
174
|
-
if (error && typeof error === 'object') {
|
|
175
|
-
const record = error as Record<string, unknown>;
|
|
176
|
-
if (record.code === 'sync.offline' || record.category === 'offline') {
|
|
177
|
-
return true;
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
const classification = classifySyncularError(error);
|
|
181
|
-
return (
|
|
182
|
-
classification?.code === 'sync.offline' ||
|
|
183
|
-
classification?.category === 'offline'
|
|
184
|
-
);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
export function syncularErrorStatus(error: unknown): 401 | 403 | undefined {
|
|
188
|
-
const details =
|
|
189
|
-
error instanceof SyncularClientError ? error.details : undefined;
|
|
190
|
-
if (
|
|
191
|
-
details &&
|
|
192
|
-
'status' in details &&
|
|
193
|
-
(details.status === 401 || details.status === 403)
|
|
194
|
-
) {
|
|
195
|
-
return details.status;
|
|
196
|
-
}
|
|
197
|
-
return httpStatusFromMessage(syncularErrorMessage(error))?.status;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
function httpStatusFromMessage(
|
|
201
|
-
message: string
|
|
202
|
-
): { status: 401 | 403 } | undefined {
|
|
203
|
-
const match = /\bHTTP (401|403)\b/.exec(message);
|
|
204
|
-
if (!match) return undefined;
|
|
205
|
-
return { status: match[1] === '401' ? 401 : 403 };
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function syncularKindFromError(error: unknown): string | undefined {
|
|
209
|
-
return error instanceof Error &&
|
|
210
|
-
typeof (error as Error & { syncularKind?: unknown }).syncularKind ===
|
|
211
|
-
'string'
|
|
212
|
-
? (error as Error & { syncularKind: string }).syncularKind
|
|
213
|
-
: undefined;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function syncularDebugFromError(error: unknown): string | undefined {
|
|
217
|
-
return error instanceof Error &&
|
|
218
|
-
typeof (error as Error & { syncularDebug?: unknown }).syncularDebug ===
|
|
219
|
-
'string'
|
|
220
|
-
? (error as Error & { syncularDebug: string }).syncularDebug
|
|
221
|
-
: undefined;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
function syncularServerErrorFromMessage(
|
|
225
|
-
message: string
|
|
226
|
-
): Omit<SyncularErrorEnvelope, 'message'> | null {
|
|
227
|
-
const match = /\bHTTP \d{3}\b: (\{.*\})\s*$/s.exec(message);
|
|
228
|
-
if (!match) return null;
|
|
229
|
-
|
|
230
|
-
let parsed: unknown;
|
|
231
|
-
try {
|
|
232
|
-
parsed = JSON.parse(match[1] ?? '');
|
|
233
|
-
} catch {
|
|
234
|
-
return null;
|
|
235
|
-
}
|
|
236
|
-
if (!parsed || typeof parsed !== 'object') return null;
|
|
237
|
-
|
|
238
|
-
const record = parsed as Record<string, unknown>;
|
|
239
|
-
const rawCode =
|
|
240
|
-
typeof record.code === 'string'
|
|
241
|
-
? record.code
|
|
242
|
-
: typeof record.error === 'string'
|
|
243
|
-
? record.error
|
|
244
|
-
: null;
|
|
245
|
-
if (!rawCode || !(rawCode in SYNCULAR_ERROR_DEFINITIONS)) return null;
|
|
246
|
-
|
|
247
|
-
const definition =
|
|
248
|
-
SYNCULAR_ERROR_DEFINITIONS[
|
|
249
|
-
rawCode as keyof typeof SYNCULAR_ERROR_DEFINITIONS
|
|
250
|
-
];
|
|
251
|
-
return {
|
|
252
|
-
code: rawCode as SyncularErrorCode,
|
|
253
|
-
category:
|
|
254
|
-
typeof record.category === 'string'
|
|
255
|
-
? (record.category as SyncularErrorCategory)
|
|
256
|
-
: definition.category,
|
|
257
|
-
retryable:
|
|
258
|
-
typeof record.retryable === 'boolean'
|
|
259
|
-
? record.retryable
|
|
260
|
-
: definition.retryable,
|
|
261
|
-
recommendedAction:
|
|
262
|
-
typeof record.recommendedAction === 'string'
|
|
263
|
-
? (record.recommendedAction as SyncularErrorRecommendedAction)
|
|
264
|
-
: definition.recommendedAction,
|
|
265
|
-
...(record.details && typeof record.details === 'object'
|
|
266
|
-
? { details: record.details as Record<string, unknown> }
|
|
267
|
-
: {}),
|
|
268
|
-
};
|
|
269
15
|
}
|
package/src/http.ts
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser transport bindings (§1.1, §5.4/§5.5, §8.1): fetch-based sync
|
|
3
|
+
* transport, segment download with signed-URL preference and direct-serve
|
|
4
|
+
* fallback, and a WebSocket realtime connector. Core tests never use these
|
|
5
|
+
* (the loopback doctrine); B6 exercises them in a real browser.
|
|
6
|
+
*/
|
|
7
|
+
import type { BlobTransport } from './blob';
|
|
8
|
+
import { SSP2_CONTENT_TYPE } from './content-type';
|
|
9
|
+
import { ClientSyncError } from './errors';
|
|
10
|
+
import type {
|
|
11
|
+
RealtimeConnector,
|
|
12
|
+
SegmentDownloader,
|
|
13
|
+
SyncTransport,
|
|
14
|
+
} from './transport';
|
|
15
|
+
|
|
16
|
+
async function throwHttpError(response: Response): Promise<never> {
|
|
17
|
+
let code = 'sync.transport_failed';
|
|
18
|
+
let message = `HTTP ${response.status}`;
|
|
19
|
+
let retryable = response.status >= 500 || response.status === 429;
|
|
20
|
+
try {
|
|
21
|
+
const body = (await response.json()) as {
|
|
22
|
+
code?: string;
|
|
23
|
+
message?: string;
|
|
24
|
+
retryable?: boolean;
|
|
25
|
+
};
|
|
26
|
+
if (typeof body.code === 'string') code = body.code;
|
|
27
|
+
if (typeof body.message === 'string') message = body.message;
|
|
28
|
+
if (typeof body.retryable === 'boolean') retryable = body.retryable;
|
|
29
|
+
} catch {
|
|
30
|
+
// non-JSON error body — keep the HTTP-status defaults
|
|
31
|
+
}
|
|
32
|
+
throw new ClientSyncError(code, message, retryable);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface HttpTransportOptions {
|
|
36
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
37
|
+
readonly fetch?: typeof fetch;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** POST `<mount>/sync` with SSP2 bodies (§1.1). */
|
|
41
|
+
export function httpSyncTransport(
|
|
42
|
+
syncUrl: string,
|
|
43
|
+
options?: HttpTransportOptions,
|
|
44
|
+
): SyncTransport {
|
|
45
|
+
const doFetch = options?.fetch ?? fetch;
|
|
46
|
+
return async (request) => {
|
|
47
|
+
const response = await doFetch(syncUrl, {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
headers: {
|
|
50
|
+
'Content-Type': SSP2_CONTENT_TYPE,
|
|
51
|
+
...options?.headers,
|
|
52
|
+
},
|
|
53
|
+
body: request.slice().buffer as ArrayBuffer,
|
|
54
|
+
});
|
|
55
|
+
if (!response.ok) await throwHttpError(response);
|
|
56
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* §5.5 direct endpoint with the `X-Syncular-Scopes` re-authorization
|
|
62
|
+
* header, plus the §5.4 `fetchUrl` capability (advertises accept bit 3).
|
|
63
|
+
* `fetchUrl` sends NO headers at all — the signed URL is the entire
|
|
64
|
+
* grant, and host auth must never leak to CDN/object hosts (§5.4).
|
|
65
|
+
* Resolution (which path a descriptor takes, expiry, no fall-through)
|
|
66
|
+
* lives in the client core, not here.
|
|
67
|
+
*/
|
|
68
|
+
export function httpSegmentDownloader(
|
|
69
|
+
segmentsBaseUrl: string,
|
|
70
|
+
options?: HttpTransportOptions,
|
|
71
|
+
): SegmentDownloader {
|
|
72
|
+
const doFetch = options?.fetch ?? fetch;
|
|
73
|
+
const direct = async (request: {
|
|
74
|
+
readonly segmentId: string;
|
|
75
|
+
readonly requestedScopesJson: string;
|
|
76
|
+
}) => {
|
|
77
|
+
const response = await doFetch(
|
|
78
|
+
`${segmentsBaseUrl}/${encodeURIComponent(request.segmentId)}`,
|
|
79
|
+
{
|
|
80
|
+
headers: {
|
|
81
|
+
'X-Syncular-Scopes': request.requestedScopesJson,
|
|
82
|
+
...options?.headers,
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
);
|
|
86
|
+
if (!response.ok) await throwHttpError(response);
|
|
87
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
88
|
+
};
|
|
89
|
+
const fetchUrl = async (url: string) => {
|
|
90
|
+
// Deliberately headerless: the URL is the bearer grant (§5.4).
|
|
91
|
+
const response = await doFetch(url);
|
|
92
|
+
if (!response.ok) {
|
|
93
|
+
throw new ClientSyncError(
|
|
94
|
+
'sync.transport_failed',
|
|
95
|
+
`signed-URL fetch failed with HTTP ${response.status} (§5.4: descriptor invalidated, re-pull to recover)`,
|
|
96
|
+
true,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
100
|
+
};
|
|
101
|
+
return Object.assign(direct, { fetchUrl });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* §5.9.3/§5.9.5 blob transport: host-authenticated `PUT`/`GET
|
|
106
|
+
* <mount>/blobs/{blobId}`. Both carry normal host auth (the blob id is not
|
|
107
|
+
* a capability — the server re-authorizes downloads against referencing
|
|
108
|
+
* rows). Content-address verification is the client core's job (§5.9.7).
|
|
109
|
+
*/
|
|
110
|
+
export function httpBlobTransport(
|
|
111
|
+
blobsBaseUrl: string,
|
|
112
|
+
options?: HttpTransportOptions,
|
|
113
|
+
): BlobTransport {
|
|
114
|
+
const doFetch = options?.fetch ?? fetch;
|
|
115
|
+
const blobUrl = (blobId: string) =>
|
|
116
|
+
`${blobsBaseUrl}/${encodeURIComponent(blobId)}`;
|
|
117
|
+
return {
|
|
118
|
+
upload: async (blobId, bytes, mediaType) => {
|
|
119
|
+
const response = await doFetch(blobUrl(blobId), {
|
|
120
|
+
method: 'PUT',
|
|
121
|
+
headers: {
|
|
122
|
+
'Content-Type': mediaType ?? 'application/octet-stream',
|
|
123
|
+
...options?.headers,
|
|
124
|
+
},
|
|
125
|
+
body: bytes.slice().buffer as ArrayBuffer,
|
|
126
|
+
});
|
|
127
|
+
if (!response.ok) await throwHttpError(response);
|
|
128
|
+
},
|
|
129
|
+
download: async (blobId) => {
|
|
130
|
+
const response = await doFetch(blobUrl(blobId), {
|
|
131
|
+
headers: { ...options?.headers },
|
|
132
|
+
});
|
|
133
|
+
if (!response.ok) await throwHttpError(response);
|
|
134
|
+
// §5.9.5 always-issue: a JSON body with `url` means presigned delivery;
|
|
135
|
+
// an octet-stream body is inline bytes.
|
|
136
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
137
|
+
if (contentType.includes('application/json')) {
|
|
138
|
+
const body = (await response.json()) as {
|
|
139
|
+
url?: string;
|
|
140
|
+
urlExpiresAtMs?: number;
|
|
141
|
+
};
|
|
142
|
+
if (typeof body.url === 'string') {
|
|
143
|
+
return {
|
|
144
|
+
kind: 'url',
|
|
145
|
+
url: body.url,
|
|
146
|
+
...(typeof body.urlExpiresAtMs === 'number'
|
|
147
|
+
? { urlExpiresAtMs: body.urlExpiresAtMs }
|
|
148
|
+
: {}),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
kind: 'bytes',
|
|
154
|
+
bytes: new Uint8Array(await response.arrayBuffer()),
|
|
155
|
+
};
|
|
156
|
+
},
|
|
157
|
+
// §5.9.5: bare GET of the signed URL — no host auth (the URL is the grant).
|
|
158
|
+
fetchUrl: async (url) => {
|
|
159
|
+
const response = await doFetch(url);
|
|
160
|
+
if (!response.ok) {
|
|
161
|
+
throw new ClientSyncError(
|
|
162
|
+
'sync.transport_failed',
|
|
163
|
+
`blob signed-URL fetch failed with HTTP ${response.status} (§5.9.5: re-request to recover)`,
|
|
164
|
+
true,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
168
|
+
},
|
|
169
|
+
// §5.9.3: presigned-upload grant.
|
|
170
|
+
uploadGrant: async (blobId, byteLength, mediaType) => {
|
|
171
|
+
const response = await doFetch(`${blobUrl(blobId)}/upload-grant`, {
|
|
172
|
+
method: 'POST',
|
|
173
|
+
headers: {
|
|
174
|
+
'Content-Type': 'application/json',
|
|
175
|
+
...options?.headers,
|
|
176
|
+
},
|
|
177
|
+
body: JSON.stringify({
|
|
178
|
+
byteLength,
|
|
179
|
+
...(mediaType !== undefined ? { mediaType } : {}),
|
|
180
|
+
}),
|
|
181
|
+
});
|
|
182
|
+
if (!response.ok) await throwHttpError(response);
|
|
183
|
+
const body = (await response.json()) as {
|
|
184
|
+
url?: string;
|
|
185
|
+
urlExpiresAtMs?: number;
|
|
186
|
+
present?: boolean;
|
|
187
|
+
};
|
|
188
|
+
if (typeof body.url === 'string') {
|
|
189
|
+
return {
|
|
190
|
+
kind: 'url',
|
|
191
|
+
url: body.url,
|
|
192
|
+
...(typeof body.urlExpiresAtMs === 'number'
|
|
193
|
+
? { urlExpiresAtMs: body.urlExpiresAtMs }
|
|
194
|
+
: {}),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
if (body.present === true) return { kind: 'present' };
|
|
198
|
+
return { kind: 'none' };
|
|
199
|
+
},
|
|
200
|
+
// §5.9.3: direct-to-storage PUT — no host auth (the URL is the grant).
|
|
201
|
+
uploadToUrl: async (url, bytes, mediaType) => {
|
|
202
|
+
const response = await doFetch(url, {
|
|
203
|
+
method: 'PUT',
|
|
204
|
+
headers: {
|
|
205
|
+
'Content-Type': mediaType ?? 'application/octet-stream',
|
|
206
|
+
},
|
|
207
|
+
body: bytes.slice().buffer as ArrayBuffer,
|
|
208
|
+
});
|
|
209
|
+
if (!response.ok) {
|
|
210
|
+
throw new ClientSyncError(
|
|
211
|
+
'sync.transport_failed',
|
|
212
|
+
`blob presigned PUT failed with HTTP ${response.status} (§5.9.3: re-request a grant or stream direct)`,
|
|
213
|
+
true,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** WebSocket realtime connector (§8.1): text = control, binary = deltas. */
|
|
221
|
+
export function webSocketRealtimeConnector(
|
|
222
|
+
realtimeUrl: string,
|
|
223
|
+
): RealtimeConnector {
|
|
224
|
+
return (handlers) =>
|
|
225
|
+
new Promise((resolve, reject) => {
|
|
226
|
+
const socket = new WebSocket(realtimeUrl);
|
|
227
|
+
socket.binaryType = 'arraybuffer';
|
|
228
|
+
socket.onopen = () => {
|
|
229
|
+
resolve({
|
|
230
|
+
send: (text) => socket.send(text),
|
|
231
|
+
sendBytes: (bytes) => {
|
|
232
|
+
socket.send(bytes.slice().buffer as ArrayBuffer);
|
|
233
|
+
},
|
|
234
|
+
close: () => socket.close(),
|
|
235
|
+
});
|
|
236
|
+
};
|
|
237
|
+
socket.onmessage = (event) => {
|
|
238
|
+
if (typeof event.data === 'string') handlers.onText(event.data);
|
|
239
|
+
else handlers.onBinary(new Uint8Array(event.data as ArrayBuffer));
|
|
240
|
+
};
|
|
241
|
+
socket.onerror = () => {
|
|
242
|
+
reject(
|
|
243
|
+
new ClientSyncError(
|
|
244
|
+
'sync.transport_failed',
|
|
245
|
+
'realtime socket failed to connect',
|
|
246
|
+
true,
|
|
247
|
+
),
|
|
248
|
+
);
|
|
249
|
+
};
|
|
250
|
+
socket.onclose = () => {
|
|
251
|
+
handlers.onClose?.();
|
|
252
|
+
};
|
|
253
|
+
});
|
|
254
|
+
}
|