@kubun/store-connector 0.17.0 → 0.17.1
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/lib/api.d.ts +19 -5
- package/lib/api.js +81 -33
- package/lib/index.d.ts +1 -1
- package/lib/migrations.js +7 -1
- package/lib/tables.d.ts +3 -0
- package/package.json +1 -1
package/lib/api.d.ts
CHANGED
|
@@ -9,11 +9,28 @@ export type SyncState = {
|
|
|
9
9
|
status: SyncStatus;
|
|
10
10
|
error?: string;
|
|
11
11
|
leaseExpiresAt?: number | null;
|
|
12
|
+
recovery?: unknown;
|
|
13
|
+
version?: number;
|
|
14
|
+
incarnation?: string;
|
|
12
15
|
};
|
|
13
16
|
export type SyncStateEntry = SyncState & {
|
|
14
17
|
connectorName: string;
|
|
15
18
|
ownerDID: string;
|
|
16
19
|
};
|
|
20
|
+
export type SyncProgressCASCurrent = {
|
|
21
|
+
checkpoint: unknown;
|
|
22
|
+
recovery: unknown;
|
|
23
|
+
version: number;
|
|
24
|
+
incarnation: string;
|
|
25
|
+
};
|
|
26
|
+
export type SyncProgressCASResult = {
|
|
27
|
+
checkpoint: unknown;
|
|
28
|
+
recovery: unknown;
|
|
29
|
+
lastSyncedAt: string;
|
|
30
|
+
entityCount: number;
|
|
31
|
+
error?: string | null;
|
|
32
|
+
};
|
|
33
|
+
export type SyncProgressRecompute = (current: SyncProgressCASCurrent | null) => SyncProgressCASResult | Promise<SyncProgressCASResult>;
|
|
17
34
|
export type PendingAuthRecord = {
|
|
18
35
|
state: string;
|
|
19
36
|
codeVerifier: string;
|
|
@@ -33,11 +50,8 @@ export type PendingAuthRecord = {
|
|
|
33
50
|
export type ConnectorStoreAPI = {
|
|
34
51
|
getSyncState(connectorName: string, ownerDID: string): Promise<SyncState | null>;
|
|
35
52
|
setSyncState(connectorName: string, ownerDID: string, state: SyncState): Promise<void>;
|
|
36
|
-
updateSyncProgress(connectorName: string, ownerDID: string,
|
|
37
|
-
|
|
38
|
-
lastSyncedAt: string;
|
|
39
|
-
entityCount: number;
|
|
40
|
-
error?: string | null;
|
|
53
|
+
updateSyncProgress(connectorName: string, ownerDID: string, recompute: SyncProgressRecompute, options?: {
|
|
54
|
+
maxRetries?: number;
|
|
41
55
|
}): Promise<void>;
|
|
42
56
|
deleteSyncState(connectorName: string, ownerDID: string): Promise<void>;
|
|
43
57
|
listSyncStatesForConnector(connectorName: string): Promise<Array<SyncStateEntry>>;
|
package/lib/api.js
CHANGED
|
@@ -1,17 +1,27 @@
|
|
|
1
1
|
// --- Serialization helpers ---
|
|
2
|
+
// `checkpoint` and `recovery` are JSON columns: ParseJSONResultsPlugin may have
|
|
3
|
+
// already parsed them (Postgres) or left them as strings (SQLite), so handle
|
|
4
|
+
// both. Mirrors the `scopes` handling in `consumePendingAuth`.
|
|
5
|
+
function decodeJSONColumn(value) {
|
|
6
|
+
if (value == null) return null;
|
|
7
|
+
return typeof value === 'string' ? JSON.parse(value) : value;
|
|
8
|
+
}
|
|
9
|
+
function encodeJSONColumn(value) {
|
|
10
|
+
return value != null ? JSON.stringify(value) : null;
|
|
11
|
+
}
|
|
2
12
|
function rowToSyncState(row) {
|
|
3
|
-
// ParseJSONResultsPlugin may have already parsed the checkpoint value
|
|
4
|
-
let checkpoint = null;
|
|
5
|
-
if (row.checkpoint != null) {
|
|
6
|
-
checkpoint = typeof row.checkpoint === 'string' ? JSON.parse(row.checkpoint) : row.checkpoint;
|
|
7
|
-
}
|
|
8
13
|
const state = {
|
|
9
|
-
checkpoint,
|
|
14
|
+
checkpoint: decodeJSONColumn(row.checkpoint),
|
|
10
15
|
lastSyncedAt: row.last_synced_at,
|
|
11
16
|
entityCount: row.entity_count,
|
|
12
17
|
status: row.status,
|
|
13
|
-
leaseExpiresAt: row.lease_expires_at
|
|
18
|
+
leaseExpiresAt: row.lease_expires_at,
|
|
19
|
+
recovery: decodeJSONColumn(row.recovery),
|
|
20
|
+
version: row.version
|
|
14
21
|
};
|
|
22
|
+
if (row.incarnation != null) {
|
|
23
|
+
state.incarnation = row.incarnation;
|
|
24
|
+
}
|
|
15
25
|
if (row.error != null) {
|
|
16
26
|
state.error = row.error;
|
|
17
27
|
}
|
|
@@ -28,48 +38,86 @@ export function createConnectorStore(db, _adapter) {
|
|
|
28
38
|
},
|
|
29
39
|
async setSyncState (connectorName, ownerDID, state) {
|
|
30
40
|
// `lease_expires_at` is dead: a fresh INSERT sets it null and a conflicting
|
|
31
|
-
// update leaves it untouched.
|
|
41
|
+
// update leaves it untouched. A fresh INSERT seeds version 0 and a random
|
|
42
|
+
// incarnation; a conflicting update leaves both untouched (this path is not
|
|
43
|
+
// the CAS write — it does not bump version or churn the incarnation).
|
|
44
|
+
const checkpoint = encodeJSONColumn(state.checkpoint);
|
|
45
|
+
const recovery = encodeJSONColumn(state.recovery);
|
|
32
46
|
await db.insertInto('kubun_connector_sync_state').values({
|
|
33
47
|
connector_name: connectorName,
|
|
34
48
|
owner_did: ownerDID,
|
|
35
|
-
checkpoint
|
|
49
|
+
checkpoint,
|
|
36
50
|
last_synced_at: state.lastSyncedAt,
|
|
37
51
|
entity_count: state.entityCount,
|
|
38
52
|
status: state.status,
|
|
39
53
|
error: state.error ?? null,
|
|
40
|
-
lease_expires_at: null
|
|
54
|
+
lease_expires_at: null,
|
|
55
|
+
recovery,
|
|
56
|
+
version: 0,
|
|
57
|
+
incarnation: globalThis.crypto.randomUUID()
|
|
41
58
|
}).onConflict((oc)=>oc.columns([
|
|
42
59
|
'connector_name',
|
|
43
60
|
'owner_did'
|
|
44
61
|
]).doUpdateSet({
|
|
45
|
-
checkpoint
|
|
62
|
+
checkpoint,
|
|
46
63
|
last_synced_at: state.lastSyncedAt,
|
|
47
64
|
entity_count: state.entityCount,
|
|
48
65
|
status: state.status,
|
|
49
|
-
error: state.error ?? null
|
|
66
|
+
error: state.error ?? null,
|
|
67
|
+
recovery
|
|
50
68
|
})).execute();
|
|
51
69
|
},
|
|
52
|
-
async updateSyncProgress (connectorName, ownerDID,
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
owner_did
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
async updateSyncProgress (connectorName, ownerDID, recompute, options) {
|
|
71
|
+
// The caller passes the authoritative MAX_CAS_RETRIES; 5 is a defensive
|
|
72
|
+
// fallback for direct callers/tests.
|
|
73
|
+
const maxRetries = options?.maxRetries ?? 5;
|
|
74
|
+
for(let attempt = 0; attempt <= maxRetries; attempt++){
|
|
75
|
+
const row = await db.selectFrom('kubun_connector_sync_state').selectAll().where('connector_name', '=', connectorName).where('owner_did', '=', ownerDID).executeTakeFirst();
|
|
76
|
+
if (row == null) {
|
|
77
|
+
// No row yet: INSERT ... ON CONFLICT DO NOTHING so a lost insert race
|
|
78
|
+
// (a concurrent writer created the row first) falls through to reload
|
|
79
|
+
// and take the update path.
|
|
80
|
+
const result = await recompute(null);
|
|
81
|
+
const inserted = await db.insertInto('kubun_connector_sync_state').values({
|
|
82
|
+
connector_name: connectorName,
|
|
83
|
+
owner_did: ownerDID,
|
|
84
|
+
checkpoint: encodeJSONColumn(result.checkpoint),
|
|
85
|
+
last_synced_at: result.lastSyncedAt,
|
|
86
|
+
entity_count: result.entityCount,
|
|
87
|
+
status: 'idle',
|
|
88
|
+
error: result.error ?? null,
|
|
89
|
+
lease_expires_at: null,
|
|
90
|
+
recovery: encodeJSONColumn(result.recovery),
|
|
91
|
+
version: 0,
|
|
92
|
+
incarnation: globalThis.crypto.randomUUID()
|
|
93
|
+
}).onConflict((oc)=>oc.columns([
|
|
94
|
+
'connector_name',
|
|
95
|
+
'owner_did'
|
|
96
|
+
]).doNothing()).executeTakeFirst();
|
|
97
|
+
if ((inserted?.numInsertedOrUpdatedRows ?? 0n) > 0n) return;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const result = await recompute({
|
|
101
|
+
checkpoint: decodeJSONColumn(row.checkpoint),
|
|
102
|
+
recovery: decodeJSONColumn(row.recovery),
|
|
103
|
+
version: row.version,
|
|
104
|
+
incarnation: row.incarnation ?? ''
|
|
105
|
+
});
|
|
106
|
+
// Single conditional UPDATE guarded on the read version AND incarnation.
|
|
107
|
+
// `status` is deliberately omitted (workflow owns it). Zero rows means a
|
|
108
|
+
// concurrent writer won, or the row was delete+recreated (incarnation
|
|
109
|
+
// differs) ⇒ reload and recompute.
|
|
110
|
+
const updated = await db.updateTable('kubun_connector_sync_state').set({
|
|
111
|
+
checkpoint: encodeJSONColumn(result.checkpoint),
|
|
112
|
+
last_synced_at: result.lastSyncedAt,
|
|
113
|
+
entity_count: result.entityCount,
|
|
114
|
+
error: result.error ?? null,
|
|
115
|
+
recovery: encodeJSONColumn(result.recovery),
|
|
116
|
+
version: row.version + 1
|
|
117
|
+
}).where('connector_name', '=', connectorName).where('owner_did', '=', ownerDID).where('version', '=', row.version).where('incarnation', '=', row.incarnation).executeTakeFirst();
|
|
118
|
+
if ((updated?.numUpdatedRows ?? 0n) > 0n) return;
|
|
119
|
+
}
|
|
120
|
+
throw new Error(`updateSyncProgress: CAS failed after ${maxRetries + 1} attempts for ${connectorName}/${ownerDID}`);
|
|
73
121
|
},
|
|
74
122
|
async deleteSyncState (connectorName, ownerDID) {
|
|
75
123
|
await db.deleteFrom('kubun_connector_sync_state').where('connector_name', '=', connectorName).where('owner_did', '=', ownerDID).execute();
|
package/lib/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { StoreProvider } from '@kubun/db';
|
|
2
2
|
import type { ConnectorStoreAPI } from './api.js';
|
|
3
|
-
export type { ConnectorStoreAPI, PendingAuthRecord, SyncState, SyncStateEntry, SyncStatus, } from './api.js';
|
|
3
|
+
export type { ConnectorStoreAPI, PendingAuthRecord, SyncProgressCASCurrent, SyncProgressCASResult, SyncProgressRecompute, SyncState, SyncStateEntry, SyncStatus, } from './api.js';
|
|
4
4
|
export { createConnectorStore } from './api.js';
|
|
5
5
|
export { connectorStoreDefinition } from './definition.js';
|
|
6
6
|
export declare const CONNECTOR_STORE: "connector";
|
package/lib/migrations.js
CHANGED
|
@@ -7,7 +7,13 @@ export function getConnectorMigrations(ctx) {
|
|
|
7
7
|
// overflows int32 on Postgres; bigint holds it and the adapter's int8
|
|
8
8
|
// parser reads it back as a JS number. Nullable: a row with no active
|
|
9
9
|
// lease leaves this null.
|
|
10
|
-
.addColumn('lease_expires_at', t.bigint)
|
|
10
|
+
.addColumn('lease_expires_at', t.bigint)// Durable recovery buffer, JSON-encoded (see @kubun/connector `Recovery`).
|
|
11
|
+
// Nullable: null means an empty buffer.
|
|
12
|
+
.addColumn('recovery', t.text)// Optimistic-concurrency version, bumped on every atomic checkpoint+
|
|
13
|
+
// recovery write. Starts at 0.
|
|
14
|
+
.addColumn('version', 'integer', (col)=>col.notNull().defaultTo(0))// Random id set on insert, part of the CAS predicate so a paused writer
|
|
15
|
+
// cannot rebase stale recovery onto a delete+recreated row (ABA).
|
|
16
|
+
.addColumn('incarnation', t.text).addPrimaryKeyConstraint('kubun_connector_sync_state_pkey', [
|
|
11
17
|
'connector_name',
|
|
12
18
|
'owner_did'
|
|
13
19
|
]).execute();
|
package/lib/tables.d.ts
CHANGED
|
@@ -8,6 +8,9 @@ export type ConnectorSyncStateTable = {
|
|
|
8
8
|
status: string;
|
|
9
9
|
error: string | null;
|
|
10
10
|
lease_expires_at: number | null;
|
|
11
|
+
recovery: string | null;
|
|
12
|
+
version: number;
|
|
13
|
+
incarnation: string | null;
|
|
11
14
|
};
|
|
12
15
|
export type ConnectorSyncState = Selectable<ConnectorSyncStateTable>;
|
|
13
16
|
export type ConnectorPendingAuthTable = {
|