@kubun/store-connector 0.16.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;
@@ -24,15 +41,17 @@ export type PendingAuthRecord = {
24
41
  /** A recovery recipient's DID in a form that can be encrypted to. See the table type. */
25
42
  controllerWrappableDID?: string | null;
26
43
  createdAt: number;
44
+ /** The callback's redirect target, needed to complete the token exchange. */
45
+ redirectURL: string;
46
+ /** The scopes requested at authorize time. */
47
+ scopes: Array<string>;
48
+ expiresAt: number;
27
49
  };
28
50
  export type ConnectorStoreAPI = {
29
51
  getSyncState(connectorName: string, ownerDID: string): Promise<SyncState | null>;
30
52
  setSyncState(connectorName: string, ownerDID: string, state: SyncState): Promise<void>;
31
- updateSyncProgress(connectorName: string, ownerDID: string, progress: {
32
- checkpoint: unknown;
33
- lastSyncedAt: string;
34
- entityCount: number;
35
- error?: string | null;
53
+ updateSyncProgress(connectorName: string, ownerDID: string, recompute: SyncProgressRecompute, options?: {
54
+ maxRetries?: number;
36
55
  }): Promise<void>;
37
56
  deleteSyncState(connectorName: string, ownerDID: string): Promise<void>;
38
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();
@@ -91,7 +139,10 @@ export function createConnectorStore(db, _adapter) {
91
139
  owner_did: record.ownerDID,
92
140
  owner_wrappable_did: record.ownerWrappableDID ?? null,
93
141
  controller_wrappable_did: record.controllerWrappableDID ?? null,
94
- created_at: record.createdAt
142
+ created_at: record.createdAt,
143
+ redirect_url: record.redirectURL,
144
+ scopes: JSON.stringify(record.scopes),
145
+ expires_at: record.expiresAt
95
146
  }).execute();
96
147
  },
97
148
  async consumePendingAuth (state) {
@@ -106,11 +157,23 @@ export function createConnectorStore(db, _adapter) {
106
157
  ownerDID: row.owner_did,
107
158
  ownerWrappableDID: row.owner_wrappable_did,
108
159
  controllerWrappableDID: row.controller_wrappable_did,
109
- createdAt: row.created_at
160
+ createdAt: row.created_at,
161
+ redirectURL: row.redirect_url,
162
+ // ParseJSONResultsPlugin may have already parsed this column, same as
163
+ // `checkpoint` in `rowToSyncState`.
164
+ scopes: typeof row.scopes === 'string' ? JSON.parse(row.scopes) : row.scopes,
165
+ expiresAt: row.expires_at
110
166
  };
111
167
  },
112
168
  async deleteExpiredPendingAuth (cutoffMs) {
113
- await db.deleteFrom('kubun_connector_pending_auth').where('created_at', '<', cutoffMs).execute();
169
+ // Strict `<` matches @kokuin/oauth `completeAuthorization`, which treats a
170
+ // record as expired only when `expiresAt < now`. A record at exactly
171
+ // `expiresAt === cutoffMs` is therefore still consumable and is kept here
172
+ // (the next sweep, once `now > expiresAt`, removes it). This intentionally
173
+ // keeps one more record than the `PendingAuthStore.deleteExpired` port doc
174
+ // ("at or before nowMs") so the sweep and the consume check agree on the
175
+ // expiry boundary.
176
+ await db.deleteFrom('kubun_connector_pending_auth').where('expires_at', '<', cutoffMs).execute();
114
177
  }
115
178
  };
116
179
  }
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();
@@ -19,7 +25,14 @@ export function getConnectorMigrations(ctx) {
19
25
  // callback so the mint can add the controller recovery wrap.
20
26
  .addColumn('controller_wrappable_did', t.text)// ms-epoch (Date.now) overflows int32 on Postgres; bigint holds it and
21
27
  // the adapter's int8 parser reads it back as a JS number.
22
- .addColumn('created_at', t.bigint, (col)=>col.notNull()).execute();
28
+ .addColumn('created_at', t.bigint, (col)=>col.notNull())// The callback's redirect target and the scopes requested at authorize
29
+ // time, both needed to complete the token exchange and to fill in
30
+ // `PendingAuthRecord`'s required fields.
31
+ .addColumn('redirect_url', t.text, (col)=>col.notNull())// JSON-encoded `Array<string>`.
32
+ .addColumn('scopes', t.text, (col)=>col.notNull())// ms-epoch (Date.now) overflows int32 on Postgres; bigint holds it and
33
+ // the adapter's int8 parser reads it back as a JS number. The sweep in
34
+ // `deleteExpiredPendingAuth` keys off this column, not `created_at`.
35
+ .addColumn('expires_at', t.bigint, (col)=>col.notNull()).execute();
23
36
  },
24
37
  async down (db) {
25
38
  await db.schema.dropTable('kubun_connector_pending_auth').ifExists().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 = {
@@ -28,6 +31,10 @@ export type ConnectorPendingAuthTable = {
28
31
  */
29
32
  controller_wrappable_did: string | null;
30
33
  created_at: number;
34
+ redirect_url: string;
35
+ /** JSON-encoded `Array<string>`. See `PendingAuthRecord.scopes`. */
36
+ scopes: string;
37
+ expires_at: number;
31
38
  };
32
39
  export type ConnectorPendingAuth = Selectable<ConnectorPendingAuthTable>;
33
40
  export type ConnectorTables = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubun/store-connector",
3
- "version": "0.16.0",
3
+ "version": "0.17.1",
4
4
  "keywords": [],
5
5
  "license": "see LICENSE.md",
6
6
  "sideEffects": false,
@@ -15,13 +15,13 @@
15
15
  "LICENSE.md"
16
16
  ],
17
17
  "dependencies": {
18
- "@kubun/db": "^0.16.0",
19
- "@kubun/db-adapter": "^0.16.0",
18
+ "@kubun/db": "^0.17.0",
19
+ "@kubun/db-adapter": "^0.17.0",
20
20
  "kysely": "^0.29.5"
21
21
  },
22
22
  "devDependencies": {
23
- "@kubun/db-better-sqlite": "^0.16.0",
24
- "@kubun/db-postgres": "^0.16.0",
23
+ "@kubun/db-better-sqlite": "^0.17.0",
24
+ "@kubun/db-postgres": "^0.17.0",
25
25
  "@testcontainers/postgresql": "^12.1.0"
26
26
  },
27
27
  "publishConfig": {
@@ -30,8 +30,8 @@
30
30
  "scripts": {
31
31
  "build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
32
32
  "build:clean": "del lib",
33
- "build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
34
- "build:types": "tsc --emitDeclarationOnly --skipLibCheck",
33
+ "build:js": "del 'lib/**/*.js' && swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
34
+ "build:types": "del 'lib/**/*.d.ts' 'lib/**/*.d.ts.map' && tsc --emitDeclarationOnly --skipLibCheck",
35
35
  "build:types:ci": "tsc --emitDeclarationOnly --declarationMap false",
36
36
  "test": "pnpm run test:types && pnpm run test:unit",
37
37
  "test:types": "tsc --noEmit -p tsconfig.test.json",