@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 +24 -5
- package/lib/api.js +99 -36
- package/lib/index.d.ts +1 -1
- package/lib/migrations.js +15 -2
- package/lib/tables.d.ts +7 -0
- package/package.json +7 -7
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,
|
|
32
|
-
|
|
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
|
|
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();
|
|
@@ -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
|
-
|
|
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)
|
|
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())
|
|
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.
|
|
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.
|
|
19
|
-
"@kubun/db-adapter": "^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.
|
|
24
|
-
"@kubun/db-postgres": "^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",
|