@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.
@@ -0,0 +1,52 @@
1
+ import type { MethodRegistry } from '@kokuin/token';
2
+ import type { StoreProvider } from '@kubun/db';
3
+ import { type Adapter } from '@kubun/db-adapter';
4
+ import { type RecordDecision, type RecordKey, type RecordStoreAdapter, type SignedRecord } from '@kubun/records';
5
+ import { type Kysely } from 'kysely';
6
+ import type { ConnectorTables } from './tables.js';
7
+ export declare function observationProviderOfRecords(records: RecordStoreAdapter): StoreProvider | null;
8
+ export declare class ConnectorObservationRecordTypeError extends Error {
9
+ constructor(type: string);
10
+ }
11
+ export declare class ConnectorObservationRecordError extends Error {
12
+ constructor(message: string);
13
+ }
14
+ export type ObservationRepair = {
15
+ documentID: string;
16
+ owner: string;
17
+ fetchHLC: string;
18
+ decisionHLC: string;
19
+ present: boolean;
20
+ valueDigest: string | null;
21
+ };
22
+ /** Where a reader of obligation writes stands: the last write it took. */
23
+ export type ObservationRepairWrite = {
24
+ writtenAt: number;
25
+ documentID: string;
26
+ };
27
+ /** An obligation with the time it was last written. */
28
+ export type WrittenObservationRepair = ObservationRepair & {
29
+ writtenAt: number;
30
+ };
31
+ export type ObservationRepairWritePage = {
32
+ after: ObservationRepairWrite | null;
33
+ limit: number;
34
+ };
35
+ export type ObservationRecordMethods = {
36
+ getObservationRecordDecision(key: RecordKey, methods?: MethodRegistry): Promise<RecordDecision | null>;
37
+ /** The decisions of many keys read together; a key with no valid copy is absent. */
38
+ getObservationRecordDecisions(keys: ReadonlyArray<RecordKey>, methods?: MethodRegistry): Promise<Array<RecordDecision>>;
39
+ listObservationRecordDecisions(types: ReadonlyArray<string>, methods?: MethodRegistry): Promise<Array<RecordDecision>>;
40
+ putObservationRecordCopy(record: SignedRecord, methods?: MethodRegistry): Promise<void>;
41
+ pruneObservationRecordCopy(key: RecordKey, signerDID: string, methods?: MethodRegistry): Promise<void>;
42
+ lockObservationRecordKeys(keys: ReadonlyArray<string>): Promise<void>;
43
+ getObservationRepair(documentID: string): Promise<ObservationRepair | null>;
44
+ /** The obligations of many documents read together; a document with none is absent. */
45
+ getObservationRepairs(documentIDs: ReadonlyArray<string>): Promise<Map<string, ObservationRepair>>;
46
+ /** One page of repair obligations in write order, written after `after`. */
47
+ listObservationRepairsWrittenSince(page: ObservationRepairWritePage): Promise<Array<WrittenObservationRepair>>;
48
+ putObservationRepair(repair: ObservationRepair): Promise<void>;
49
+ clearObservationRepair(documentID: string): Promise<void>;
50
+ };
51
+ export declare function createObservationRecordMethods(db: Kysely<ConnectorTables>, adapter: Adapter): ObservationRecordMethods;
52
+ export declare function connectorObservationRecordAdapter(provider: StoreProvider, methods?: MethodRegistry): Promise<RecordStoreAdapter>;
@@ -0,0 +1,310 @@
1
+ import { AbstractPostgresAdapter, chunked } from '@kubun/db-adapter';
2
+ import { compareRecordDecisionHLC, parseCanonicalHLC, recordClaimDigest, recordDecisionFromVerifiedCopies, recordKeyText, recordPayloadMatches, recordSignerKey, verifySignedRecord } from '@kubun/records';
3
+ import { sql } from 'kysely';
4
+ const TYPE = 'connector.observation';
5
+ const providers = new WeakMap();
6
+ export function observationProviderOfRecords(records) {
7
+ return providers.get(records) ?? null;
8
+ }
9
+ export class ConnectorObservationRecordTypeError extends Error {
10
+ constructor(type){
11
+ super(`Unsupported connector observation record type: ${type}`);
12
+ this.name = 'ConnectorObservationRecordTypeError';
13
+ }
14
+ }
15
+ export class ConnectorObservationRecordError extends Error {
16
+ constructor(message){
17
+ super(message);
18
+ this.name = 'ConnectorObservationRecordError';
19
+ }
20
+ }
21
+ function requireType(type) {
22
+ if (type !== TYPE) throw new ConnectorObservationRecordTypeError(type);
23
+ }
24
+ function lockKey(key) {
25
+ return JSON.stringify([
26
+ key.type,
27
+ JSON.stringify([
28
+ key.owner,
29
+ key.id
30
+ ])
31
+ ]);
32
+ }
33
+ function copyFromRow(row) {
34
+ return {
35
+ type: row.type,
36
+ owner: row.owner,
37
+ id: row.id,
38
+ hlc: row.hlc,
39
+ opJWT: row.op_jwt,
40
+ opHash: row.op_hash,
41
+ payload: row.payload === null ? null : Uint8Array.from(row.payload)
42
+ };
43
+ }
44
+ async function verifiedRows(rows, methods) {
45
+ const result = [];
46
+ for (const row of rows){
47
+ const copy = copyFromRow(row);
48
+ const op = await verifySignedRecord(copy, methods);
49
+ if (op === null || !recordPayloadMatches(copy, op.claim)) continue;
50
+ if (typeof op.claim.meta.version !== 'string' || parseCanonicalHLC(op.claim.meta.version) === null) continue;
51
+ const signerKey = recordSignerKey(op.issuer, copy.opJWT);
52
+ if (signerKey !== null) result.push({
53
+ row,
54
+ copy,
55
+ op,
56
+ signerKey
57
+ });
58
+ }
59
+ return result;
60
+ }
61
+ function decision(key, rows) {
62
+ return recordDecisionFromVerifiedCopies(key, rows, 'version');
63
+ }
64
+ function repairFromRow(row) {
65
+ return {
66
+ documentID: row.document_id,
67
+ owner: row.owner,
68
+ fetchHLC: row.fetch_hlc,
69
+ decisionHLC: row.decision_hlc,
70
+ present: row.present === 1,
71
+ valueDigest: row.value_digest
72
+ };
73
+ }
74
+ export function createObservationRecordMethods(db, adapter) {
75
+ const rowsFor = async (key, tx = db)=>await tx.selectFrom('kubun_connector_observation_records').selectAll().where('type', '=', key.type).where('owner', '=', key.owner).where('id', '=', key.id).execute();
76
+ const lockKeys = async (tx, keys)=>{
77
+ if (!(adapter instanceof AbstractPostgresAdapter)) return;
78
+ for (const key of [
79
+ ...new Set(keys)
80
+ ].sort())await sql`select pg_advisory_xact_lock(hashtextextended(${key}, 0))`.execute(tx);
81
+ };
82
+ const write = async (fn)=>db.isTransaction ? await fn(db) : await db.transaction().execute(fn);
83
+ return {
84
+ async getObservationRecordDecision (key, methods) {
85
+ requireType(key.type);
86
+ return decision(key, await verifiedRows(await rowsFor(key), methods));
87
+ },
88
+ async getObservationRecordDecisions (keys, methods) {
89
+ for (const key of keys)requireType(key.type);
90
+ const wanted = new Map();
91
+ for (const key of keys)wanted.set(JSON.stringify([
92
+ key.owner,
93
+ key.id
94
+ ]), key);
95
+ const ids = [
96
+ ...new Set(keys.map((key)=>key.id))
97
+ ];
98
+ const groups = new Map();
99
+ for (const chunk of chunked(ids)){
100
+ const rows = await db.selectFrom('kubun_connector_observation_records').selectAll().where('type', '=', TYPE).where('id', 'in', chunk).execute();
101
+ for (const row of rows){
102
+ const address = JSON.stringify([
103
+ row.owner,
104
+ row.id
105
+ ]);
106
+ if (!wanted.has(address)) continue;
107
+ const group = groups.get(address) ?? [];
108
+ group.push(row);
109
+ groups.set(address, group);
110
+ }
111
+ }
112
+ const result = [];
113
+ for (const [address, group] of groups){
114
+ const key = wanted.get(address);
115
+ if (key === undefined) continue;
116
+ const current = decision(key, await verifiedRows(group, methods));
117
+ if (current !== null) result.push(current);
118
+ }
119
+ return result;
120
+ },
121
+ async listObservationRecordDecisions (types, methods) {
122
+ for (const type of types)requireType(type);
123
+ if (types.length === 0) return [];
124
+ const rows = await db.selectFrom('kubun_connector_observation_records').selectAll().where('type', '=', TYPE).orderBy('owner').orderBy('id').execute();
125
+ const groups = new Map();
126
+ for (const row of rows){
127
+ const address = JSON.stringify([
128
+ row.owner,
129
+ row.id
130
+ ]);
131
+ const group = groups.get(address) ?? [];
132
+ group.push(row);
133
+ groups.set(address, group);
134
+ }
135
+ const result = [];
136
+ for (const group of groups.values()){
137
+ const first = group[0];
138
+ if (first === undefined) continue;
139
+ const current = decision({
140
+ type: TYPE,
141
+ owner: first.owner,
142
+ id: first.id
143
+ }, await verifiedRows(group, methods));
144
+ if (current !== null) result.push(current);
145
+ }
146
+ return result;
147
+ },
148
+ async putObservationRecordCopy (record, methods) {
149
+ requireType(record.type);
150
+ const op = await verifySignedRecord(record, methods);
151
+ if (op === null || !recordPayloadMatches(record, op.claim)) throw new ConnectorObservationRecordError('Observation record is not a valid signed copy');
152
+ const version = op.claim.meta.version;
153
+ if (typeof version !== 'string' || parseCanonicalHLC(version) === null) throw new ConnectorObservationRecordError('Observation decision version is invalid');
154
+ const signerKey = recordSignerKey(op.issuer, record.opJWT);
155
+ if (signerKey === null) throw new ConnectorObservationRecordError('Observation signer key is invalid');
156
+ const key = {
157
+ type: TYPE,
158
+ owner: record.owner,
159
+ id: record.id
160
+ };
161
+ await write(async (tx)=>{
162
+ await lockKeys(tx, [
163
+ lockKey(key)
164
+ ]);
165
+ const rows = await rowsFor(key, tx);
166
+ const valid = await verifiedRows(rows, methods);
167
+ const current = decision(key, valid);
168
+ const digest = recordClaimDigest(op.claim);
169
+ const order = current === null ? 1 : compareRecordDecisionHLC(version, current.decisionHLC) || (digest < current.contentDigest ? -1 : digest > current.contentDigest ? 1 : 0);
170
+ if (order < 0) return;
171
+ const retained = order === 0 ? valid.filter((entry)=>entry.op.claim.meta.version === version && recordClaimDigest(entry.op.claim) === digest) : [];
172
+ const sameSigner = retained.find((entry)=>entry.signerKey === signerKey);
173
+ const incomingWinsSigner = sameSigner === undefined || record.opHash < sameSigner.copy.opHash;
174
+ for (const row of rows){
175
+ const entry = retained.find((candidate)=>candidate.row.op_hash === row.op_hash);
176
+ if (entry !== undefined && entry.signerKey !== signerKey) continue;
177
+ if (entry !== undefined && !incomingWinsSigner && entry.copy.opHash === sameSigner?.copy.opHash) continue;
178
+ await tx.deleteFrom('kubun_connector_observation_records').where('type', '=', TYPE).where('owner', '=', key.owner).where('id', '=', key.id).where('op_hash', '=', row.op_hash).execute();
179
+ }
180
+ if (!incomingWinsSigner) return;
181
+ await tx.insertInto('kubun_connector_observation_records').values({
182
+ type: TYPE,
183
+ owner: record.owner,
184
+ id: record.id,
185
+ op_hash: record.opHash,
186
+ hlc: record.hlc,
187
+ op_jwt: record.opJWT,
188
+ payload: record.payload === null ? null : adapter.encodeBinary(record.payload)
189
+ }).onConflict((oc)=>oc.columns([
190
+ 'type',
191
+ 'owner',
192
+ 'id',
193
+ 'op_hash'
194
+ ]).doNothing()).execute();
195
+ });
196
+ },
197
+ async pruneObservationRecordCopy (key, signerDID, methods) {
198
+ requireType(key.type);
199
+ await write(async (tx)=>{
200
+ await lockKeys(tx, [
201
+ lockKey(key)
202
+ ]);
203
+ const valid = await verifiedRows(await rowsFor(key, tx), methods);
204
+ if (valid.filter((entry)=>entry.op.issuer !== signerDID).length === 0) return;
205
+ for (const entry of valid){
206
+ if (entry.op.issuer !== signerDID) continue;
207
+ await tx.deleteFrom('kubun_connector_observation_records').where('type', '=', TYPE).where('owner', '=', key.owner).where('id', '=', key.id).where('op_hash', '=', entry.copy.opHash).execute();
208
+ }
209
+ });
210
+ },
211
+ async lockObservationRecordKeys (keys) {
212
+ if (!db.isTransaction) throw new ConnectorObservationRecordError('Observation locks require a transaction');
213
+ await lockKeys(db, keys);
214
+ },
215
+ async getObservationRepair (documentID) {
216
+ const row = await db.selectFrom('kubun_connector_observation_repair').selectAll().where('document_id', '=', documentID).executeTakeFirst();
217
+ return row === undefined ? null : repairFromRow(row);
218
+ },
219
+ async getObservationRepairs (documentIDs) {
220
+ const repairs = new Map();
221
+ for (const ids of chunked([
222
+ ...documentIDs
223
+ ])){
224
+ const rows = await db.selectFrom('kubun_connector_observation_repair').selectAll().where('document_id', 'in', ids).execute();
225
+ for (const row of rows)repairs.set(row.document_id, repairFromRow(row));
226
+ }
227
+ return repairs;
228
+ },
229
+ async listObservationRepairsWrittenSince (page) {
230
+ let query = db.selectFrom('kubun_connector_observation_repair').selectAll().orderBy('written_at').orderBy('document_id').limit(page.limit);
231
+ const after = page.after;
232
+ if (after !== null) {
233
+ query = query.where((eb)=>eb.or([
234
+ eb('written_at', '>', after.writtenAt),
235
+ eb.and([
236
+ eb('written_at', '=', after.writtenAt),
237
+ eb('document_id', '>', after.documentID)
238
+ ])
239
+ ]));
240
+ }
241
+ return (await query.execute()).map((row)=>({
242
+ ...repairFromRow(row),
243
+ writtenAt: Number(row.written_at)
244
+ }));
245
+ },
246
+ async putObservationRepair (repair) {
247
+ const values = {
248
+ owner: repair.owner,
249
+ fetch_hlc: repair.fetchHLC,
250
+ decision_hlc: repair.decisionHLC,
251
+ present: repair.present ? 1 : 0,
252
+ value_digest: repair.valueDigest,
253
+ written_at: Date.now()
254
+ };
255
+ await db.insertInto('kubun_connector_observation_repair').values({
256
+ document_id: repair.documentID,
257
+ ...values
258
+ }).onConflict((oc)=>oc.column('document_id').doUpdateSet(values)).execute();
259
+ },
260
+ async clearObservationRepair (documentID) {
261
+ await db.deleteFrom('kubun_connector_observation_repair').where('document_id', '=', documentID).execute();
262
+ }
263
+ };
264
+ }
265
+ export async function connectorObservationRecordAdapter(provider, methods) {
266
+ const connector = await provider.getStore('connector');
267
+ const records = {
268
+ isTransaction: provider.isTransactionBound,
269
+ async transaction (fn) {
270
+ return await provider.withTransaction(async (tx)=>await fn(await connectorObservationRecordAdapter(tx, methods)));
271
+ },
272
+ async get (key) {
273
+ requireType(key.type);
274
+ return await connector.getObservationRecordDecision(key, methods);
275
+ },
276
+ async getMany (keys) {
277
+ for (const key of keys)requireType(key.type);
278
+ const decisions = new Map();
279
+ for (const decision of (await connector.getObservationRecordDecisions(keys, methods))){
280
+ decisions.set(recordKeyText(decision.key), decision);
281
+ }
282
+ return decisions;
283
+ },
284
+ async list (types) {
285
+ for (const type of types)requireType(type);
286
+ return await connector.listObservationRecordDecisions(types, methods);
287
+ },
288
+ async putCopy (record) {
289
+ requireType(record.type);
290
+ await connector.putObservationRecordCopy(record, methods);
291
+ },
292
+ async pruneCopy (key, signerDID) {
293
+ requireType(key.type);
294
+ await connector.pruneObservationRecordCopy(key, signerDID, methods);
295
+ },
296
+ async lock (keys) {
297
+ for (const key of keys)requireType(key.type);
298
+ await connector.lockObservationRecordKeys(keys.map((key)=>JSON.stringify([
299
+ key.type,
300
+ key.key
301
+ ])));
302
+ },
303
+ async getObservationDocumentOwner (documentID) {
304
+ const graph = await provider.getStore('graph');
305
+ return (await graph.getDocument(documentID))?.owner ?? null;
306
+ }
307
+ };
308
+ providers.set(records, provider);
309
+ return records;
310
+ }
@@ -0,0 +1,4 @@
1
+ import type { ConnectorStoreAPI } from './api-types.js';
2
+ import type { ConnectorStoreContext } from './store-context.js';
3
+ export type OperationsMethods = Pick<ConnectorStoreAPI, 'claimOperation' | 'acquireLease' | 'renewLease' | 'releaseLease' | 'getOperation' | 'listTrackedOperations' | 'hasNewerOperationOnDocument' | 'markSendStarted' | 'deferOperation' | 'recordOperation'>;
4
+ export declare function createOperationsMethods(context: ConnectorStoreContext): OperationsMethods;
@@ -0,0 +1,223 @@
1
+ import { chunked, IN_LIST_CHUNK } from '@kubun/db-adapter';
2
+ import { encodeJSONColumn, rowToOperation } from './serialization.js';
3
+ // The `send_marker` of an attempt that more than one run marked. It matches
4
+ // no run's marker, so no run of that attempt can refund it.
5
+ const CONTESTED_SEND_MARKER = '*contested*';
6
+ // The `listTrackedOperations` order: oldest first, then caller and operation id.
7
+ function compareOperations(a, b) {
8
+ if (a.createdAt !== b.createdAt) return a.createdAt - b.createdAt;
9
+ if (a.callerDID !== b.callerDID) return a.callerDID < b.callerDID ? -1 : 1;
10
+ if (a.operationID !== b.operationID) return a.operationID < b.operationID ? -1 : 1;
11
+ return 0;
12
+ }
13
+ export function createOperationsMethods(context) {
14
+ const { db, getRandomID } = context;
15
+ const methods = {
16
+ // --- Connector write-back operation envelope ---
17
+ async claimOperation (input) {
18
+ const now = Date.now();
19
+ // INSERT ... ON CONFLICT DO NOTHING RETURNING *, mirroring the insert-race
20
+ // half of `updateSyncProgress`: a fresh key wins the insert and is
21
+ // returned directly; a conflicting key returns nothing here and falls
22
+ // through to the read+compare below.
23
+ const insertedRow = await db.insertInto('kubun_connector_operation').values({
24
+ caller_did: input.callerDID,
25
+ operation_id: input.operationID,
26
+ node_key: getRandomID(),
27
+ owner_did: input.ownerDID,
28
+ connector_name: input.connectorName,
29
+ model: input.model,
30
+ op: input.op,
31
+ account_key: input.accountKey,
32
+ target: input.target,
33
+ source_id: input.sourceID,
34
+ deterministic_event_id: input.deterministicEventID,
35
+ payload_hash: input.payloadHash,
36
+ reissue_intent: encodeJSONColumn(input.reissueIntent) ?? 'null',
37
+ provider_etag: input.providerEtag ?? null,
38
+ document_id: input.documentID ?? null,
39
+ result_entity: null,
40
+ state: 'attempted',
41
+ attempts: 0,
42
+ next_attempt_at: null,
43
+ last_kind: null,
44
+ last_http_status: null,
45
+ version: 0,
46
+ incarnation: getRandomID(),
47
+ lease_token: input.leaseToken,
48
+ lease_expires_at: input.leaseExpiresAt,
49
+ created_at: now,
50
+ updated_at: now
51
+ }).onConflict((oc)=>oc.columns([
52
+ 'caller_did',
53
+ 'operation_id'
54
+ ]).doNothing()).returningAll().executeTakeFirst();
55
+ if (insertedRow != null) {
56
+ return {
57
+ status: 'claimed',
58
+ record: rowToOperation(insertedRow)
59
+ };
60
+ }
61
+ const row = await db.selectFrom('kubun_connector_operation').selectAll().where('caller_did', '=', input.callerDID).where('operation_id', '=', input.operationID).executeTakeFirst();
62
+ if (row == null) {
63
+ // The conflicting row vanished between the failed insert and this read
64
+ // (e.g. a concurrent delete) — neither a match nor a safe claim.
65
+ return {
66
+ status: 'conflict'
67
+ };
68
+ }
69
+ // The immutable envelope tuple: equal on every field → the identical
70
+ // request replayed (`exists`); any drift is a genuine conflict, and the
71
+ // stored row is never overwritten.
72
+ const envelopeMatches = row.owner_did === input.ownerDID && row.connector_name === input.connectorName && row.model === input.model && row.op === input.op && row.account_key === input.accountKey && row.source_id === input.sourceID && row.deterministic_event_id === input.deterministicEventID && row.target === input.target && row.payload_hash === input.payloadHash;
73
+ return envelopeMatches ? {
74
+ status: 'exists',
75
+ record: rowToOperation(row)
76
+ } : {
77
+ status: 'conflict'
78
+ };
79
+ },
80
+ async acquireLease ({ callerDID, operationID, token, expiresAt }) {
81
+ const now = Date.now();
82
+ const result = await db.updateTable('kubun_connector_operation').set({
83
+ lease_token: token,
84
+ lease_expires_at: expiresAt,
85
+ updated_at: now
86
+ }).where('caller_did', '=', callerDID).where('operation_id', '=', operationID).where((eb)=>eb.or([
87
+ eb('lease_token', 'is', null),
88
+ eb('lease_expires_at', '<', now),
89
+ eb('lease_token', '=', token)
90
+ ])).returning('operation_id').executeTakeFirst();
91
+ return result != null;
92
+ },
93
+ async renewLease ({ callerDID, operationID, token, expiresAt }) {
94
+ const now = Date.now();
95
+ const result = await db.updateTable('kubun_connector_operation').set({
96
+ lease_expires_at: expiresAt,
97
+ updated_at: now
98
+ }).where('caller_did', '=', callerDID).where('operation_id', '=', operationID).where((eb)=>eb.or([
99
+ eb('lease_token', 'is', null),
100
+ eb('lease_expires_at', '<', now),
101
+ eb('lease_token', '=', token)
102
+ ])).returning('operation_id').executeTakeFirst();
103
+ return result != null;
104
+ },
105
+ async releaseLease ({ callerDID, operationID, token }) {
106
+ const now = Date.now();
107
+ const result = await db.updateTable('kubun_connector_operation').set({
108
+ lease_token: null,
109
+ lease_expires_at: null,
110
+ updated_at: now
111
+ }).where('caller_did', '=', callerDID).where('operation_id', '=', operationID).where((eb)=>eb.or([
112
+ eb('lease_token', 'is', null),
113
+ eb('lease_expires_at', '<', now),
114
+ eb('lease_token', '=', token)
115
+ ])).returning('operation_id').executeTakeFirst();
116
+ return result != null;
117
+ },
118
+ async getOperation (callerDID, operationID) {
119
+ const row = await db.selectFrom('kubun_connector_operation').selectAll().where('caller_did', '=', callerDID).where('operation_id', '=', operationID).executeTakeFirst();
120
+ return row != null ? rowToOperation(row) : null;
121
+ },
122
+ async listTrackedOperations (filter) {
123
+ if (filter.states?.length === 0 || filter.documentIDs?.length === 0) return [];
124
+ if (filter.documentIDs != null && filter.documentIDs.length > IN_LIST_CHUNK) {
125
+ const chunks = [];
126
+ for (const documentIDs of chunked(filter.documentIDs)){
127
+ chunks.push(await methods.listTrackedOperations({
128
+ ...filter,
129
+ documentIDs
130
+ }));
131
+ }
132
+ return chunks.flat().sort(compareOperations);
133
+ }
134
+ const rows = await db.selectFrom('kubun_connector_operation').selectAll().$if(filter.nodeKey != null, (qb)=>qb.where('node_key', '=', filter.nodeKey)).$if(filter.callerDID != null, (qb)=>qb.where('caller_did', '=', filter.callerDID)).$if(filter.operationID != null, (qb)=>qb.where('operation_id', '=', filter.operationID)).$if(filter.visibleTo != null, (qb)=>qb.where((eb)=>eb.or([
135
+ eb('caller_did', '=', filter.visibleTo),
136
+ eb('owner_did', '=', filter.visibleTo)
137
+ ]))).$if(filter.connectorName != null, (qb)=>qb.where('connector_name', '=', filter.connectorName)).$if(filter.states != null, (qb)=>qb.where('state', 'in', filter.states)).$if(filter.documentIDs != null, (qb)=>qb.where('document_id', 'in', filter.documentIDs)).orderBy('created_at').orderBy('caller_did').orderBy('operation_id').execute();
138
+ return rows.map(rowToOperation);
139
+ },
140
+ async hasNewerOperationOnDocument (callerDID, operationID) {
141
+ const row = await db.selectFrom('kubun_connector_operation as o').innerJoin('kubun_connector_operation as n', 'n.document_id', 'o.document_id').select('n.operation_id').where('o.caller_did', '=', callerDID).where('o.operation_id', '=', operationID).where((order)=>order.or([
142
+ order('n.created_at', '>', order.ref('o.created_at')),
143
+ order.and([
144
+ order('n.created_at', '=', order.ref('o.created_at')),
145
+ order('n.caller_did', '>', order.ref('o.caller_did'))
146
+ ]),
147
+ order.and([
148
+ order('n.created_at', '=', order.ref('o.created_at')),
149
+ order('n.caller_did', '=', order.ref('o.caller_did')),
150
+ order('n.operation_id', '>', order.ref('o.operation_id'))
151
+ ])
152
+ ])).limit(1).executeTakeFirst();
153
+ return row != null;
154
+ },
155
+ async markSendStarted (params) {
156
+ if (params.marker === CONTESTED_SEND_MARKER) {
157
+ throw new Error('markSendStarted: marker is reserved');
158
+ }
159
+ const result = await db.updateTable('kubun_connector_operation').set((eb)=>({
160
+ // A second run marking the same attempt means an earlier run may
161
+ // already have sent: the attempt becomes refundable by no run.
162
+ send_marker: eb.case().when(eb.or([
163
+ eb('send_marker', 'is', null),
164
+ eb('send_marker', '=', params.marker)
165
+ ])).then(params.marker).else(CONTESTED_SEND_MARKER).end(),
166
+ updated_at: Date.now()
167
+ })).where('caller_did', '=', params.callerDID).where('operation_id', '=', params.operationID).where('state', '=', 'attempted').where('lease_token', '=', params.leaseToken).executeTakeFirst();
168
+ return (result?.numUpdatedRows ?? 0n) > 0n;
169
+ },
170
+ async deferOperation (params) {
171
+ const now = Date.now();
172
+ const result = await db.updateTable('kubun_connector_operation').set({
173
+ next_attempt_at: params.nextAttemptAt,
174
+ defer_reason: params.reason ?? null,
175
+ updated_at: now
176
+ }).where('caller_did', '=', params.callerDID).where('operation_id', '=', params.operationID).where('state', '=', 'attempted').where((eb)=>{
177
+ const free = [
178
+ eb('lease_token', 'is', null),
179
+ eb('lease_expires_at', '<', now)
180
+ ];
181
+ return eb.or(params.leaseToken != null ? [
182
+ ...free,
183
+ eb('lease_token', '=', params.leaseToken)
184
+ ] : free);
185
+ }).executeTakeFirst();
186
+ return (result?.numUpdatedRows ?? 0n) > 0n;
187
+ },
188
+ async recordOperation (input) {
189
+ // The type excludes `'committed'` from `toState`; this guards a caller
190
+ // that forces it through with a cast.
191
+ if (input.toState === 'committed') {
192
+ throw new Error('recordOperation cannot reach committed; use finalizeOperationAndArmWatch');
193
+ }
194
+ const now = Date.now();
195
+ const result = await db.updateTable('kubun_connector_operation').set((eb)=>({
196
+ state: input.toState,
197
+ provider_etag: input.providerEtag ?? null,
198
+ attempts: input.refundAttempt === true ? eb('attempts', '-', 1) : input.countAttempt === false ? eb.ref('attempts') : eb('attempts', '+', 1),
199
+ next_attempt_at: input.nextAttemptAt ?? null,
200
+ defer_reason: input.deferReason ?? null,
201
+ // A newly consumed attempt has started no send yet. A refund
202
+ // clears the marker too: it only applies when the attempt is
203
+ // unmarked or marked by the refunding run alone (see the guard
204
+ // below), so a contested or other run's mark is never erased, and
205
+ // the next run of the attempt starts unmarked.
206
+ ...input.refundAttempt === true || input.countAttempt !== false ? {
207
+ send_marker: null
208
+ } : {},
209
+ last_kind: input.kind ?? null,
210
+ last_http_status: input.httpStatus ?? null,
211
+ version: eb('version', '+', 1),
212
+ updated_at: now
213
+ })).where('caller_did', '=', input.callerDID).where('operation_id', '=', input.operationID).where('state', '=', input.fromState).where('version', '=', input.version).where('lease_token', '=', input.leaseToken).$if(input.refundAttempt === true, (qb)=>qb.where('attempts', '>', 0).where((eb)=>eb.or([
214
+ eb('send_marker', 'is', null),
215
+ ...input.sendMarker != null ? [
216
+ eb('send_marker', '=', input.sendMarker)
217
+ ] : []
218
+ ]))).executeTakeFirst();
219
+ return (result?.numUpdatedRows ?? 0n) > 0n;
220
+ }
221
+ };
222
+ return methods;
223
+ }
@@ -0,0 +1,4 @@
1
+ import type { ConnectorStoreAPI } from './api-types.js';
2
+ import type { ConnectorStoreContext } from './store-context.js';
3
+ export type PendingAuthMethods = Pick<ConnectorStoreAPI, 'createPendingAuth' | 'getPendingAuth' | 'consumePendingAuth' | 'deleteExpiredPendingAuth'>;
4
+ export declare function createPendingAuthMethods(context: ConnectorStoreContext): PendingAuthMethods;
@@ -0,0 +1,69 @@
1
+ export function createPendingAuthMethods(context) {
2
+ const { db } = context;
3
+ const methods = {
4
+ // --- Pending OAuth authorization operations ---
5
+ async createPendingAuth (record) {
6
+ await db.insertInto('kubun_connector_pending_auth').values({
7
+ state: record.state,
8
+ code_verifier: record.codeVerifier,
9
+ provider: record.provider,
10
+ owner_did: record.ownerDID,
11
+ owner_wrappable_did: record.ownerWrappableDID ?? null,
12
+ controller_wrappable_did: record.controllerWrappableDID ?? null,
13
+ created_at: record.createdAt,
14
+ redirect_url: record.redirectURL,
15
+ scopes: JSON.stringify(record.scopes),
16
+ is_incremental: record.incremental ? 1 : 0,
17
+ expires_at: record.expiresAt
18
+ }).execute();
19
+ },
20
+ async getPendingAuth (state) {
21
+ const row = await db.selectFrom('kubun_connector_pending_auth').selectAll().where('state', '=', state).executeTakeFirst();
22
+ return row == null ? null : {
23
+ state: row.state,
24
+ codeVerifier: row.code_verifier,
25
+ provider: row.provider,
26
+ ownerDID: row.owner_did,
27
+ ownerWrappableDID: row.owner_wrappable_did,
28
+ controllerWrappableDID: row.controller_wrappable_did,
29
+ createdAt: row.created_at,
30
+ redirectURL: row.redirect_url,
31
+ scopes: typeof row.scopes === 'string' ? JSON.parse(row.scopes) : row.scopes,
32
+ incremental: row.is_incremental === 1,
33
+ expiresAt: row.expires_at
34
+ };
35
+ },
36
+ async consumePendingAuth (state) {
37
+ // Atomic single-use read: DELETE ... RETURNING removes and returns the row
38
+ // in one statement so concurrent callbacks cannot both consume the state.
39
+ const row = await db.deleteFrom('kubun_connector_pending_auth').where('state', '=', state).returningAll().executeTakeFirst();
40
+ if (row == null) return null;
41
+ return {
42
+ state: row.state,
43
+ codeVerifier: row.code_verifier,
44
+ provider: row.provider,
45
+ ownerDID: row.owner_did,
46
+ ownerWrappableDID: row.owner_wrappable_did,
47
+ controllerWrappableDID: row.controller_wrappable_did,
48
+ createdAt: row.created_at,
49
+ redirectURL: row.redirect_url,
50
+ // ParseJSONResultsPlugin may have already parsed this column, same as
51
+ // `checkpoint` in `rowToSyncState`.
52
+ scopes: typeof row.scopes === 'string' ? JSON.parse(row.scopes) : row.scopes,
53
+ incremental: row.is_incremental === 1,
54
+ expiresAt: row.expires_at
55
+ };
56
+ },
57
+ async deleteExpiredPendingAuth (cutoffMs) {
58
+ // Strict `<` matches @kokuin/oauth `completeAuthorization`, which treats a
59
+ // record as expired only when `expiresAt < now`. A record at exactly
60
+ // `expiresAt === cutoffMs` is therefore still consumable and is kept here
61
+ // (the next sweep, once `now > expiresAt`, removes it). This intentionally
62
+ // keeps one more record than the `PendingAuthStore.deleteExpired` port doc
63
+ // ("at or before nowMs") so the sweep and the consume check agree on the
64
+ // expiry boundary.
65
+ await db.deleteFrom('kubun_connector_pending_auth').where('expires_at', '<', cutoffMs).execute();
66
+ }
67
+ };
68
+ return methods;
69
+ }