@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
|
@@ -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
|
+
}
|