@kubun/store-connector 0.17.1 → 0.18.0
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-types.d.ts +434 -0
- package/lib/api-types.js +2 -0
- package/lib/api.d.ts +4 -60
- package/lib/api.js +118 -173
- package/lib/definition.d.ts +7 -2
- package/lib/definition.js +11 -5
- package/lib/document-sweep.d.ts +8 -0
- package/lib/document-sweep.js +46 -0
- package/lib/entity-trim.d.ts +4 -0
- package/lib/entity-trim.js +36 -0
- package/lib/index.d.ts +6 -3
- package/lib/index.js +4 -1
- package/lib/migrations.js +119 -1
- package/lib/observation-records.d.ts +52 -0
- package/lib/observation-records.js +310 -0
- package/lib/operations.d.ts +4 -0
- package/lib/operations.js +223 -0
- package/lib/pending-auth.d.ts +4 -0
- package/lib/pending-auth.js +69 -0
- package/lib/projection-watches.d.ts +4 -0
- package/lib/projection-watches.js +265 -0
- package/lib/retention.d.ts +4 -0
- package/lib/retention.js +193 -0
- package/lib/serialization.d.ts +63 -0
- package/lib/serialization.js +79 -0
- package/lib/store-context.d.ts +11 -0
- package/lib/store-context.js +1 -0
- package/lib/sweep-state.d.ts +5 -0
- package/lib/sweep-state.js +180 -0
- package/lib/sync-state.d.ts +4 -0
- package/lib/sync-state.js +107 -0
- package/lib/tables.d.ts +127 -0
- package/package.json +13 -7
package/lib/api.js
CHANGED
|
@@ -1,179 +1,124 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
}
|
|
12
|
-
function rowToSyncState(row) {
|
|
13
|
-
const state = {
|
|
14
|
-
checkpoint: decodeJSONColumn(row.checkpoint),
|
|
15
|
-
lastSyncedAt: row.last_synced_at,
|
|
16
|
-
entityCount: row.entity_count,
|
|
17
|
-
status: row.status,
|
|
18
|
-
leaseExpiresAt: row.lease_expires_at,
|
|
19
|
-
recovery: decodeJSONColumn(row.recovery),
|
|
20
|
-
version: row.version
|
|
21
|
-
};
|
|
22
|
-
if (row.incarnation != null) {
|
|
23
|
-
state.incarnation = row.incarnation;
|
|
24
|
-
}
|
|
25
|
-
if (row.error != null) {
|
|
26
|
-
state.error = row.error;
|
|
27
|
-
}
|
|
28
|
-
return state;
|
|
29
|
-
}
|
|
1
|
+
import { AbstractPostgresAdapter } from '@kubun/db-adapter';
|
|
2
|
+
import { createDocumentSweepMethods } from './document-sweep.js';
|
|
3
|
+
import { createEntityTrimMethods } from './entity-trim.js';
|
|
4
|
+
import { createObservationRecordMethods } from './observation-records.js';
|
|
5
|
+
import { createOperationsMethods } from './operations.js';
|
|
6
|
+
import { createPendingAuthMethods } from './pending-auth.js';
|
|
7
|
+
import { createProjectionWatchesMethods } from './projection-watches.js';
|
|
8
|
+
import { createRetentionMethods } from './retention.js';
|
|
9
|
+
import { encodeJSONColumn } from './serialization.js';
|
|
10
|
+
import { createSweepStateMethods } from './sweep-state.js';
|
|
11
|
+
import { createSyncStateMethods } from './sync-state.js';
|
|
30
12
|
// --- Factory function ---
|
|
31
|
-
export function createConnectorStore(db,
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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;
|
|
13
|
+
export function createConnectorStore(db, adapter, deps) {
|
|
14
|
+
// `.forUpdate()` compiles to a `FOR UPDATE` clause via Kysely's default
|
|
15
|
+
// compiler; SQLite has no dedicated override and errors on it (no such
|
|
16
|
+
// clause), so it is applied conditionally, never on SQLite. Postgres row
|
|
17
|
+
// locking is what makes a concurrent `armWatch`/`finalizeOperationAndArmWatch`
|
|
18
|
+
// on the same identity block instead of interleave; SQLite's single
|
|
19
|
+
// connection already serializes the whole transaction with no clause
|
|
20
|
+
// needed.
|
|
21
|
+
const isPostgres = adapter instanceof AbstractPostgresAdapter;
|
|
22
|
+
const { getRandomID } = deps;
|
|
23
|
+
// Declared as a named const (not an inline `return {...}`) so
|
|
24
|
+
// `requestRepull` can delegate to `api.armWatch` below — by the time
|
|
25
|
+
// `requestRepull` is actually invoked, `api` has long since finished
|
|
26
|
+
// initializing (closures capture the binding, not a snapshot), so the
|
|
27
|
+
// forward reference is safe.
|
|
28
|
+
const context = {
|
|
29
|
+
db,
|
|
30
|
+
adapter,
|
|
31
|
+
isPostgres,
|
|
32
|
+
deps,
|
|
33
|
+
getRandomID
|
|
34
|
+
};
|
|
35
|
+
const api = {
|
|
36
|
+
...createObservationRecordMethods(db, adapter),
|
|
37
|
+
...createSyncStateMethods(context),
|
|
38
|
+
...createSweepStateMethods(context),
|
|
39
|
+
...createEntityTrimMethods(context),
|
|
40
|
+
...createDocumentSweepMethods(context),
|
|
41
|
+
...createPendingAuthMethods(context),
|
|
42
|
+
...createOperationsMethods(context),
|
|
43
|
+
async finalizeOperationAndArmWatch (input) {
|
|
44
|
+
const now = Date.now();
|
|
45
|
+
return await db.transaction().execute(async (tx)=>{
|
|
46
|
+
// Conditional UPDATE first: the fenced lease CAS gate. `version` is
|
|
47
|
+
// deliberately NOT bumped here (see `FinalizeOperationInput`'s doc) —
|
|
48
|
+
// only the watch row's own `version` advances below.
|
|
49
|
+
const updated = await tx.updateTable('kubun_connector_operation').set({
|
|
50
|
+
state: 'committed',
|
|
51
|
+
document_id: input.documentID,
|
|
52
|
+
result_entity: encodeJSONColumn(input.resultEntity),
|
|
53
|
+
updated_at: now
|
|
54
|
+
}).where('caller_did', '=', input.key.callerDID).where('operation_id', '=', input.key.operationID).where('state', 'in', [
|
|
55
|
+
'attempted',
|
|
56
|
+
'committed'
|
|
57
|
+
]).where('version', '=', input.version).where('lease_token', '=', input.leaseToken).executeTakeFirst();
|
|
58
|
+
// Lost CAS: the transaction commits with NO watch write — the point
|
|
59
|
+
// is that the upsert below never runs, so a stale-lease finalize
|
|
60
|
+
// leaves the watch row untouched (absent if it never existed).
|
|
61
|
+
if ((updated?.numUpdatedRows ?? 0n) === 0n) {
|
|
62
|
+
return false;
|
|
99
63
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
expires_at: record.expiresAt
|
|
146
|
-
}).execute();
|
|
64
|
+
// One watch per document: a watch the document still has under
|
|
65
|
+
// another provider identity (the same provider id written under a
|
|
66
|
+
// second account, say) is superseded by this one, not kept beside it.
|
|
67
|
+
const identity = input.watchIdentity;
|
|
68
|
+
await tx.deleteFrom('kubun_connector_projection_watch').where('owner_did', '=', identity.ownerDID).where('model', '=', identity.model).where('document_id', '=', input.documentID).where((eb)=>eb.or([
|
|
69
|
+
eb('connector_name', '!=', identity.connectorName),
|
|
70
|
+
eb('account_key', '!=', identity.accountKey),
|
|
71
|
+
eb('source_id', '!=', identity.sourceID)
|
|
72
|
+
])).execute();
|
|
73
|
+
await tx.insertInto('kubun_connector_projection_watch').values({
|
|
74
|
+
owner_did: input.watchIdentity.ownerDID,
|
|
75
|
+
connector_name: input.watchIdentity.connectorName,
|
|
76
|
+
model: input.watchIdentity.model,
|
|
77
|
+
account_key: input.watchIdentity.accountKey,
|
|
78
|
+
source_id: input.watchIdentity.sourceID,
|
|
79
|
+
document_id: input.documentID,
|
|
80
|
+
verified_state_digest: null,
|
|
81
|
+
observed_winner_digest: input.currentWinnerDigest,
|
|
82
|
+
verification_status: 'pending_verification',
|
|
83
|
+
verification_attempts: 0,
|
|
84
|
+
repair_generation: 0,
|
|
85
|
+
next_attempt_at: now,
|
|
86
|
+
version: 0,
|
|
87
|
+
created_at: now,
|
|
88
|
+
updated_at: now
|
|
89
|
+
}).onConflict((oc)=>oc.columns([
|
|
90
|
+
'owner_did',
|
|
91
|
+
'connector_name',
|
|
92
|
+
'model',
|
|
93
|
+
'account_key',
|
|
94
|
+
'source_id'
|
|
95
|
+
]).doUpdateSet((eb)=>({
|
|
96
|
+
repair_generation: eb('kubun_connector_projection_watch.repair_generation', '+', 1),
|
|
97
|
+
verification_attempts: 0,
|
|
98
|
+
verification_status: 'pending_verification',
|
|
99
|
+
observed_winner_digest: input.currentWinnerDigest,
|
|
100
|
+
verified_state_digest: null,
|
|
101
|
+
next_attempt_at: now,
|
|
102
|
+
claimed_until: null,
|
|
103
|
+
defer_reason: null,
|
|
104
|
+
version: eb('kubun_connector_projection_watch.version', '+', 1),
|
|
105
|
+
updated_at: now
|
|
106
|
+
}))).execute();
|
|
107
|
+
return true;
|
|
108
|
+
});
|
|
147
109
|
},
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
ownerWrappableDID: row.owner_wrappable_did,
|
|
159
|
-
controllerWrappableDID: row.controller_wrappable_did,
|
|
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
|
|
166
|
-
};
|
|
110
|
+
...createProjectionWatchesMethods(context),
|
|
111
|
+
async requestRepull (identity, opts) {
|
|
112
|
+
// Thin wrapper: re-arm only. The caller is responsible for scheduling
|
|
113
|
+
// the actual sweep tick that picks this back up via
|
|
114
|
+
// `listDueOperations` — this method's whole job is to make the watch
|
|
115
|
+
// due again (unconditionally opening a new generation, same as any
|
|
116
|
+
// other `'manual'` arm).
|
|
117
|
+
await api.armWatch(identity, 'manual', {
|
|
118
|
+
dueAt: opts.dueAt
|
|
119
|
+
});
|
|
167
120
|
},
|
|
168
|
-
|
|
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();
|
|
177
|
-
}
|
|
121
|
+
...createRetentionMethods(context)
|
|
178
122
|
};
|
|
123
|
+
return api;
|
|
179
124
|
}
|
package/lib/definition.d.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import type { StoreDefinition } from '@kubun/db';
|
|
2
|
-
import type { ConnectorStoreAPI } from './api.js';
|
|
2
|
+
import type { ConnectorStoreAPI, ConnectorStoreDeps } from './api.js';
|
|
3
3
|
import type { ConnectorTables } from './tables.js';
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* The connector store's definition, its API bound to `deps`:
|
|
6
|
+
* `KubunDB.register`/`getStore` call `createAPI(db, adapter)` with exactly two
|
|
7
|
+
* arguments, so the dependencies close over here.
|
|
8
|
+
*/
|
|
9
|
+
export declare function createConnectorStoreDefinition(deps: ConnectorStoreDeps): StoreDefinition<ConnectorTables, ConnectorStoreAPI>;
|
package/lib/definition.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { createConnectorStore } from './api.js';
|
|
2
2
|
import { getConnectorMigrations } from './migrations.js';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
/**
|
|
4
|
+
* The connector store's definition, its API bound to `deps`:
|
|
5
|
+
* `KubunDB.register`/`getStore` call `createAPI(db, adapter)` with exactly two
|
|
6
|
+
* arguments, so the dependencies close over here.
|
|
7
|
+
*/ export function createConnectorStoreDefinition(deps) {
|
|
8
|
+
return {
|
|
9
|
+
name: 'connector',
|
|
10
|
+
migrations: getConnectorMigrations,
|
|
11
|
+
createAPI: (db, adapter)=>createConnectorStore(db, adapter, deps)
|
|
12
|
+
};
|
|
13
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ConnectorStoreAPI } from './api-types.js';
|
|
2
|
+
import type { ConnectorStoreContext } from './store-context.js';
|
|
3
|
+
export declare class ConnectorDocumentSweepError extends Error {
|
|
4
|
+
get code(): 'CONNECTOR_SWEEP_UNFINISHED_OPERATION';
|
|
5
|
+
constructor(documentID: string);
|
|
6
|
+
}
|
|
7
|
+
export type DocumentSweepMethods = Pick<ConnectorStoreAPI, 'hasDocumentConnectorWork' | 'sweepDocumentRows'>;
|
|
8
|
+
export declare function createDocumentSweepMethods({ db }: ConnectorStoreContext): DocumentSweepMethods;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const OBSERVATION_TYPE = 'connector.observation';
|
|
2
|
+
/** States an operation can still leave: its row is the idempotency record of a provider write. */ const UNFINISHED = [
|
|
3
|
+
'attempted',
|
|
4
|
+
'reconcile_only'
|
|
5
|
+
];
|
|
6
|
+
const FINISHED = [
|
|
7
|
+
'committed',
|
|
8
|
+
'dead'
|
|
9
|
+
];
|
|
10
|
+
export class ConnectorDocumentSweepError extends Error {
|
|
11
|
+
get code() {
|
|
12
|
+
return 'CONNECTOR_SWEEP_UNFINISHED_OPERATION';
|
|
13
|
+
}
|
|
14
|
+
constructor(documentID){
|
|
15
|
+
super(`Document ${documentID} has an unfinished connector operation`);
|
|
16
|
+
this.name = 'ConnectorDocumentSweepError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
// Plain statements on `db`, never `db.transaction()`: both run inside the caller's sweep
|
|
20
|
+
// transaction, where a nested transaction is refused.
|
|
21
|
+
export function createDocumentSweepMethods({ db }) {
|
|
22
|
+
const unfinishedOperation = async (documentID)=>{
|
|
23
|
+
const row = await db.selectFrom('kubun_connector_operation').select('operation_id').where('document_id', '=', documentID).where('state', 'in', UNFINISHED).limit(1).executeTakeFirst();
|
|
24
|
+
return row !== undefined;
|
|
25
|
+
};
|
|
26
|
+
const anyRow = async (table, documentID)=>{
|
|
27
|
+
const row = await db.selectFrom(table).select('document_id').where('document_id', '=', documentID).limit(1).executeTakeFirst();
|
|
28
|
+
return row !== undefined;
|
|
29
|
+
};
|
|
30
|
+
return {
|
|
31
|
+
async hasDocumentConnectorWork (documentID) {
|
|
32
|
+
return await anyRow('kubun_connector_projection_watch', documentID) || await anyRow('kubun_connector_sweep_candidate', documentID) || await anyRow('kubun_connector_entity_trim', documentID) || await unfinishedOperation(documentID);
|
|
33
|
+
},
|
|
34
|
+
async sweepDocumentRows (documentID) {
|
|
35
|
+
// Deleting it would let a replayed request write to the provider again.
|
|
36
|
+
if (await unfinishedOperation(documentID)) throw new ConnectorDocumentSweepError(documentID);
|
|
37
|
+
// Every copy, whatever its owner or signer: the document ID names one document.
|
|
38
|
+
await db.deleteFrom('kubun_connector_observation_records').where('type', '=', OBSERVATION_TYPE).where('id', '=', documentID).execute();
|
|
39
|
+
await db.deleteFrom('kubun_connector_observation_repair').where('document_id', '=', documentID).execute();
|
|
40
|
+
await db.deleteFrom('kubun_connector_entity_trim').where('document_id', '=', documentID).execute();
|
|
41
|
+
await db.deleteFrom('kubun_connector_projection_watch').where('document_id', '=', documentID).execute();
|
|
42
|
+
await db.deleteFrom('kubun_connector_sweep_candidate').where('document_id', '=', documentID).execute();
|
|
43
|
+
await db.deleteFrom('kubun_connector_operation').where('document_id', '=', documentID).where('state', 'in', FINISHED).execute();
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ConnectorStoreAPI } from './api-types.js';
|
|
2
|
+
import type { ConnectorStoreContext } from './store-context.js';
|
|
3
|
+
export type EntityTrimMethods = Pick<ConnectorStoreAPI, 'getEntityTrim' | 'setEntityTrim' | 'clearEntityTrim'>;
|
|
4
|
+
export declare function createEntityTrimMethods(context: ConnectorStoreContext): EntityTrimMethods;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { decodeJSONColumn } from './serialization.js';
|
|
2
|
+
export function createEntityTrimMethods(context) {
|
|
3
|
+
const { db } = context;
|
|
4
|
+
const methods = {
|
|
5
|
+
// --- Entity trim records ---
|
|
6
|
+
async getEntityTrim (documentID) {
|
|
7
|
+
const row = await db.selectFrom('kubun_connector_entity_trim').selectAll().where('document_id', '=', documentID).executeTakeFirst();
|
|
8
|
+
if (row == null) return null;
|
|
9
|
+
return {
|
|
10
|
+
documentID: row.document_id,
|
|
11
|
+
ownerDID: row.owner_did,
|
|
12
|
+
connectorName: row.connector_name,
|
|
13
|
+
trimmedFields: decodeJSONColumn(row.trimmed_fields),
|
|
14
|
+
updatedAt: row.updated_at
|
|
15
|
+
};
|
|
16
|
+
},
|
|
17
|
+
async setEntityTrim (input) {
|
|
18
|
+
const trimmedFields = JSON.stringify(input.trimmedFields);
|
|
19
|
+
const updatedAt = Date.now();
|
|
20
|
+
await db.insertInto('kubun_connector_entity_trim').values({
|
|
21
|
+
document_id: input.documentID,
|
|
22
|
+
owner_did: input.ownerDID,
|
|
23
|
+
connector_name: input.connectorName,
|
|
24
|
+
trimmed_fields: trimmedFields,
|
|
25
|
+
updated_at: updatedAt
|
|
26
|
+
}).onConflict((oc)=>oc.column('document_id').doUpdateSet({
|
|
27
|
+
trimmed_fields: trimmedFields,
|
|
28
|
+
updated_at: updatedAt
|
|
29
|
+
})).execute();
|
|
30
|
+
},
|
|
31
|
+
async clearEntityTrim (documentID) {
|
|
32
|
+
await db.deleteFrom('kubun_connector_entity_trim').where('document_id', '=', documentID).execute();
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
return methods;
|
|
36
|
+
}
|
package/lib/index.d.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import type { StoreProvider } from '@kubun/db';
|
|
2
2
|
import type { ConnectorStoreAPI } from './api.js';
|
|
3
|
-
export type { ConnectorStoreAPI, PendingAuthRecord, SyncProgressCASCurrent, SyncProgressCASResult, SyncProgressRecompute, SyncState, SyncStateEntry, SyncStatus, } from './api.js';
|
|
3
|
+
export type { AcquireLeaseParams, ArmWatchOptions, ArmWatchOutcome, ArmWatchReason, ClaimOperationInput, ConnectorOperation, ConnectorOperationOp, ConnectorOperationState, ConnectorStoreAPI, ConnectorStoreDeps, DeferReason, EntityTrimInput, EntityTrimRecord, FinalizeOperationInput, PendingAuthRecord, ProjectionWatch, ProjectionWatchIdentity, ProjectionWatchVerificationStatus, ReadWinner, ReadWinners, RecordOperationInput, RecordVerificationInput, RecordVerificationOutcome, ReleaseLeaseParams, RenewLeaseParams, SweepApplyFailureInput, SweepCandidate, SweepCandidateInput, SweepCandidateKey, SweepKey, SweepOutcomeInput, SweepReceipt, SweepReceiptKey, SyncProgressCASCurrent, SyncProgressCASResult, SyncProgressRecompute, SyncState, SyncStateEntry, SyncStatus, TrackedOperationFilter, UpdateSyncProgressParams, } from './api.js';
|
|
4
4
|
export { createConnectorStore } from './api.js';
|
|
5
|
-
export {
|
|
5
|
+
export { createConnectorStoreDefinition } from './definition.js';
|
|
6
|
+
export { ConnectorDocumentSweepError } from './document-sweep.js';
|
|
7
|
+
export { ConnectorObservationRecordError, ConnectorObservationRecordTypeError, connectorObservationRecordAdapter, type ObservationRepair, type ObservationRepairWrite, type ObservationRepairWritePage, observationProviderOfRecords, type WrittenObservationRepair, } from './observation-records.js';
|
|
8
|
+
export { MAX_SWEEP_APPLY_ATTEMPTS } from './sweep-state.js';
|
|
6
9
|
export declare const CONNECTOR_STORE: "connector";
|
|
7
10
|
export declare function getConnectorStore(provider: StoreProvider): Promise<ConnectorStoreAPI>;
|
|
8
|
-
export type { ConnectorSyncState, ConnectorSyncStateTable, ConnectorTables, } from './tables.js';
|
|
11
|
+
export type { ConnectorEntityTrim, ConnectorEntityTrimTable, ConnectorSyncState, ConnectorSyncStateTable, ConnectorTables, } from './tables.js';
|
package/lib/index.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
export { createConnectorStore } from './api.js';
|
|
2
|
-
export {
|
|
2
|
+
export { createConnectorStoreDefinition } from './definition.js';
|
|
3
|
+
export { ConnectorDocumentSweepError } from './document-sweep.js';
|
|
4
|
+
export { ConnectorObservationRecordError, ConnectorObservationRecordTypeError, connectorObservationRecordAdapter, observationProviderOfRecords } from './observation-records.js';
|
|
5
|
+
export { MAX_SWEEP_APPLY_ATTEMPTS } from './sweep-state.js';
|
|
3
6
|
export const CONNECTOR_STORE = 'connector';
|
|
4
7
|
export function getConnectorStore(provider) {
|
|
5
8
|
return provider.getStore(CONNECTOR_STORE);
|
package/lib/migrations.js
CHANGED
|
@@ -2,6 +2,30 @@ export function getConnectorMigrations(ctx) {
|
|
|
2
2
|
const t = ctx.types;
|
|
3
3
|
const init = {
|
|
4
4
|
async up (db) {
|
|
5
|
+
await db.schema.createTable('kubun_connector_sweep_receipt').ifNotExists().addColumn('owner_did', t.text, (col)=>col.notNull()).addColumn('connector_name', t.text, (col)=>col.notNull()).addColumn('partition', t.text, (col)=>col.notNull()).addColumn('provenance', t.text, (col)=>col.notNull()).addColumn('sequence', t.bigint, (col)=>col.notNull()).addColumn('model_id', t.text, (col)=>col.notNull()).addColumn('scope', t.text, (col)=>col.notNull()).addColumn('keep', t.text, (col)=>col.notNull()).addColumn('source_fetch_hlc', t.text, (col)=>col.notNull()).addColumn('predicate', t.text).addColumn('kind', t.text, (col)=>col.notNull()).addColumn('complete', 'integer', (col)=>col.notNull()).addColumn('created_at', t.bigint, (col)=>col.notNull()).addColumn('committed_at', t.bigint).addPrimaryKeyConstraint('kubun_connector_sweep_receipt_pkey', [
|
|
6
|
+
'owner_did',
|
|
7
|
+
'connector_name',
|
|
8
|
+
'partition',
|
|
9
|
+
'provenance'
|
|
10
|
+
]).execute();
|
|
11
|
+
await db.schema.createTable('kubun_connector_sweep_candidate').ifNotExists().addColumn('owner_did', t.text, (col)=>col.notNull()).addColumn('connector_name', t.text, (col)=>col.notNull()).addColumn('partition', t.text, (col)=>col.notNull()).addColumn('provenance', t.text, (col)=>col.notNull()).addColumn('document_id', t.text, (col)=>col.notNull()).addColumn('source_id', t.text, (col)=>col.notNull()).addColumn('model_id', t.text, (col)=>col.notNull()).addColumn('state', t.text, (col)=>col.notNull()).addColumn('attempts', 'integer', (col)=>col.notNull()).addColumn('apply_failures', 'integer', (col)=>col.notNull().defaultTo(0)).addColumn('last_error', t.text).addColumn('next_attempt_at', t.bigint, (col)=>col.notNull().defaultTo(0)).addColumn('updated_at', t.bigint, (col)=>col.notNull()).addPrimaryKeyConstraint('kubun_connector_sweep_candidate_pkey', [
|
|
12
|
+
'owner_did',
|
|
13
|
+
'connector_name',
|
|
14
|
+
'partition',
|
|
15
|
+
'provenance',
|
|
16
|
+
'document_id'
|
|
17
|
+
]).execute();
|
|
18
|
+
await db.schema.createIndex('kubun_connector_sweep_candidate_due_idx').ifNotExists().on('kubun_connector_sweep_candidate').columns([
|
|
19
|
+
'owner_did',
|
|
20
|
+
'connector_name',
|
|
21
|
+
'state',
|
|
22
|
+
'next_attempt_at'
|
|
23
|
+
]).execute();
|
|
24
|
+
await db.schema.createTable('kubun_connector_sweep_checkpoint').ifNotExists().addColumn('owner_did', t.text, (col)=>col.notNull()).addColumn('connector_name', t.text, (col)=>col.notNull()).addColumn('partition', t.text, (col)=>col.notNull()).addColumn('provenance', t.text, (col)=>col.notNull()).addColumn('receipt_sequence', t.bigint, (col)=>col.notNull()).addColumn('updated_at', t.bigint, (col)=>col.notNull()).addPrimaryKeyConstraint('kubun_connector_sweep_checkpoint_pkey', [
|
|
25
|
+
'owner_did',
|
|
26
|
+
'connector_name',
|
|
27
|
+
'partition'
|
|
28
|
+
]).execute();
|
|
5
29
|
// Connector sync state
|
|
6
30
|
await db.schema.createTable('kubun_connector_sync_state').ifNotExists().addColumn('connector_name', t.text, (col)=>col.notNull()).addColumn('owner_did', t.text, (col)=>col.notNull()).addColumn('checkpoint', t.text).addColumn('last_synced_at', t.text, (col)=>col.notNull()).addColumn('entity_count', 'integer', (col)=>col.notNull().defaultTo(0)).addColumn('status', t.text, (col)=>col.notNull()).addColumn('error', t.text)// Lease expiry for atomic sync-claim coordination. ms-epoch (Date.now)
|
|
7
31
|
// overflows int32 on Postgres; bigint holds it and the adapter's int8
|
|
@@ -29,12 +53,106 @@ export function getConnectorMigrations(ctx) {
|
|
|
29
53
|
// time, both needed to complete the token exchange and to fill in
|
|
30
54
|
// `PendingAuthRecord`'s required fields.
|
|
31
55
|
.addColumn('redirect_url', t.text, (col)=>col.notNull())// JSON-encoded `Array<string>`.
|
|
32
|
-
.addColumn('scopes', t.text, (col)=>col.notNull())//
|
|
56
|
+
.addColumn('scopes', t.text, (col)=>col.notNull())// 1 when start-auth requested only the missing scopes, else 0.
|
|
57
|
+
.addColumn('is_incremental', 'integer', (col)=>col.notNull())// ms-epoch (Date.now) overflows int32 on Postgres; bigint holds it and
|
|
33
58
|
// the adapter's int8 parser reads it back as a JS number. The sweep in
|
|
34
59
|
// `deleteExpiredPendingAuth` keys off this column, not `created_at`.
|
|
35
60
|
.addColumn('expires_at', t.bigint, (col)=>col.notNull()).execute();
|
|
61
|
+
// Connector write-back operation envelope: one row per attempted create/
|
|
62
|
+
// update, keyed by the caller's idempotency key so a replayed request
|
|
63
|
+
// (crash recovery, retry) converges on the same row instead of double-
|
|
64
|
+
// writing to the provider.
|
|
65
|
+
await db.schema.createTable('kubun_connector_operation').ifNotExists().addColumn('caller_did', t.text, (col)=>col.notNull()).addColumn('operation_id', t.text, (col)=>col.notNull())// An opaque random key assigned at claim time, the operation's public
|
|
66
|
+
// identity (its GraphQL node id). Kept apart from the caller-supplied
|
|
67
|
+
// `operation_id`, which is an idempotency key and says nothing about
|
|
68
|
+
// who may look the operation up.
|
|
69
|
+
.addColumn('node_key', t.text, (col)=>col.notNull()).addColumn('owner_did', t.text, (col)=>col.notNull()).addColumn('connector_name', t.text, (col)=>col.notNull()).addColumn('model', t.text, (col)=>col.notNull()).addColumn('op', t.text, (col)=>col.notNull()).addColumn('account_key', t.text, (col)=>col.notNull()).addColumn('target', t.text, (col)=>col.notNull()).addColumn('source_id', t.text).addColumn('deterministic_event_id', t.text, (col)=>col.notNull()).addColumn('payload_hash', t.text, (col)=>col.notNull()).addColumn('reissue_intent', t.text, (col)=>col.notNull()).addColumn('provider_etag', t.text).addColumn('document_id', t.text).addColumn('result_entity', t.text).addColumn('state', t.text, (col)=>col.notNull()).addColumn('attempts', 'integer', (col)=>col.notNull().defaultTo(0))// ms-epoch (Date.now) overflows int32 on Postgres; bigint holds it and
|
|
70
|
+
// the adapter's int8 parser reads it back as a JS number.
|
|
71
|
+
.addColumn('next_attempt_at', t.bigint)// Why `next_attempt_at` was pushed out without spending an attempt, when
|
|
72
|
+
// that reason can lapse on its own: 'offline' is made due again as soon
|
|
73
|
+
// as the device reconnects, 'auth' as soon as the owner authorizes.
|
|
74
|
+
// Null for a backoff or any other deferral.
|
|
75
|
+
.addColumn('defer_reason', t.text)// The run that started a provider write in the current attempt (a
|
|
76
|
+
// per-run marker), cleared when a new attempt begins: a re-run of the
|
|
77
|
+
// same attempt cannot claim no request was ever sent.
|
|
78
|
+
.addColumn('send_marker', t.text).addColumn('last_kind', t.text).addColumn('last_http_status', 'integer').addColumn('version', 'integer', (col)=>col.notNull().defaultTo(0)).addColumn('incarnation', t.text).addColumn('lease_token', t.text)// ms-epoch (Date.now) overflows int32 on Postgres; bigint holds it and
|
|
79
|
+
// the adapter's int8 parser reads it back as a JS number.
|
|
80
|
+
.addColumn('lease_expires_at', t.bigint)// ms-epoch (Date.now) overflows int32 on Postgres; bigint holds it and
|
|
81
|
+
// the adapter's int8 parser reads it back as a JS number.
|
|
82
|
+
.addColumn('created_at', t.bigint, (col)=>col.notNull()).addColumn('updated_at', t.bigint, (col)=>col.notNull()).addPrimaryKeyConstraint('kubun_connector_operation_pkey', [
|
|
83
|
+
'caller_did',
|
|
84
|
+
'operation_id'
|
|
85
|
+
]).execute();
|
|
86
|
+
await db.schema.createIndex('uq_connector_operation_node_key').ifNotExists().on('kubun_connector_operation').column('node_key').unique().execute();
|
|
87
|
+
// The operations of a document (its write state), read by document id.
|
|
88
|
+
await db.schema.createIndex('idx_connector_operation_document').ifNotExists().on('kubun_connector_operation').column('document_id').execute();
|
|
89
|
+
// The operations an owner may see (`listTrackedOperations({ visibleTo })`
|
|
90
|
+
// matches the caller through the primary key, the owner through this).
|
|
91
|
+
await db.schema.createIndex('idx_connector_operation_owner').ifNotExists().on('kubun_connector_operation').column('owner_did').execute();
|
|
92
|
+
// The retention cleanup's candidates: finished operations by last update.
|
|
93
|
+
await db.schema.createIndex('idx_connector_operation_state_updated').ifNotExists().on('kubun_connector_operation').columns([
|
|
94
|
+
'state',
|
|
95
|
+
'updated_at'
|
|
96
|
+
]).execute();
|
|
97
|
+
// Connector projection verify/repair watch: one row per ingested
|
|
98
|
+
// provider document being tracked for eventual verification against the
|
|
99
|
+
// local converged state, with a unique ingress index so the same
|
|
100
|
+
// (owner, model, document) is only ever watched under one source.
|
|
101
|
+
await db.schema.createTable('kubun_connector_projection_watch').ifNotExists().addColumn('owner_did', t.text, (col)=>col.notNull()).addColumn('connector_name', t.text, (col)=>col.notNull()).addColumn('model', t.text, (col)=>col.notNull()).addColumn('account_key', t.text, (col)=>col.notNull()).addColumn('source_id', t.text, (col)=>col.notNull()).addColumn('document_id', t.text, (col)=>col.notNull()).addColumn('verified_state_digest', t.text).addColumn('observed_winner_digest', t.text).addColumn('verification_status', t.text, (col)=>col.notNull()).addColumn('verification_attempts', 'integer', (col)=>col.notNull().defaultTo(0)).addColumn('repair_generation', 'integer', (col)=>col.notNull().defaultTo(0))// ms-epoch (Date.now) overflows int32 on Postgres; bigint holds it and
|
|
102
|
+
// the adapter's int8 parser reads it back as a JS number.
|
|
103
|
+
.addColumn('next_attempt_at', t.bigint)// Until when a verifier holds the current attempt (`claimWatchAttempt`);
|
|
104
|
+
// kept apart from `next_attempt_at`, which an ingress coalesce moves.
|
|
105
|
+
.addColumn('claimed_until', t.bigint)// As on the operation table: 'offline' when the pending attempt was
|
|
106
|
+
// deferred only for being offline.
|
|
107
|
+
.addColumn('defer_reason', t.text).addColumn('version', 'integer', (col)=>col.notNull().defaultTo(0))// ms-epoch (Date.now) overflows int32 on Postgres; bigint holds it and
|
|
108
|
+
// the adapter's int8 parser reads it back as a JS number.
|
|
109
|
+
.addColumn('created_at', t.bigint, (col)=>col.notNull()).addColumn('updated_at', t.bigint, (col)=>col.notNull()).addPrimaryKeyConstraint('kubun_connector_projection_watch_pkey', [
|
|
110
|
+
'owner_did',
|
|
111
|
+
'connector_name',
|
|
112
|
+
'model',
|
|
113
|
+
'account_key',
|
|
114
|
+
'source_id'
|
|
115
|
+
]).execute();
|
|
116
|
+
// Ingress index: within one owner+model, a given provider document may
|
|
117
|
+
// only be watched under a single source. A write-back finalized for the
|
|
118
|
+
// document under another source supersedes the earlier row.
|
|
119
|
+
await db.schema.createIndex('uq_connector_watch_ingress').ifNotExists().on('kubun_connector_projection_watch').columns([
|
|
120
|
+
'owner_did',
|
|
121
|
+
'model',
|
|
122
|
+
'document_id'
|
|
123
|
+
]).unique().execute();
|
|
124
|
+
// Document lookup: an applied write names only its document id
|
|
125
|
+
// (`getWatchByDocument`), which the ingress index cannot serve alone.
|
|
126
|
+
await db.schema.createIndex('idx_connector_watch_document').ifNotExists().on('kubun_connector_projection_watch').column('document_id').execute();
|
|
127
|
+
await db.schema.createTable('kubun_connector_observation_records').ifNotExists().addColumn('type', t.text, (col)=>col.notNull()).addColumn('owner', t.text, (col)=>col.notNull()).addColumn('id', t.text, (col)=>col.notNull()).addColumn('op_hash', t.text, (col)=>col.notNull()).addColumn('hlc', t.text, (col)=>col.notNull()).addColumn('op_jwt', t.text, (col)=>col.notNull()).addColumn('payload', t.binary).addPrimaryKeyConstraint('kubun_connector_observation_records_pkey', [
|
|
128
|
+
'type',
|
|
129
|
+
'owner',
|
|
130
|
+
'id',
|
|
131
|
+
'op_hash'
|
|
132
|
+
]).execute();
|
|
133
|
+
await db.schema.createTable('kubun_connector_observation_repair').ifNotExists().addColumn('document_id', t.text, (col)=>col.notNull().primaryKey()).addColumn('owner', t.text, (col)=>col.notNull()).addColumn('fetch_hlc', t.text, (col)=>col.notNull()).addColumn('decision_hlc', t.text, (col)=>col.notNull()).addColumn('present', 'integer', (col)=>col.notNull()).addColumn('value_digest', t.text)// When the obligation was last written, so a reader can take only the
|
|
134
|
+
// ones written since it last looked.
|
|
135
|
+
.addColumn('written_at', t.bigint, (col)=>col.notNull()).execute();
|
|
136
|
+
await db.schema.createIndex('idx_connector_observation_repair_written').ifNotExists().on('kubun_connector_observation_repair').columns([
|
|
137
|
+
'written_at',
|
|
138
|
+
'document_id'
|
|
139
|
+
]).execute();
|
|
140
|
+
// The source values fitted to a model's caps when a document was last
|
|
141
|
+
// written, keyed by that document. Replaced on every write that trims,
|
|
142
|
+
// removed by one that does not.
|
|
143
|
+
await db.schema.createTable('kubun_connector_entity_trim').ifNotExists().addColumn('document_id', t.text, (col)=>col.notNull().primaryKey()).addColumn('owner_did', t.text, (col)=>col.notNull()).addColumn('connector_name', t.text, (col)=>col.notNull()).addColumn('trimmed_fields', t.text, (col)=>col.notNull())// ms-epoch (Date.now) overflows int32 on Postgres; bigint holds it and
|
|
144
|
+
// the adapter's int8 parser reads it back as a JS number.
|
|
145
|
+
.addColumn('updated_at', t.bigint, (col)=>col.notNull()).execute();
|
|
36
146
|
},
|
|
37
147
|
async down (db) {
|
|
148
|
+
await db.schema.dropTable('kubun_connector_sweep_checkpoint').ifExists().execute();
|
|
149
|
+
await db.schema.dropTable('kubun_connector_sweep_candidate').ifExists().execute();
|
|
150
|
+
await db.schema.dropTable('kubun_connector_sweep_receipt').ifExists().execute();
|
|
151
|
+
await db.schema.dropTable('kubun_connector_observation_repair').ifExists().execute();
|
|
152
|
+
await db.schema.dropTable('kubun_connector_observation_records').ifExists().execute();
|
|
153
|
+
await db.schema.dropTable('kubun_connector_entity_trim').ifExists().execute();
|
|
154
|
+
await db.schema.dropTable('kubun_connector_projection_watch').ifExists().execute();
|
|
155
|
+
await db.schema.dropTable('kubun_connector_operation').ifExists().execute();
|
|
38
156
|
await db.schema.dropTable('kubun_connector_pending_auth').ifExists().execute();
|
|
39
157
|
await db.schema.dropTable('kubun_connector_sync_state').ifExists().execute();
|
|
40
158
|
}
|