@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 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, progress: {
37
- checkpoint: unknown;
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: state.checkpoint != null ? JSON.stringify(state.checkpoint) : null,
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: state.checkpoint != null ? JSON.stringify(state.checkpoint) : null,
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, progress) {
53
- const checkpoint = progress.checkpoint != null ? JSON.stringify(progress.checkpoint) : null;
54
- const error = progress.error ?? null;
55
- await db.insertInto('kubun_connector_sync_state').values({
56
- connector_name: connectorName,
57
- owner_did: ownerDID,
58
- checkpoint,
59
- last_synced_at: progress.lastSyncedAt,
60
- entity_count: progress.entityCount,
61
- status: 'idle',
62
- error,
63
- lease_expires_at: null
64
- }).onConflict((oc)=>oc.columns([
65
- 'connector_name',
66
- 'owner_did'
67
- ]).doUpdateSet({
68
- checkpoint,
69
- last_synced_at: progress.lastSyncedAt,
70
- entity_count: progress.entityCount,
71
- error
72
- })).execute();
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).addPrimaryKeyConstraint('kubun_connector_sync_state_pkey', [
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 = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubun/store-connector",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "keywords": [],
5
5
  "license": "see LICENSE.md",
6
6
  "sideEffects": false,