@syncular/client 0.0.3-3 → 0.0.3-7
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/client.d.ts +69 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +104 -0
- package/dist/client.js.map +1 -1
- package/dist/engine/SyncEngine.d.ts +57 -1
- package/dist/engine/SyncEngine.d.ts.map +1 -1
- package/dist/engine/SyncEngine.js +707 -9
- package/dist/engine/SyncEngine.js.map +1 -1
- package/dist/engine/types.d.ts +115 -2
- package/dist/engine/types.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/subscription-state.d.ts +46 -0
- package/dist/subscription-state.d.ts.map +1 -0
- package/dist/subscription-state.js +185 -0
- package/dist/subscription-state.js.map +1 -0
- package/dist/utils/id.d.ts +9 -0
- package/dist/utils/id.d.ts.map +1 -1
- package/dist/utils/id.js +27 -0
- package/dist/utils/id.js.map +1 -1
- package/package.json +3 -3
- package/src/client.test.ts +39 -0
- package/src/client.ts +158 -0
- package/src/engine/SyncEngine.test.ts +39 -0
- package/src/engine/SyncEngine.ts +906 -21
- package/src/engine/types.ts +150 -1
- package/src/index.ts +1 -0
- package/src/subscription-state.ts +259 -0
- package/src/utils/id.ts +35 -0
package/src/engine/types.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type { Kysely } from 'kysely';
|
|
|
15
15
|
import type { ClientTableRegistry } from '../handlers/registry';
|
|
16
16
|
import type { SyncClientPlugin } from '../plugins/types';
|
|
17
17
|
import type { SyncClientDb } from '../schema';
|
|
18
|
+
import type { SubscriptionState } from '../subscription-state';
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* Connection state for the sync engine
|
|
@@ -30,6 +31,8 @@ export type SyncConnectionState =
|
|
|
30
31
|
*/
|
|
31
32
|
export type SyncTransportMode = 'polling' | 'realtime';
|
|
32
33
|
|
|
34
|
+
export type TransportFallbackReason = 'network' | 'auth' | 'server' | 'manual';
|
|
35
|
+
|
|
33
36
|
/**
|
|
34
37
|
* Sync engine state
|
|
35
38
|
*/
|
|
@@ -54,18 +57,78 @@ export interface SyncEngineState {
|
|
|
54
57
|
isRetrying: boolean;
|
|
55
58
|
}
|
|
56
59
|
|
|
60
|
+
export interface TransportHealth {
|
|
61
|
+
mode: 'realtime' | 'polling' | 'disconnected';
|
|
62
|
+
connected: boolean;
|
|
63
|
+
lastSuccessfulPollAt: number | null;
|
|
64
|
+
lastRealtimeMessageAt: number | null;
|
|
65
|
+
fallbackReason: TransportFallbackReason | null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type SubscriptionProgressPhase =
|
|
69
|
+
| 'idle'
|
|
70
|
+
| 'bootstrapping'
|
|
71
|
+
| 'catching_up'
|
|
72
|
+
| 'live'
|
|
73
|
+
| 'error';
|
|
74
|
+
|
|
75
|
+
export type SyncChannelPhase =
|
|
76
|
+
| 'idle'
|
|
77
|
+
| 'starting'
|
|
78
|
+
| 'bootstrapping'
|
|
79
|
+
| 'catching_up'
|
|
80
|
+
| 'live'
|
|
81
|
+
| 'error';
|
|
82
|
+
|
|
83
|
+
export interface SubscriptionProgress {
|
|
84
|
+
stateId: string;
|
|
85
|
+
id: string;
|
|
86
|
+
table?: string;
|
|
87
|
+
phase: SubscriptionProgressPhase;
|
|
88
|
+
progressPercent: number;
|
|
89
|
+
rowsProcessed?: number;
|
|
90
|
+
rowsTotal?: number;
|
|
91
|
+
tablesProcessed?: number;
|
|
92
|
+
tablesTotal?: number;
|
|
93
|
+
startedAt?: number;
|
|
94
|
+
completedAt?: number;
|
|
95
|
+
lastErrorCode?: string;
|
|
96
|
+
lastErrorMessage?: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface SyncProgress {
|
|
100
|
+
channelPhase: SyncChannelPhase;
|
|
101
|
+
progressPercent: number;
|
|
102
|
+
subscriptions: SubscriptionProgress[];
|
|
103
|
+
}
|
|
104
|
+
|
|
57
105
|
/**
|
|
58
106
|
* Sync error with context
|
|
59
107
|
*/
|
|
60
108
|
export interface SyncError {
|
|
61
109
|
/** Error code */
|
|
62
|
-
code:
|
|
110
|
+
code:
|
|
111
|
+
| 'NETWORK_ERROR'
|
|
112
|
+
| 'AUTH_FAILED'
|
|
113
|
+
| 'SNAPSHOT_CHUNK_NOT_FOUND'
|
|
114
|
+
| 'MIGRATION_FAILED'
|
|
115
|
+
| 'CONFLICT'
|
|
116
|
+
| 'SYNC_ERROR'
|
|
117
|
+
| 'UNKNOWN';
|
|
63
118
|
/** Error message */
|
|
64
119
|
message: string;
|
|
65
120
|
/** Original error if available */
|
|
66
121
|
cause?: Error;
|
|
67
122
|
/** Timestamp when error occurred */
|
|
68
123
|
timestamp: number;
|
|
124
|
+
/** Whether retrying this error is expected to succeed */
|
|
125
|
+
retryable: boolean;
|
|
126
|
+
/** HTTP status code when available */
|
|
127
|
+
httpStatus?: number;
|
|
128
|
+
/** Related subscription id when available */
|
|
129
|
+
subscriptionId?: string;
|
|
130
|
+
/** Related state id when available */
|
|
131
|
+
stateId?: string;
|
|
69
132
|
}
|
|
70
133
|
|
|
71
134
|
/**
|
|
@@ -75,7 +138,11 @@ export type SyncEventType =
|
|
|
75
138
|
| 'state:change'
|
|
76
139
|
| 'sync:start'
|
|
77
140
|
| 'sync:complete'
|
|
141
|
+
| 'sync:live'
|
|
78
142
|
| 'sync:error'
|
|
143
|
+
| 'bootstrap:start'
|
|
144
|
+
| 'bootstrap:progress'
|
|
145
|
+
| 'bootstrap:complete'
|
|
79
146
|
| 'connection:change'
|
|
80
147
|
| 'outbox:change'
|
|
81
148
|
| 'data:change'
|
|
@@ -103,7 +170,25 @@ export interface SyncEventPayloads {
|
|
|
103
170
|
pullRounds: number;
|
|
104
171
|
pullResponse: SyncPullResponse;
|
|
105
172
|
};
|
|
173
|
+
'sync:live': { timestamp: number };
|
|
106
174
|
'sync:error': SyncError;
|
|
175
|
+
'bootstrap:start': {
|
|
176
|
+
timestamp: number;
|
|
177
|
+
stateId: string;
|
|
178
|
+
subscriptionId: string;
|
|
179
|
+
};
|
|
180
|
+
'bootstrap:progress': {
|
|
181
|
+
timestamp: number;
|
|
182
|
+
stateId: string;
|
|
183
|
+
subscriptionId: string;
|
|
184
|
+
progress: SubscriptionProgress;
|
|
185
|
+
};
|
|
186
|
+
'bootstrap:complete': {
|
|
187
|
+
timestamp: number;
|
|
188
|
+
stateId: string;
|
|
189
|
+
subscriptionId: string;
|
|
190
|
+
durationMs: number;
|
|
191
|
+
};
|
|
107
192
|
'connection:change': {
|
|
108
193
|
previous: SyncConnectionState;
|
|
109
194
|
current: SyncConnectionState;
|
|
@@ -268,3 +353,67 @@ export interface OutboxStats {
|
|
|
268
353
|
acked: number;
|
|
269
354
|
total: number;
|
|
270
355
|
}
|
|
356
|
+
|
|
357
|
+
export type SyncResetScope = 'state' | 'subscription' | 'all';
|
|
358
|
+
|
|
359
|
+
export interface SyncResetOptions {
|
|
360
|
+
scope: SyncResetScope;
|
|
361
|
+
stateId?: string;
|
|
362
|
+
subscriptionIds?: string[];
|
|
363
|
+
clearOutbox?: boolean;
|
|
364
|
+
clearConflicts?: boolean;
|
|
365
|
+
clearSyncedTables?: boolean;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export interface SyncResetResult {
|
|
369
|
+
deletedSubscriptionStates: number;
|
|
370
|
+
deletedOutboxCommits: number;
|
|
371
|
+
deletedConflicts: number;
|
|
372
|
+
clearedTables: string[];
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export interface SyncRepairOptions {
|
|
376
|
+
mode: 'rebootstrap-missing-chunks';
|
|
377
|
+
stateId?: string;
|
|
378
|
+
subscriptionIds?: string[];
|
|
379
|
+
clearOutbox?: boolean;
|
|
380
|
+
clearConflicts?: boolean;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export interface SyncAwaitPhaseOptions {
|
|
384
|
+
timeoutMs?: number;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export interface SyncAwaitBootstrapOptions {
|
|
388
|
+
timeoutMs?: number;
|
|
389
|
+
stateId?: string;
|
|
390
|
+
subscriptionId?: string;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export interface SyncDiagnostics {
|
|
394
|
+
timestamp: number;
|
|
395
|
+
state: SyncEngineState;
|
|
396
|
+
transport: TransportHealth;
|
|
397
|
+
progress: SyncProgress;
|
|
398
|
+
outbox: OutboxStats;
|
|
399
|
+
conflictCount: number;
|
|
400
|
+
subscriptions: SubscriptionState[];
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export interface SyncInspectorEvent {
|
|
404
|
+
id: number;
|
|
405
|
+
event: SyncEventType;
|
|
406
|
+
timestamp: number;
|
|
407
|
+
payload: Record<string, unknown>;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export interface SyncInspectorOptions {
|
|
411
|
+
eventLimit?: number;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export interface SyncInspectorSnapshot {
|
|
415
|
+
version: 1;
|
|
416
|
+
generatedAt: number;
|
|
417
|
+
diagnostics: Record<string, unknown>;
|
|
418
|
+
recentEvents: SyncInspectorEvent[];
|
|
419
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @syncular/client - Subscription state helpers
|
|
3
|
+
*
|
|
4
|
+
* Stable accessors for sync subscription metadata.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ScopeValues, SyncBootstrapState } from '@syncular/core';
|
|
8
|
+
import { isRecord } from '@syncular/core';
|
|
9
|
+
import type { Kysely } from 'kysely';
|
|
10
|
+
import { sql } from 'kysely';
|
|
11
|
+
import type {
|
|
12
|
+
SubscriptionStatus,
|
|
13
|
+
SyncClientDb,
|
|
14
|
+
SyncSubscriptionStateTable,
|
|
15
|
+
} from './schema';
|
|
16
|
+
|
|
17
|
+
export const DEFAULT_SYNC_STATE_ID = 'default';
|
|
18
|
+
|
|
19
|
+
export interface SubscriptionState {
|
|
20
|
+
stateId: string;
|
|
21
|
+
subscriptionId: string;
|
|
22
|
+
table: string;
|
|
23
|
+
scopes: ScopeValues;
|
|
24
|
+
params: Record<string, unknown>;
|
|
25
|
+
cursor: number;
|
|
26
|
+
bootstrapState: SyncBootstrapState | null;
|
|
27
|
+
status: SubscriptionStatus;
|
|
28
|
+
createdAt: number;
|
|
29
|
+
updatedAt: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ListSubscriptionStatesOptions {
|
|
33
|
+
stateId?: string;
|
|
34
|
+
table?: string;
|
|
35
|
+
status?: SubscriptionStatus;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface GetSubscriptionStateOptions {
|
|
39
|
+
stateId?: string;
|
|
40
|
+
subscriptionId: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface UpsertSubscriptionStateInput {
|
|
44
|
+
stateId?: string;
|
|
45
|
+
subscriptionId: string;
|
|
46
|
+
table: string;
|
|
47
|
+
scopes: ScopeValues;
|
|
48
|
+
params?: Record<string, unknown>;
|
|
49
|
+
cursor: number;
|
|
50
|
+
bootstrapState?: SyncBootstrapState | null;
|
|
51
|
+
status?: SubscriptionStatus;
|
|
52
|
+
nowMs?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isScopeValues(value: unknown): value is ScopeValues {
|
|
56
|
+
if (!isRecord(value)) return false;
|
|
57
|
+
|
|
58
|
+
for (const entry of Object.values(value)) {
|
|
59
|
+
if (typeof entry === 'string') continue;
|
|
60
|
+
if (Array.isArray(entry) && entry.every((v) => typeof v === 'string')) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function parseBootstrapState(
|
|
70
|
+
value: string | object | null | undefined
|
|
71
|
+
): SyncBootstrapState | null {
|
|
72
|
+
if (!value) return null;
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const parsed: unknown =
|
|
76
|
+
typeof value === 'string' ? JSON.parse(value) : value;
|
|
77
|
+
|
|
78
|
+
if (!isRecord(parsed)) return null;
|
|
79
|
+
if (typeof parsed.asOfCommitSeq !== 'number') return null;
|
|
80
|
+
if (!Array.isArray(parsed.tables)) return null;
|
|
81
|
+
if (!parsed.tables.every((table) => typeof table === 'string')) return null;
|
|
82
|
+
if (typeof parsed.tableIndex !== 'number') return null;
|
|
83
|
+
if (parsed.rowCursor !== null && typeof parsed.rowCursor !== 'string') {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
asOfCommitSeq: parsed.asOfCommitSeq,
|
|
89
|
+
tables: parsed.tables,
|
|
90
|
+
tableIndex: parsed.tableIndex,
|
|
91
|
+
rowCursor: parsed.rowCursor,
|
|
92
|
+
};
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function parseScopes(value: string): ScopeValues {
|
|
99
|
+
try {
|
|
100
|
+
const parsed: unknown = JSON.parse(value);
|
|
101
|
+
return isScopeValues(parsed) ? parsed : {};
|
|
102
|
+
} catch {
|
|
103
|
+
return {};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function parseParams(value: string): Record<string, unknown> {
|
|
108
|
+
try {
|
|
109
|
+
const parsed: unknown = JSON.parse(value);
|
|
110
|
+
return isRecord(parsed) ? parsed : {};
|
|
111
|
+
} catch {
|
|
112
|
+
return {};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function mapSubscriptionState(
|
|
117
|
+
row: SyncSubscriptionStateTable
|
|
118
|
+
): SubscriptionState {
|
|
119
|
+
return {
|
|
120
|
+
stateId: row.state_id,
|
|
121
|
+
subscriptionId: row.subscription_id,
|
|
122
|
+
table: row.table,
|
|
123
|
+
scopes: parseScopes(row.scopes_json),
|
|
124
|
+
params: parseParams(row.params_json),
|
|
125
|
+
cursor: row.cursor,
|
|
126
|
+
bootstrapState: parseBootstrapState(row.bootstrap_state_json),
|
|
127
|
+
status: row.status,
|
|
128
|
+
createdAt: row.created_at,
|
|
129
|
+
updatedAt: row.updated_at,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function listSubscriptionStates<DB extends SyncClientDb>(
|
|
134
|
+
db: Kysely<DB>,
|
|
135
|
+
options: ListSubscriptionStatesOptions = {}
|
|
136
|
+
): Promise<SubscriptionState[]> {
|
|
137
|
+
const filters: Array<ReturnType<typeof sql>> = [];
|
|
138
|
+
if (options.stateId) {
|
|
139
|
+
filters.push(sql`${sql.ref('state_id')} = ${sql.val(options.stateId)}`);
|
|
140
|
+
}
|
|
141
|
+
if (options.table) {
|
|
142
|
+
filters.push(sql`${sql.ref('table')} = ${sql.val(options.table)}`);
|
|
143
|
+
}
|
|
144
|
+
if (options.status) {
|
|
145
|
+
filters.push(sql`${sql.ref('status')} = ${sql.val(options.status)}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const whereClause =
|
|
149
|
+
filters.length > 0 ? sql`where ${sql.join(filters, sql` and `)}` : sql``;
|
|
150
|
+
|
|
151
|
+
const rows = await sql<SyncSubscriptionStateTable>`
|
|
152
|
+
select
|
|
153
|
+
${sql.ref('state_id')},
|
|
154
|
+
${sql.ref('subscription_id')},
|
|
155
|
+
${sql.ref('table')},
|
|
156
|
+
${sql.ref('scopes_json')},
|
|
157
|
+
${sql.ref('params_json')},
|
|
158
|
+
${sql.ref('cursor')},
|
|
159
|
+
${sql.ref('bootstrap_state_json')},
|
|
160
|
+
${sql.ref('status')},
|
|
161
|
+
${sql.ref('created_at')},
|
|
162
|
+
${sql.ref('updated_at')}
|
|
163
|
+
from ${sql.table('sync_subscription_state')}
|
|
164
|
+
${whereClause}
|
|
165
|
+
order by ${sql.ref('state_id')} asc, ${sql.ref('subscription_id')} asc
|
|
166
|
+
`.execute(db);
|
|
167
|
+
|
|
168
|
+
return rows.rows.map((row) => mapSubscriptionState(row));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function getSubscriptionState<DB extends SyncClientDb>(
|
|
172
|
+
db: Kysely<DB>,
|
|
173
|
+
options: GetSubscriptionStateOptions
|
|
174
|
+
): Promise<SubscriptionState | null> {
|
|
175
|
+
const stateId = options.stateId ?? DEFAULT_SYNC_STATE_ID;
|
|
176
|
+
|
|
177
|
+
const rows = await sql<SyncSubscriptionStateTable>`
|
|
178
|
+
select
|
|
179
|
+
${sql.ref('state_id')},
|
|
180
|
+
${sql.ref('subscription_id')},
|
|
181
|
+
${sql.ref('table')},
|
|
182
|
+
${sql.ref('scopes_json')},
|
|
183
|
+
${sql.ref('params_json')},
|
|
184
|
+
${sql.ref('cursor')},
|
|
185
|
+
${sql.ref('bootstrap_state_json')},
|
|
186
|
+
${sql.ref('status')},
|
|
187
|
+
${sql.ref('created_at')},
|
|
188
|
+
${sql.ref('updated_at')}
|
|
189
|
+
from ${sql.table('sync_subscription_state')}
|
|
190
|
+
where
|
|
191
|
+
${sql.ref('state_id')} = ${sql.val(stateId)}
|
|
192
|
+
and ${sql.ref('subscription_id')} = ${sql.val(options.subscriptionId)}
|
|
193
|
+
limit 1
|
|
194
|
+
`.execute(db);
|
|
195
|
+
|
|
196
|
+
const row = rows.rows[0];
|
|
197
|
+
return row ? mapSubscriptionState(row) : null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function upsertSubscriptionState<DB extends SyncClientDb>(
|
|
201
|
+
db: Kysely<DB>,
|
|
202
|
+
input: UpsertSubscriptionStateInput
|
|
203
|
+
): Promise<SubscriptionState> {
|
|
204
|
+
const now = input.nowMs ?? Date.now();
|
|
205
|
+
const stateId = input.stateId ?? DEFAULT_SYNC_STATE_ID;
|
|
206
|
+
|
|
207
|
+
const bootstrapStateJson =
|
|
208
|
+
input.bootstrapState === null || input.bootstrapState === undefined
|
|
209
|
+
? null
|
|
210
|
+
: JSON.stringify(input.bootstrapState);
|
|
211
|
+
|
|
212
|
+
await sql`
|
|
213
|
+
insert into ${sql.table('sync_subscription_state')} (
|
|
214
|
+
${sql.ref('state_id')},
|
|
215
|
+
${sql.ref('subscription_id')},
|
|
216
|
+
${sql.ref('table')},
|
|
217
|
+
${sql.ref('scopes_json')},
|
|
218
|
+
${sql.ref('params_json')},
|
|
219
|
+
${sql.ref('cursor')},
|
|
220
|
+
${sql.ref('bootstrap_state_json')},
|
|
221
|
+
${sql.ref('status')},
|
|
222
|
+
${sql.ref('created_at')},
|
|
223
|
+
${sql.ref('updated_at')}
|
|
224
|
+
) values (
|
|
225
|
+
${sql.val(stateId)},
|
|
226
|
+
${sql.val(input.subscriptionId)},
|
|
227
|
+
${sql.val(input.table)},
|
|
228
|
+
${sql.val(JSON.stringify(input.scopes ?? {}))},
|
|
229
|
+
${sql.val(JSON.stringify(input.params ?? {}))},
|
|
230
|
+
${sql.val(input.cursor)},
|
|
231
|
+
${sql.val(bootstrapStateJson)},
|
|
232
|
+
${sql.val(input.status ?? 'active')},
|
|
233
|
+
${sql.val(now)},
|
|
234
|
+
${sql.val(now)}
|
|
235
|
+
)
|
|
236
|
+
on conflict (${sql.join([sql.ref('state_id'), sql.ref('subscription_id')])})
|
|
237
|
+
do update set
|
|
238
|
+
${sql.ref('table')} = ${sql.val(input.table)},
|
|
239
|
+
${sql.ref('scopes_json')} = ${sql.val(JSON.stringify(input.scopes ?? {}))},
|
|
240
|
+
${sql.ref('params_json')} = ${sql.val(JSON.stringify(input.params ?? {}))},
|
|
241
|
+
${sql.ref('cursor')} = ${sql.val(input.cursor)},
|
|
242
|
+
${sql.ref('bootstrap_state_json')} = ${sql.val(bootstrapStateJson)},
|
|
243
|
+
${sql.ref('status')} = ${sql.val(input.status ?? 'active')},
|
|
244
|
+
${sql.ref('updated_at')} = ${sql.val(now)}
|
|
245
|
+
`.execute(db);
|
|
246
|
+
|
|
247
|
+
const next = await getSubscriptionState(db, {
|
|
248
|
+
stateId,
|
|
249
|
+
subscriptionId: input.subscriptionId,
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
if (!next) {
|
|
253
|
+
throw new Error(
|
|
254
|
+
`[subscription-state] Failed to load upserted state for "${input.subscriptionId}"`
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return next;
|
|
259
|
+
}
|
package/src/utils/id.ts
CHANGED
|
@@ -18,3 +18,38 @@ export function randomUUID(): string {
|
|
|
18
18
|
return v.toString(16);
|
|
19
19
|
});
|
|
20
20
|
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Build a stable state id from meaningful segments.
|
|
24
|
+
* Empty/undefined segments are ignored.
|
|
25
|
+
*/
|
|
26
|
+
export function buildStateId(
|
|
27
|
+
...segments: Array<string | null | undefined>
|
|
28
|
+
): string {
|
|
29
|
+
const normalized = segments
|
|
30
|
+
.map((segment) => segment?.trim())
|
|
31
|
+
.filter((segment): segment is string => !!segment && segment.length > 0);
|
|
32
|
+
|
|
33
|
+
if (normalized.length === 0) return 'default';
|
|
34
|
+
return normalized.join(':');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Create a deterministic fingerprint string for scope values.
|
|
39
|
+
*/
|
|
40
|
+
export function createScopeFingerprint(
|
|
41
|
+
scopes: Record<string, string | string[]>
|
|
42
|
+
): string {
|
|
43
|
+
const entries = Object.entries(scopes)
|
|
44
|
+
.map(([key, value]) => {
|
|
45
|
+
const encodedValues = (Array.isArray(value) ? [...value] : [value])
|
|
46
|
+
.map((item) => item.trim())
|
|
47
|
+
.filter((item) => item.length > 0)
|
|
48
|
+
.sort();
|
|
49
|
+
|
|
50
|
+
return `${key}:${encodedValues.join('|')}`;
|
|
51
|
+
})
|
|
52
|
+
.sort();
|
|
53
|
+
|
|
54
|
+
return entries.join(';');
|
|
55
|
+
}
|