@syncular/client 0.0.1-72 → 0.0.1-83
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/blobs/index.js +3 -3
- package/dist/client.d.ts +1 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +63 -16
- package/dist/client.js.map +1 -1
- package/dist/conflicts.d.ts.map +1 -1
- package/dist/conflicts.js +1 -7
- package/dist/conflicts.js.map +1 -1
- package/dist/create-client.js +4 -4
- package/dist/engine/SyncEngine.d.ts +1 -0
- package/dist/engine/SyncEngine.d.ts.map +1 -1
- package/dist/engine/SyncEngine.js +30 -32
- package/dist/engine/SyncEngine.js.map +1 -1
- package/dist/engine/index.js +2 -2
- package/dist/handlers/create-handler.d.ts.map +1 -1
- package/dist/handlers/create-handler.js +1 -4
- package/dist/handlers/create-handler.js.map +1 -1
- package/dist/index.js +19 -19
- package/dist/mutations.d.ts.map +1 -1
- package/dist/mutations.js +2 -12
- package/dist/mutations.js.map +1 -1
- package/dist/outbox.d.ts.map +1 -1
- package/dist/outbox.js +1 -11
- package/dist/outbox.js.map +1 -1
- package/dist/plugins/index.js +2 -2
- package/dist/proxy/dialect.js +1 -1
- package/dist/proxy/driver.js +1 -1
- package/dist/proxy/index.js +4 -4
- package/dist/proxy/mutations.js +1 -1
- package/dist/push-engine.js +2 -2
- package/dist/query/QueryContext.js +1 -1
- package/dist/query/index.js +3 -3
- package/dist/query/tracked-select.js +1 -1
- package/dist/sync-loop.js +4 -4
- package/package.json +1 -1
- package/src/client.test.ts +369 -0
- package/src/client.ts +79 -13
- package/src/conflicts.ts +1 -10
- package/src/engine/SyncEngine.test.ts +157 -0
- package/src/engine/SyncEngine.ts +55 -29
- package/src/handlers/create-handler.ts +1 -5
- package/src/mutations.ts +1 -15
- package/src/outbox.ts +1 -15
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
2
|
+
import type { SyncChange, SyncTransport } from '@syncular/core';
|
|
3
|
+
import type { Kysely } from 'kysely';
|
|
4
|
+
import { sql } from 'kysely';
|
|
5
|
+
import { createBunSqliteDb } from '../../../dialect-bun-sqlite/src';
|
|
6
|
+
import { ClientTableRegistry } from '../handlers/registry';
|
|
7
|
+
import { ensureClientSyncSchema } from '../migrate';
|
|
8
|
+
import type { SyncClientDb } from '../schema';
|
|
9
|
+
import { SyncEngine } from './SyncEngine';
|
|
10
|
+
|
|
11
|
+
interface TasksTable {
|
|
12
|
+
id: string;
|
|
13
|
+
title: string;
|
|
14
|
+
server_version: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface TestDb extends SyncClientDb {
|
|
18
|
+
tasks: TasksTable;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const noopTransport: SyncTransport = {
|
|
22
|
+
async sync() {
|
|
23
|
+
return {};
|
|
24
|
+
},
|
|
25
|
+
async fetchSnapshotChunk() {
|
|
26
|
+
return new Uint8Array();
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
describe('SyncEngine WS inline apply', () => {
|
|
31
|
+
let db: Kysely<TestDb>;
|
|
32
|
+
|
|
33
|
+
beforeEach(async () => {
|
|
34
|
+
db = createBunSqliteDb<TestDb>({ path: ':memory:' });
|
|
35
|
+
await ensureClientSyncSchema(db);
|
|
36
|
+
|
|
37
|
+
await db.schema
|
|
38
|
+
.createTable('tasks')
|
|
39
|
+
.addColumn('id', 'text', (col) => col.primaryKey())
|
|
40
|
+
.addColumn('title', 'text', (col) => col.notNull())
|
|
41
|
+
.addColumn('server_version', 'integer', (col) =>
|
|
42
|
+
col.notNull().defaultTo(0)
|
|
43
|
+
)
|
|
44
|
+
.execute();
|
|
45
|
+
|
|
46
|
+
await db
|
|
47
|
+
.insertInto('tasks')
|
|
48
|
+
.values({
|
|
49
|
+
id: 't1',
|
|
50
|
+
title: 'old',
|
|
51
|
+
server_version: 1,
|
|
52
|
+
})
|
|
53
|
+
.execute();
|
|
54
|
+
|
|
55
|
+
await db
|
|
56
|
+
.insertInto('sync_subscription_state')
|
|
57
|
+
.values({
|
|
58
|
+
state_id: 'default',
|
|
59
|
+
subscription_id: 'sub-1',
|
|
60
|
+
shape: 'tasks',
|
|
61
|
+
scopes_json: '{}',
|
|
62
|
+
params_json: '{}',
|
|
63
|
+
cursor: 0,
|
|
64
|
+
bootstrap_state_json: null,
|
|
65
|
+
status: 'active',
|
|
66
|
+
created_at: Date.now(),
|
|
67
|
+
updated_at: Date.now(),
|
|
68
|
+
})
|
|
69
|
+
.execute();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
afterEach(async () => {
|
|
73
|
+
await db.destroy();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('rolls back row updates and cursor when any inline WS change fails', async () => {
|
|
77
|
+
const shapes = new ClientTableRegistry<TestDb>().register({
|
|
78
|
+
table: 'tasks',
|
|
79
|
+
async applySnapshot() {},
|
|
80
|
+
async clearAll() {},
|
|
81
|
+
async applyChange(ctx, change) {
|
|
82
|
+
if (change.row_id === 'fail') {
|
|
83
|
+
throw new Error('forced apply failure');
|
|
84
|
+
}
|
|
85
|
+
const rowJson =
|
|
86
|
+
change.row_json && typeof change.row_json === 'object'
|
|
87
|
+
? change.row_json
|
|
88
|
+
: null;
|
|
89
|
+
const title =
|
|
90
|
+
rowJson && 'title' in rowJson ? String(rowJson.title ?? '') : '';
|
|
91
|
+
await sql`
|
|
92
|
+
update ${sql.table('tasks')}
|
|
93
|
+
set
|
|
94
|
+
${sql.ref('title')} = ${sql.val(title)},
|
|
95
|
+
${sql.ref('server_version')} = ${sql.val(Number(change.row_version ?? 0))}
|
|
96
|
+
where ${sql.ref('id')} = ${sql.val(change.row_id)}
|
|
97
|
+
`.execute(ctx.trx);
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const engine = new SyncEngine<TestDb>({
|
|
102
|
+
db,
|
|
103
|
+
transport: noopTransport,
|
|
104
|
+
shapes,
|
|
105
|
+
actorId: 'u1',
|
|
106
|
+
clientId: 'client-1',
|
|
107
|
+
subscriptions: [],
|
|
108
|
+
stateId: 'default',
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const changes: SyncChange[] = [
|
|
112
|
+
{
|
|
113
|
+
table: 'tasks',
|
|
114
|
+
row_id: 't1',
|
|
115
|
+
op: 'upsert',
|
|
116
|
+
row_json: { id: 't1', title: 'new' },
|
|
117
|
+
row_version: 2,
|
|
118
|
+
scopes: {},
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
table: 'tasks',
|
|
122
|
+
row_id: 'fail',
|
|
123
|
+
op: 'upsert',
|
|
124
|
+
row_json: { id: 'fail', title: 'bad' },
|
|
125
|
+
row_version: 1,
|
|
126
|
+
scopes: {},
|
|
127
|
+
},
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
const applyWsDeliveredChanges = Reflect.get(
|
|
131
|
+
engine,
|
|
132
|
+
'applyWsDeliveredChanges'
|
|
133
|
+
);
|
|
134
|
+
if (typeof applyWsDeliveredChanges !== 'function') {
|
|
135
|
+
throw new Error('Expected applyWsDeliveredChanges to be callable');
|
|
136
|
+
}
|
|
137
|
+
const applied = await applyWsDeliveredChanges.call(engine, changes, 10);
|
|
138
|
+
|
|
139
|
+
expect(applied).toBe(false);
|
|
140
|
+
|
|
141
|
+
const task = await db
|
|
142
|
+
.selectFrom('tasks')
|
|
143
|
+
.select(['title', 'server_version'])
|
|
144
|
+
.where('id', '=', 't1')
|
|
145
|
+
.executeTakeFirstOrThrow();
|
|
146
|
+
expect(task.title).toBe('old');
|
|
147
|
+
expect(task.server_version).toBe(1);
|
|
148
|
+
|
|
149
|
+
const state = await db
|
|
150
|
+
.selectFrom('sync_subscription_state')
|
|
151
|
+
.select(['cursor'])
|
|
152
|
+
.where('state_id', '=', 'default')
|
|
153
|
+
.where('subscription_id', '=', 'sub-1')
|
|
154
|
+
.executeTakeFirstOrThrow();
|
|
155
|
+
expect(state.cursor).toBe(0);
|
|
156
|
+
});
|
|
157
|
+
});
|
package/src/engine/SyncEngine.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
SyncPullResponse,
|
|
11
11
|
SyncSubscriptionRequest,
|
|
12
12
|
} from '@syncular/core';
|
|
13
|
+
import { isRecord } from '@syncular/core';
|
|
13
14
|
import { type Kysely, sql } from 'kysely';
|
|
14
15
|
import { syncPushOnce } from '../push-engine';
|
|
15
16
|
import type {
|
|
@@ -71,10 +72,6 @@ function createSyncError(
|
|
|
71
72
|
};
|
|
72
73
|
}
|
|
73
74
|
|
|
74
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
75
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
75
|
/**
|
|
79
76
|
* Sync engine that orchestrates push/pull cycles with proper lifecycle management.
|
|
80
77
|
*
|
|
@@ -611,7 +608,12 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
611
608
|
}
|
|
612
609
|
|
|
613
610
|
// Refresh outbox stats (fire-and-forget — don't block sync:complete)
|
|
614
|
-
this.refreshOutboxStats().catch(() => {
|
|
611
|
+
this.refreshOutboxStats().catch((error) => {
|
|
612
|
+
console.warn(
|
|
613
|
+
'[SyncEngine] Failed to refresh outbox stats after sync:',
|
|
614
|
+
error
|
|
615
|
+
);
|
|
616
|
+
});
|
|
615
617
|
|
|
616
618
|
return syncResult;
|
|
617
619
|
} catch (err) {
|
|
@@ -680,13 +682,13 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
680
682
|
try {
|
|
681
683
|
await this.config.db.transaction().execute(async (trx) => {
|
|
682
684
|
for (const change of changes) {
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
// Best-effort: individual change failures are fine
|
|
685
|
+
const handler = this.config.shapes.get(change.table);
|
|
686
|
+
if (!handler) {
|
|
687
|
+
throw new Error(
|
|
688
|
+
`Missing client table handler for WS change table "${change.table}"`
|
|
689
|
+
);
|
|
689
690
|
}
|
|
691
|
+
await handler.applyChange({ trx }, change);
|
|
690
692
|
}
|
|
691
693
|
|
|
692
694
|
// Update subscription cursors
|
|
@@ -737,13 +739,19 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
737
739
|
): Promise<void> {
|
|
738
740
|
// If a sync is already in-flight, let it handle everything
|
|
739
741
|
if (this.syncPromise) {
|
|
740
|
-
this.
|
|
742
|
+
this.triggerSyncInBackground(
|
|
743
|
+
{ trigger: 'ws' },
|
|
744
|
+
'ws delivery with in-flight sync'
|
|
745
|
+
);
|
|
741
746
|
return;
|
|
742
747
|
}
|
|
743
748
|
|
|
744
749
|
// If there are pending outbox commits, need to push via HTTP
|
|
745
750
|
if (this.state.pendingCount > 0) {
|
|
746
|
-
this.
|
|
751
|
+
this.triggerSyncInBackground(
|
|
752
|
+
{ trigger: 'ws' },
|
|
753
|
+
'ws delivery with pending outbox'
|
|
754
|
+
);
|
|
747
755
|
return;
|
|
748
756
|
}
|
|
749
757
|
|
|
@@ -753,14 +761,20 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
753
761
|
(p) => typeof p.afterPull === 'function'
|
|
754
762
|
);
|
|
755
763
|
if (hasAfterPullPlugins) {
|
|
756
|
-
this.
|
|
764
|
+
this.triggerSyncInBackground(
|
|
765
|
+
{ trigger: 'ws' },
|
|
766
|
+
'ws delivery with afterPull plugins'
|
|
767
|
+
);
|
|
757
768
|
return;
|
|
758
769
|
}
|
|
759
770
|
|
|
760
771
|
// Apply changes + update cursor
|
|
761
772
|
const applied = await this.applyWsDeliveredChanges(changes, cursor);
|
|
762
773
|
if (!applied) {
|
|
763
|
-
this.
|
|
774
|
+
this.triggerSyncInBackground(
|
|
775
|
+
{ trigger: 'ws' },
|
|
776
|
+
'ws inline apply fallback'
|
|
777
|
+
);
|
|
764
778
|
return;
|
|
765
779
|
}
|
|
766
780
|
|
|
@@ -779,7 +793,12 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
779
793
|
pullResponse: { ok: true, subscriptions: [] },
|
|
780
794
|
});
|
|
781
795
|
|
|
782
|
-
this.refreshOutboxStats().catch(() => {
|
|
796
|
+
this.refreshOutboxStats().catch((error) => {
|
|
797
|
+
console.warn(
|
|
798
|
+
'[SyncEngine] Failed to refresh outbox stats after WS apply:',
|
|
799
|
+
error
|
|
800
|
+
);
|
|
801
|
+
});
|
|
783
802
|
}
|
|
784
803
|
|
|
785
804
|
private timestampCounter = 0;
|
|
@@ -888,7 +907,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
888
907
|
this.retryTimeoutId = setTimeout(() => {
|
|
889
908
|
this.retryTimeoutId = null;
|
|
890
909
|
if (!this.isDestroyed) {
|
|
891
|
-
this.
|
|
910
|
+
this.triggerSyncInBackground(undefined, 'retry timer');
|
|
892
911
|
}
|
|
893
912
|
}, delay);
|
|
894
913
|
}
|
|
@@ -898,13 +917,25 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
898
917
|
this.config.onError?.(error);
|
|
899
918
|
}
|
|
900
919
|
|
|
920
|
+
private triggerSyncInBackground(
|
|
921
|
+
opts?: { trigger?: 'ws' | 'local' | 'poll' },
|
|
922
|
+
reason = 'background'
|
|
923
|
+
): void {
|
|
924
|
+
void this.sync(opts).catch((error) => {
|
|
925
|
+
console.error(
|
|
926
|
+
`[SyncEngine] Unexpected sync failure during ${reason}:`,
|
|
927
|
+
error
|
|
928
|
+
);
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
|
|
901
932
|
private setupPolling(): void {
|
|
902
933
|
this.stopPolling();
|
|
903
934
|
|
|
904
935
|
const interval = this.config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
905
936
|
this.pollerId = setInterval(() => {
|
|
906
937
|
if (!this.state.isSyncing && !this.isDestroyed) {
|
|
907
|
-
this.
|
|
938
|
+
this.triggerSyncInBackground(undefined, 'polling interval');
|
|
908
939
|
}
|
|
909
940
|
}, interval);
|
|
910
941
|
|
|
@@ -966,7 +997,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
966
997
|
this.handleWsDelivery(event.data.changes as SyncChange[], cursor);
|
|
967
998
|
} else {
|
|
968
999
|
// Cursor-only wake-up or no cursor — must HTTP sync
|
|
969
|
-
this.
|
|
1000
|
+
this.triggerSyncInBackground({ trigger: 'ws' }, 'ws cursor wakeup');
|
|
970
1001
|
}
|
|
971
1002
|
}
|
|
972
1003
|
},
|
|
@@ -977,7 +1008,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
977
1008
|
this.hasRealtimeConnectedOnce = true;
|
|
978
1009
|
this.setConnectionState('connected');
|
|
979
1010
|
this.stopFallbackPolling();
|
|
980
|
-
this.
|
|
1011
|
+
this.triggerSyncInBackground(undefined, 'realtime connected state');
|
|
981
1012
|
if (wasConnectedBefore) {
|
|
982
1013
|
this.scheduleRealtimeReconnectCatchupSync();
|
|
983
1014
|
}
|
|
@@ -1022,9 +1053,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1022
1053
|
if (this.isDestroyed || !this.isEnabled()) return;
|
|
1023
1054
|
if (this.state.connectionState !== 'connected') return;
|
|
1024
1055
|
|
|
1025
|
-
this.
|
|
1026
|
-
// Best-effort catch-up sync after reconnect.
|
|
1027
|
-
});
|
|
1056
|
+
this.triggerSyncInBackground(undefined, 'realtime reconnect catchup');
|
|
1028
1057
|
}, REALTIME_RECONNECT_CATCHUP_DELAY_MS);
|
|
1029
1058
|
}
|
|
1030
1059
|
|
|
@@ -1034,7 +1063,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1034
1063
|
const interval = this.config.realtimeFallbackPollMs ?? 30_000;
|
|
1035
1064
|
this.fallbackPollerId = setInterval(() => {
|
|
1036
1065
|
if (!this.state.isSyncing && !this.isDestroyed) {
|
|
1037
|
-
this.
|
|
1066
|
+
this.triggerSyncInBackground(undefined, 'realtime fallback poll');
|
|
1038
1067
|
}
|
|
1039
1068
|
}, interval);
|
|
1040
1069
|
}
|
|
@@ -1087,10 +1116,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1087
1116
|
// Polling mode: restart the poller and trigger a sync immediately.
|
|
1088
1117
|
if (this.state.transportMode === 'polling') {
|
|
1089
1118
|
this.setupPolling();
|
|
1090
|
-
|
|
1091
|
-
this.sync().catch((err) => {
|
|
1092
|
-
console.error('Unexpected error during reconnect sync:', err);
|
|
1093
|
-
});
|
|
1119
|
+
this.triggerSyncInBackground(undefined, 'reconnect');
|
|
1094
1120
|
}
|
|
1095
1121
|
}
|
|
1096
1122
|
|
|
@@ -1247,7 +1273,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1247
1273
|
): void {
|
|
1248
1274
|
this.config.subscriptions = subscriptions;
|
|
1249
1275
|
// Trigger a sync to apply new subscriptions
|
|
1250
|
-
this.
|
|
1276
|
+
this.triggerSyncInBackground(undefined, 'subscription update');
|
|
1251
1277
|
}
|
|
1252
1278
|
|
|
1253
1279
|
/**
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { ScopeDefinition, SyncChange, SyncSnapshot } from '@syncular/core';
|
|
6
|
-
import { normalizeScopes } from '@syncular/core';
|
|
6
|
+
import { isRecord, normalizeScopes } from '@syncular/core';
|
|
7
7
|
import { sql } from 'kysely';
|
|
8
8
|
import type { SyncClientDb } from '../schema';
|
|
9
9
|
import type {
|
|
@@ -13,10 +13,6 @@ import type {
|
|
|
13
13
|
ClientTableHandler,
|
|
14
14
|
} from './types';
|
|
15
15
|
|
|
16
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
17
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
16
|
/**
|
|
21
17
|
* Coerce a value for SQL parameter binding.
|
|
22
18
|
* - PostgreSQL (PGlite) does not implicitly cast booleans to integers,
|
package/src/mutations.ts
CHANGED
|
@@ -19,6 +19,7 @@ import type {
|
|
|
19
19
|
SyncPushResponse,
|
|
20
20
|
SyncTransport,
|
|
21
21
|
} from '@syncular/core';
|
|
22
|
+
import { isRecord, randomId } from '@syncular/core';
|
|
22
23
|
import type { Insertable, Kysely, Transaction, Updateable } from 'kysely';
|
|
23
24
|
import { sql } from 'kysely';
|
|
24
25
|
import { enqueueOutboxCommit } from './outbox';
|
|
@@ -138,21 +139,6 @@ export type MutationsApi<DB, CommitOptions = unknown> = {
|
|
|
138
139
|
[T in KnownTableKey<DB>]: TableMutations<DB, T>;
|
|
139
140
|
};
|
|
140
141
|
|
|
141
|
-
function randomId(): string {
|
|
142
|
-
if (
|
|
143
|
-
typeof crypto !== 'undefined' &&
|
|
144
|
-
typeof crypto.randomUUID === 'function'
|
|
145
|
-
) {
|
|
146
|
-
return crypto.randomUUID();
|
|
147
|
-
}
|
|
148
|
-
// Very small fallback; good enough for tests.
|
|
149
|
-
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
153
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
142
|
function sanitizePayload(
|
|
157
143
|
payload: Record<string, unknown>,
|
|
158
144
|
args: { omit: string[] }
|
package/src/outbox.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { SyncOperation } from '@syncular/core';
|
|
6
|
+
import { isRecord, randomId } from '@syncular/core';
|
|
6
7
|
import type { Kysely } from 'kysely';
|
|
7
8
|
import { sql } from 'kysely';
|
|
8
9
|
import type { OutboxCommitStatus, SyncClientDb } from './schema';
|
|
@@ -22,21 +23,6 @@ export interface OutboxCommit {
|
|
|
22
23
|
schema_version: number;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
function randomId(): string {
|
|
26
|
-
if (
|
|
27
|
-
typeof crypto !== 'undefined' &&
|
|
28
|
-
typeof crypto.randomUUID === 'function'
|
|
29
|
-
) {
|
|
30
|
-
return crypto.randomUUID();
|
|
31
|
-
}
|
|
32
|
-
// Very small fallback; good enough for tests.
|
|
33
|
-
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
37
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
26
|
function isSyncOperation(value: unknown): value is SyncOperation {
|
|
41
27
|
if (!isRecord(value)) return false;
|
|
42
28
|
if (typeof value.table !== 'string') return false;
|