@kubun/store-graph 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,5 @@
1
+ import type { CatalogRule } from './catalog-scope.js';
2
+ import type { CatalogDefinition, CatalogMergeResult, MergeCatalogDefinitionsParams } from './catalog-types.js';
3
+ export declare function orderMergedCatalogRules(local: ReadonlyArray<CatalogRule>, upstream: ReadonlyArray<CatalogRule>, selected: ReadonlyMap<string, CatalogRule>): Array<CatalogRule>;
4
+ export declare function mergeCatalogDefinitions(params: MergeCatalogDefinitionsParams): CatalogMergeResult;
5
+ export declare function catalogLocalRevisionID(definition: CatalogDefinition): string;
@@ -0,0 +1,117 @@
1
+ import { digestJSON } from '@kubun/id';
2
+ import { toB64U } from '@sozai/codec';
3
+ import { CatalogScopeRulesEmptyError, canonicalCatalogRuleContent, InvalidCatalogScopeError, validateScopeCatalog } from './catalog-scope.js';
4
+ function sameRule(left, right) {
5
+ if (left === null || right === null) return left === right;
6
+ return canonicalCatalogRuleContent(left) === canonicalCatalogRuleContent(right);
7
+ }
8
+ export function orderMergedCatalogRules(local, upstream, selected) {
9
+ const localIDs = local.map((r)=>r.id).filter((id)=>selected.has(id));
10
+ const localSurvivors = new Set(localIDs);
11
+ const beginning = [];
12
+ const after = new Map();
13
+ let anchor = null;
14
+ for (const rule of upstream){
15
+ if (localSurvivors.has(rule.id)) anchor = rule.id;
16
+ else if (selected.has(rule.id)) {
17
+ if (anchor === null) beginning.push(rule.id);
18
+ else after.set(anchor, [
19
+ ...after.get(anchor) ?? [],
20
+ rule.id
21
+ ]);
22
+ }
23
+ }
24
+ const ids = [
25
+ ...beginning,
26
+ ...localIDs.flatMap((id)=>[
27
+ id,
28
+ ...after.get(id) ?? []
29
+ ])
30
+ ];
31
+ return ids.map((id)=>{
32
+ const rule = selected.get(id);
33
+ if (rule === undefined) throw new InvalidCatalogScopeError('Missing selected catalog rule');
34
+ return rule;
35
+ });
36
+ }
37
+ export function mergeCatalogDefinitions(params) {
38
+ const { base, local, upstream, resolutions } = params;
39
+ const baseRules = new Map(base.rules.map((rule)=>[
40
+ rule.id,
41
+ rule
42
+ ]));
43
+ const localRules = new Map(local.rules.map((rule)=>[
44
+ rule.id,
45
+ rule
46
+ ]));
47
+ const upstreamRules = new Map(upstream.rules.map((rule)=>[
48
+ rule.id,
49
+ rule
50
+ ]));
51
+ const ids = new Set([
52
+ ...baseRules.keys(),
53
+ ...localRules.keys(),
54
+ ...upstreamRules.keys()
55
+ ]);
56
+ const selected = new Map();
57
+ const conflicts = [];
58
+ for (const ruleID of ids){
59
+ const baseRule = baseRules.get(ruleID) ?? null;
60
+ const localRule = localRules.get(ruleID) ?? null;
61
+ const upstreamRule = upstreamRules.get(ruleID) ?? null;
62
+ let rule = localRule;
63
+ if (sameRule(localRule, baseRule)) rule = upstreamRule;
64
+ else if (!sameRule(upstreamRule, baseRule) && !sameRule(localRule, upstreamRule)) {
65
+ conflicts.push({
66
+ ruleID,
67
+ base: baseRule,
68
+ local: localRule,
69
+ upstream: upstreamRule
70
+ });
71
+ }
72
+ if (rule !== null) selected.set(ruleID, rule);
73
+ }
74
+ if (resolutions !== undefined) {
75
+ const conflictMap = new Map(conflicts.map((conflict)=>[
76
+ conflict.ruleID,
77
+ conflict
78
+ ]));
79
+ const resolved = new Set();
80
+ for (const resolution of resolutions){
81
+ const conflict = conflictMap.get(resolution.ruleID);
82
+ if (conflict === undefined || resolved.has(resolution.ruleID) || resolution.side !== 'LOCAL' && resolution.side !== 'UPSTREAM') {
83
+ throw new InvalidCatalogScopeError('Invalid catalog rule resolution choice');
84
+ }
85
+ resolved.add(resolution.ruleID);
86
+ const rule = resolution.side === 'LOCAL' ? conflict.local : conflict.upstream;
87
+ if (rule === null) selected.delete(resolution.ruleID);
88
+ else selected.set(resolution.ruleID, rule);
89
+ }
90
+ if (resolved.size !== conflicts.length) {
91
+ throw new InvalidCatalogScopeError('Every catalog rule conflict needs one resolution');
92
+ }
93
+ }
94
+ const merged = {
95
+ name: local.name === base.name ? upstream.name : local.name,
96
+ description: local.description === base.description ? upstream.description : local.description,
97
+ app: local.app,
98
+ rules: orderMergedCatalogRules(local.rules, upstream.rules, selected)
99
+ };
100
+ if (merged.rules.length === 0) throw new CatalogScopeRulesEmptyError();
101
+ if (resolutions !== undefined) validateScopeCatalog(merged);
102
+ return {
103
+ merged,
104
+ conflicts
105
+ };
106
+ }
107
+ export function catalogLocalRevisionID(definition) {
108
+ return toB64U(digestJSON({
109
+ name: definition.name,
110
+ description: definition.description ?? null,
111
+ app: definition.app ?? null,
112
+ rules: definition.rules.map((rule)=>({
113
+ id: rule.id,
114
+ content: canonicalCatalogRuleContent(rule)
115
+ }))
116
+ }));
117
+ }
@@ -0,0 +1,34 @@
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 MergePolicy, type RecordDecision, type RecordKey, type RecordStoreAdapter, type SignedRecord, type VerifiedRecordOp } from '@kubun/records';
5
+ import { type Kysely } from 'kysely';
6
+ import type { GraphStoreAPI } from './api.js';
7
+ import type { GraphTables } from './tables.js';
8
+ export declare class GraphCatalogRecordTypeError extends Error {
9
+ constructor(type: string);
10
+ }
11
+ export declare class GraphCatalogRecordError extends Error {
12
+ constructor(message: string);
13
+ }
14
+ /** The merge policy the graph catalog store orders a type's decisions by. */
15
+ export declare function catalogRecordPolicy(type: string): MergePolicy;
16
+ export type GraphCatalogRecordMethods = {
17
+ getCatalogRecordDecision(key: RecordKey, methods?: MethodRegistry): Promise<RecordDecision | null>;
18
+ /** The decision of each key in `keys`, in order, from one read. */
19
+ getCatalogRecordDecisions(keys: ReadonlyArray<RecordKey>, methods?: MethodRegistry): Promise<Array<RecordDecision | null>>;
20
+ listCatalogRecordDecisions(types: ReadonlyArray<string>, methods?: MethodRegistry): Promise<Array<RecordDecision>>;
21
+ listCatalogRecordDecisionsForGroup(groupID: string, methods?: MethodRegistry): Promise<Array<RecordDecision>>;
22
+ putCatalogRecordCopy(record: SignedRecord, methods?: MethodRegistry): Promise<boolean>;
23
+ pruneCatalogRecordCopy(key: RecordKey, signerDID: string, methods?: MethodRegistry): Promise<void>;
24
+ lockCatalogRecordKeys(keys: ReadonlyArray<string>): Promise<void>;
25
+ };
26
+ export type GraphCatalogRecordMethodsParams = {
27
+ onClose?: (fn: () => void) => void;
28
+ /** Called once a custody assignment, acceptance or eligibility was stored, in the writing transaction. */
29
+ onCustodyRecordStored?: () => void;
30
+ };
31
+ export declare function createGraphCatalogRecordMethods(db: Kysely<GraphTables>, adapter: Adapter, params?: GraphCatalogRecordMethodsParams): GraphCatalogRecordMethods;
32
+ export declare function graphCatalogRecordAdapter(provider: StoreProvider, methods?: MethodRegistry): Promise<RecordStoreAdapter>;
33
+ export declare function graphStoreOfCatalogRecords(records: RecordStoreAdapter): GraphStoreAPI | null;
34
+ export declare function verifyGraphCatalogRecord(records: RecordStoreAdapter, record: SignedRecord): Promise<VerifiedRecordOp | null>;
@@ -0,0 +1,527 @@
1
+ import { AbstractPostgresAdapter, chunked } from '@kubun/db-adapter';
2
+ import { compareRecordOps, parseCanonicalHLC, recordClaimDigest, recordKeyText, recordPayloadMatches, recordSignerKey, verifySignedRecord } from '@kubun/records';
3
+ import { sql } from 'kysely';
4
+ function exposureVersion(claim) {
5
+ const stamp = typeof claim.meta.version === 'string' ? parseCanonicalHLC(claim.meta.version) : null;
6
+ return stamp === null ? [
7
+ Number.NaN
8
+ ] : [
9
+ stamp.wallTime,
10
+ stamp.counter
11
+ ];
12
+ }
13
+ function custodyGeneration(claim) {
14
+ const generation = claim.meta.generation;
15
+ return [
16
+ typeof generation === 'number' && Number.isSafeInteger(generation) && generation >= 0 ? generation : Number.NaN
17
+ ];
18
+ }
19
+ // The store decides with the same policy apply uses, so it never drops a write apply accepted.
20
+ const POLICIES = new Map([
21
+ [
22
+ 'catalog',
23
+ {
24
+ kind: 'lww'
25
+ }
26
+ ],
27
+ [
28
+ 'catalog.exposure',
29
+ {
30
+ kind: 'monotonic',
31
+ versionOrder: 'hlc',
32
+ version: exposureVersion
33
+ }
34
+ ],
35
+ [
36
+ 'catalog.version',
37
+ {
38
+ kind: 'immutable'
39
+ }
40
+ ],
41
+ [
42
+ 'catalog.custody',
43
+ {
44
+ kind: 'monotonic',
45
+ version: custodyGeneration
46
+ }
47
+ ],
48
+ [
49
+ 'catalog.custody-acceptance',
50
+ {
51
+ kind: 'lww'
52
+ }
53
+ ],
54
+ [
55
+ 'catalog.custody-eligibility',
56
+ {
57
+ kind: 'lww'
58
+ }
59
+ ]
60
+ ]);
61
+ const TYPES = new Set(POLICIES.keys());
62
+ export class GraphCatalogRecordTypeError extends Error {
63
+ constructor(type){
64
+ super(`Unsupported graph catalog record type: ${type}`);
65
+ this.name = 'GraphCatalogRecordTypeError';
66
+ }
67
+ }
68
+ export class GraphCatalogRecordError extends Error {
69
+ constructor(message){
70
+ super(message);
71
+ this.name = 'GraphCatalogRecordError';
72
+ }
73
+ }
74
+ function requireType(type) {
75
+ if (!TYPES.has(type)) throw new GraphCatalogRecordTypeError(type);
76
+ }
77
+ /** The merge policy the graph catalog store orders a type's decisions by. */ export function catalogRecordPolicy(type) {
78
+ const policy = POLICIES.get(type);
79
+ if (policy === undefined) throw new GraphCatalogRecordTypeError(type);
80
+ return policy;
81
+ }
82
+ function copyFromRow(row) {
83
+ if ([
84
+ row.type,
85
+ row.owner,
86
+ row.id,
87
+ row.hlc,
88
+ row.op_jwt,
89
+ row.op_hash
90
+ ].some((value)=>typeof value !== 'string' || value.length === 0)) return null;
91
+ return {
92
+ type: row.type,
93
+ owner: row.owner,
94
+ id: row.id,
95
+ hlc: row.hlc,
96
+ opJWT: row.op_jwt,
97
+ opHash: row.op_hash,
98
+ payload: row.payload === null ? null : Uint8Array.from(row.payload)
99
+ };
100
+ }
101
+ const PEER4_PREFIX = 'did:peer:4';
102
+ /**
103
+ * Issuers whose key is read from the DID itself: did:key, and long-form did:peer:4, which
104
+ * carries its document. Any other issuer may depend on resolution state that can change.
105
+ */ function isSelfCertifyingIssuer(issuer) {
106
+ return issuer.startsWith('did:key:') || issuer.startsWith(PEER4_PREFIX) && issuer.indexOf(':', PEER4_PREFIX.length) !== -1;
107
+ }
108
+ // Self-certifying rows, across transactions; others remain transaction-local per methods.
109
+ const verifiedCopiesByAdapter = new WeakMap();
110
+ const clearVerifiedCopiesByAdapter = new WeakMap();
111
+ function sameRow(left, right) {
112
+ return left.type === right.type && left.owner === right.owner && left.id === right.id && left.op_hash === right.op_hash && left.hlc === right.hlc && left.op_jwt === right.op_jwt && left.catalog_id === right.catalog_id && left.group_id === right.group_id && left.circle_id === right.circle_id && (left.payload === null ? right.payload === null : right.payload !== null && left.payload.length === right.payload.length && left.payload.every((byte, index)=>byte === right.payload?.[index]));
113
+ }
114
+ async function verifiedRows(rows, caches, methods) {
115
+ const result = [];
116
+ const hashes = new Set(rows.map((row)=>row.op_hash));
117
+ for (const cache of [
118
+ caches.shared,
119
+ caches.local
120
+ ]){
121
+ if (cache == null) continue;
122
+ for (const hash of cache.keys())if (!hashes.has(hash)) cache.delete(hash);
123
+ }
124
+ const remember = (row, verified)=>{
125
+ if (verified !== null && caches.shared != null && isSelfCertifyingIssuer(verified.op.issuer)) {
126
+ caches.shared.set(row.op_hash, {
127
+ row,
128
+ verified
129
+ });
130
+ } else {
131
+ caches.shared?.delete(row.op_hash);
132
+ caches.local?.set(row.op_hash, {
133
+ row,
134
+ verified
135
+ });
136
+ }
137
+ };
138
+ for (const row of rows){
139
+ const known = [
140
+ caches.shared?.get(row.op_hash),
141
+ caches.local?.get(row.op_hash)
142
+ ].find((entry)=>entry !== undefined && sameRow(row, entry.row));
143
+ if (known !== undefined) {
144
+ if (known.verified !== null) result.push(known.verified);
145
+ continue;
146
+ }
147
+ const copy = copyFromRow(row);
148
+ if (copy === null) {
149
+ remember(row, null);
150
+ continue;
151
+ }
152
+ const op = await verifySignedRecord(copy, methods);
153
+ if (op === null || !recordPayloadMatches(copy, op.claim)) {
154
+ remember(row, null);
155
+ continue;
156
+ }
157
+ const signerKey = recordSignerKey(op.issuer, copy.opJWT);
158
+ if (signerKey === null) {
159
+ remember(row, null);
160
+ continue;
161
+ }
162
+ const decisionHLC = row.type === 'catalog.exposure' ? op.claim.meta.version : op.claim.hlc;
163
+ if (typeof decisionHLC !== 'string' || parseCanonicalHLC(decisionHLC) === null) {
164
+ remember(row, null);
165
+ continue;
166
+ }
167
+ const verified = {
168
+ row,
169
+ copy,
170
+ op,
171
+ signerKey,
172
+ decisionHLC,
173
+ contentDigest: recordClaimDigest(op.claim)
174
+ };
175
+ remember(row, verified);
176
+ result.push(verified);
177
+ }
178
+ return result;
179
+ }
180
+ function compareStrings(left, right) {
181
+ return left < right ? -1 : left > right ? 1 : 0;
182
+ }
183
+ function sameDecision(left, right) {
184
+ return left.decisionHLC === right.decisionHLC && left.contentDigest === right.contentDigest;
185
+ }
186
+ /** Whether `incoming` replaces `stored` under the type's policy; a fork keeps the greater digest. */ function prefers(incoming, stored, policy) {
187
+ const decision = compareRecordOps(incoming.op, stored?.op ?? null, policy);
188
+ return decision.status === 'apply' || decision.status === 'fork' && stored !== null && compareStrings(incoming.contentDigest, stored.contentDigest) > 0;
189
+ }
190
+ function winningRow(key, rows) {
191
+ const policy = catalogRecordPolicy(key.type);
192
+ let winner = null;
193
+ for (const entry of rows){
194
+ if (winner !== null && sameDecision(entry, winner)) continue;
195
+ if (prefers(entry, winner, policy)) winner = entry;
196
+ }
197
+ return winner;
198
+ }
199
+ function decisionFromVerified(key, copies) {
200
+ const winner = winningRow(key, copies);
201
+ if (winner === null) return null;
202
+ return {
203
+ key,
204
+ decisionHLC: winner.decisionHLC,
205
+ contentDigest: winner.contentDigest,
206
+ copies: copies.filter((entry)=>sameDecision(entry, winner)).map(({ copy })=>({
207
+ ...copy,
208
+ payload: copy.payload?.slice() ?? null
209
+ })).sort((a, b)=>compareStrings(a.opHash, b.opHash))
210
+ };
211
+ }
212
+ const CUSTODY_TYPES = new Set([
213
+ 'catalog.custody',
214
+ 'catalog.custody-acceptance',
215
+ 'catalog.custody-eligibility'
216
+ ]);
217
+ export function createGraphCatalogRecordMethods(db, adapter, params = {}) {
218
+ const { onClose } = params;
219
+ let clearShared = clearVerifiedCopiesByAdapter.get(adapter);
220
+ if (clearShared === undefined) {
221
+ clearShared = ()=>verifiedCopiesByAdapter.delete(adapter);
222
+ clearVerifiedCopiesByAdapter.set(adapter, clearShared);
223
+ }
224
+ onClose?.(clearShared);
225
+ const verifiedCopies = new Map();
226
+ const cachesFor = (address, methods)=>{
227
+ const caches = {};
228
+ // Whatever the methods: only self-certifying rows enter it, and those never consult them.
229
+ let byKey = verifiedCopiesByAdapter.get(adapter);
230
+ if (byKey === undefined) {
231
+ byKey = new Map();
232
+ verifiedCopiesByAdapter.set(adapter, byKey);
233
+ }
234
+ caches.shared = byKey.get(address) ?? new Map();
235
+ byKey.set(address, caches.shared);
236
+ if (db.isTransaction) {
237
+ let byMethods = verifiedCopies.get(address);
238
+ if (byMethods === undefined) {
239
+ byMethods = new Map();
240
+ verifiedCopies.set(address, byMethods);
241
+ }
242
+ caches.local = byMethods.get(methods) ?? new Map();
243
+ byMethods.set(methods, caches.local);
244
+ }
245
+ return caches;
246
+ };
247
+ const verifiedRowsFor = async (key, rows, methods)=>{
248
+ const address = JSON.stringify([
249
+ key.type,
250
+ key.owner,
251
+ key.id
252
+ ]);
253
+ // A key without rows keeps no cache entry, and with it no copy of its last row.
254
+ if (rows.length === 0) {
255
+ verifiedCopiesByAdapter.get(adapter)?.delete(address);
256
+ verifiedCopies.delete(address);
257
+ return [];
258
+ }
259
+ const verified = await verifiedRows(rows, cachesFor(address, methods), methods);
260
+ // Rows the shared cache may not hold leave its entry empty.
261
+ const byKey = verifiedCopiesByAdapter.get(adapter);
262
+ if (byKey?.get(address)?.size === 0) byKey.delete(address);
263
+ return verified;
264
+ };
265
+ const lockKeys = async (tx, keys)=>{
266
+ if (!(adapter instanceof AbstractPostgresAdapter)) return;
267
+ for (const key of [
268
+ ...new Set(keys)
269
+ ].sort(compareStrings)){
270
+ await sql`select pg_advisory_xact_lock(hashtextextended(${key}, 0))`.execute(tx);
271
+ }
272
+ };
273
+ const keyText = (key)=>JSON.stringify([
274
+ key.type,
275
+ key.owner,
276
+ key.id
277
+ ]);
278
+ const rowsFor = async (key)=>await db.selectFrom('kubun_graph_catalog_records').selectAll().where('type', '=', key.type).where('owner', '=', key.owner).where('id', '=', key.id).execute();
279
+ const decisionsFromRows = async (rows, methods)=>{
280
+ const groups = new Map();
281
+ for (const row of rows){
282
+ const id = JSON.stringify([
283
+ row.type,
284
+ row.owner,
285
+ row.id
286
+ ]);
287
+ const group = groups.get(id) ?? [];
288
+ group.push(row);
289
+ groups.set(id, group);
290
+ }
291
+ const decisions = [];
292
+ for (const group of groups.values()){
293
+ const first = group[0];
294
+ if (first === undefined) continue;
295
+ const key = {
296
+ type: first.type,
297
+ owner: first.owner,
298
+ id: first.id
299
+ };
300
+ const decision = decisionFromVerified(key, await verifiedRowsFor(key, group, methods));
301
+ if (decision !== null) decisions.push(decision);
302
+ }
303
+ return decisions;
304
+ };
305
+ return {
306
+ async getCatalogRecordDecision (key, methods) {
307
+ requireType(key.type);
308
+ return decisionFromVerified(key, await verifiedRowsFor(key, await rowsFor(key), methods));
309
+ },
310
+ async getCatalogRecordDecisions (keys, methods) {
311
+ for (const key of keys)requireType(key.type);
312
+ if (keys.length === 0) return [];
313
+ // The exact keys as one row-value list: an OR per key would nest one
314
+ // expression level each, past SQLite's depth limit for large batches.
315
+ const unique = new Map(keys.map((key)=>[
316
+ keyText(key),
317
+ key
318
+ ]));
319
+ const wanted = sql.join(Array.from(unique.values(), (key)=>sql`(${key.type}, ${key.owner}, ${key.id})`));
320
+ const rows = await db.selectFrom('kubun_graph_catalog_records').selectAll().where(sql`(${sql.ref('type')}, ${sql.ref('owner')}, ${sql.ref('id')}) in (values ${wanted})`).execute();
321
+ const rowsByKey = new Map();
322
+ for (const row of rows){
323
+ const text = keyText(row);
324
+ const group = rowsByKey.get(text) ?? [];
325
+ group.push(row);
326
+ rowsByKey.set(text, group);
327
+ }
328
+ const decisions = new Map();
329
+ for (const key of keys){
330
+ const text = keyText(key);
331
+ if (decisions.has(text)) continue;
332
+ decisions.set(text, decisionFromVerified(key, await verifiedRowsFor(key, rowsByKey.get(text) ?? [], methods)));
333
+ }
334
+ return keys.map((key)=>decisions.get(keyText(key)) ?? null);
335
+ },
336
+ async listCatalogRecordDecisions (types, methods) {
337
+ for (const type of types)requireType(type);
338
+ if (types.length === 0) return [];
339
+ const rows = await db.selectFrom('kubun_graph_catalog_records').selectAll().where('type', 'in', [
340
+ ...types
341
+ ]).orderBy('type').orderBy('owner').orderBy('id').execute();
342
+ return await decisionsFromRows(rows, methods);
343
+ },
344
+ async listCatalogRecordDecisionsForGroup (groupID, methods) {
345
+ const rows = await db.selectFrom('kubun_graph_catalog_records').selectAll().where('type', '=', 'catalog.exposure').where('group_id', '=', groupID).orderBy('owner').orderBy('id').execute();
346
+ return await decisionsFromRows(rows, methods);
347
+ },
348
+ async putCatalogRecordCopy (record, methods) {
349
+ requireType(record.type);
350
+ const op = await verifySignedRecord(record, methods);
351
+ if (op === null || !recordPayloadMatches(record, op.claim)) throw new GraphCatalogRecordError('Catalog record is not a valid signed copy');
352
+ const signerKey = recordSignerKey(op.issuer, record.opJWT);
353
+ if (signerKey === null) throw new GraphCatalogRecordError('Catalog signer key is invalid');
354
+ const key = {
355
+ type: record.type,
356
+ owner: record.owner,
357
+ id: record.id
358
+ };
359
+ const incomingHLC = record.type === 'catalog.exposure' ? op.claim.meta.version : op.claim.hlc;
360
+ if (typeof incomingHLC !== 'string' || parseCanonicalHLC(incomingHLC) === null) return false;
361
+ const incomingDigest = recordClaimDigest(op.claim);
362
+ const write = async (tx)=>{
363
+ await lockKeys(tx, [
364
+ keyText(key)
365
+ ]);
366
+ const rows = await tx.selectFrom('kubun_graph_catalog_records').selectAll().where('type', '=', key.type).where('owner', '=', key.owner).where('id', '=', key.id).execute();
367
+ const valid = await verifiedRowsFor(key, rows, methods);
368
+ const winner = winningRow(key, valid);
369
+ if (record.type === 'catalog' && record.payload !== null && winner !== null && winner.copy.payload === null) return false;
370
+ const incoming = {
371
+ op,
372
+ decisionHLC: incomingHLC,
373
+ contentDigest: incomingDigest
374
+ };
375
+ const retained = winner !== null && sameDecision(incoming, winner) ? valid.filter((entry)=>sameDecision(entry, incoming)) : [];
376
+ if (retained.length === 0 && !prefers(incoming, winner, catalogRecordPolicy(key.type))) return false;
377
+ const keepBySigner = new Map([
378
+ [
379
+ signerKey,
380
+ record.opHash
381
+ ]
382
+ ]);
383
+ for (const entry of retained){
384
+ const previous = keepBySigner.get(entry.signerKey);
385
+ if (previous === undefined || compareStrings(entry.copy.opHash, previous) < 0) {
386
+ keepBySigner.set(entry.signerKey, entry.copy.opHash);
387
+ }
388
+ }
389
+ for (const row of rows){
390
+ const entry = retained.find((candidate)=>candidate.row.op_hash === row.op_hash);
391
+ if (entry === undefined || keepBySigner.get(entry.signerKey) !== row.op_hash) {
392
+ await tx.deleteFrom('kubun_graph_catalog_records').where('type', '=', key.type).where('owner', '=', key.owner).where('id', '=', key.id).where('op_hash', '=', row.op_hash).execute();
393
+ }
394
+ }
395
+ if (keepBySigner.get(signerKey) !== record.opHash) return false;
396
+ const meta = op.claim.meta;
397
+ await tx.insertInto('kubun_graph_catalog_records').values({
398
+ type: record.type,
399
+ owner: record.owner,
400
+ id: record.id,
401
+ op_hash: record.opHash,
402
+ hlc: record.hlc,
403
+ op_jwt: record.opJWT,
404
+ payload: record.payload === null ? null : adapter.encodeBinary(record.payload),
405
+ catalog_id: typeof meta.catalogID === 'string' ? meta.catalogID : null,
406
+ group_id: typeof meta.groupID === 'string' ? meta.groupID : null,
407
+ circle_id: typeof meta.circleID === 'string' ? meta.circleID : null
408
+ }).onConflict((oc)=>oc.columns([
409
+ 'type',
410
+ 'owner',
411
+ 'id',
412
+ 'op_hash'
413
+ ]).doNothing()).execute();
414
+ return true;
415
+ };
416
+ const stored = db.isTransaction ? await write(db) : await db.transaction().execute(write);
417
+ if (stored && CUSTODY_TYPES.has(record.type)) params.onCustodyRecordStored?.();
418
+ return stored;
419
+ },
420
+ async pruneCatalogRecordCopy (key, signerDID, methods) {
421
+ requireType(key.type);
422
+ const write = async (tx)=>{
423
+ await lockKeys(tx, [
424
+ keyText(key)
425
+ ]);
426
+ const valid = await verifiedRowsFor(key, await tx.selectFrom('kubun_graph_catalog_records').selectAll().where('type', '=', key.type).where('owner', '=', key.owner).where('id', '=', key.id).execute(), methods);
427
+ if (valid.filter((entry)=>entry.op.issuer !== signerDID).length === 0) return;
428
+ for (const entry of valid){
429
+ if (entry.op.issuer !== signerDID) continue;
430
+ await tx.deleteFrom('kubun_graph_catalog_records').where('type', '=', key.type).where('owner', '=', key.owner).where('id', '=', key.id).where('op_hash', '=', entry.copy.opHash).execute();
431
+ }
432
+ };
433
+ if (db.isTransaction) await write(db);
434
+ else await db.transaction().execute(write);
435
+ },
436
+ async lockCatalogRecordKeys (keys) {
437
+ if (!db.isTransaction) throw new GraphCatalogRecordError('Catalog record locks require a transaction');
438
+ await lockKeys(db, keys);
439
+ }
440
+ };
441
+ }
442
+ export async function graphCatalogRecordAdapter(provider, methods) {
443
+ const graph = await provider.getStore('graph');
444
+ const records = {
445
+ isTransaction: provider.isTransactionBound,
446
+ async transaction (fn) {
447
+ return await provider.withTransaction(async (tx)=>await fn(await graphCatalogRecordAdapter(tx, methods)));
448
+ },
449
+ async get (key) {
450
+ requireType(key.type);
451
+ return await graph.getCatalogRecordDecision(key, methods);
452
+ },
453
+ async getMany (keys) {
454
+ for (const key of keys)requireType(key.type);
455
+ const decisions = new Map();
456
+ for (const chunk of chunked(keys)){
457
+ const found = await graph.getCatalogRecordDecisions(chunk, methods);
458
+ chunk.forEach((key, index)=>{
459
+ const decision = found[index];
460
+ if (decision != null) decisions.set(recordKeyText(key), decision);
461
+ });
462
+ }
463
+ return decisions;
464
+ },
465
+ async list (types) {
466
+ for (const type of types)requireType(type);
467
+ return await graph.listCatalogRecordDecisions(types, methods);
468
+ },
469
+ async listByGroup (type, groupID) {
470
+ if (type !== 'catalog.exposure') throw new GraphCatalogRecordTypeError(type);
471
+ return await graph.listCatalogRecordDecisionsForGroup(groupID, methods);
472
+ },
473
+ async putCopy (record) {
474
+ requireType(record.type);
475
+ await graph.putCatalogRecordCopy(record, methods);
476
+ },
477
+ async pruneCopy (key, signerDID) {
478
+ requireType(key.type);
479
+ await graph.pruneCatalogRecordCopy(key, signerDID, methods);
480
+ },
481
+ async lock (keys) {
482
+ for (const key of keys)requireType(key.type);
483
+ await graph.lockCatalogRecordKeys(keys.map((entry)=>JSON.stringify([
484
+ entry.type,
485
+ entry.key
486
+ ])));
487
+ }
488
+ };
489
+ graphStores.set(records, graph);
490
+ verificationMethods.set(records, methods);
491
+ return records;
492
+ }
493
+ const graphStores = new WeakMap();
494
+ const verificationMethods = new WeakMap();
495
+ export function graphStoreOfCatalogRecords(records) {
496
+ return graphStores.get(records) ?? null;
497
+ }
498
+ const VERIFIED_OPS_LIMIT = 4096;
499
+ // Ops of self-certifying issuers, by every record field verification reads: they verify the same
500
+ // under any methods. Callers verify copies a read already verified, to learn the op; recompute
501
+ // does so for every custody guard of every document.
502
+ const verifiedOps = new Map();
503
+ export async function verifyGraphCatalogRecord(records, record) {
504
+ const key = JSON.stringify([
505
+ record.opJWT,
506
+ record.type,
507
+ record.owner,
508
+ record.id,
509
+ record.hlc,
510
+ record.opHash
511
+ ]);
512
+ const known = verifiedOps.get(key);
513
+ if (known !== undefined) {
514
+ verifiedOps.delete(key);
515
+ verifiedOps.set(key, known);
516
+ return known;
517
+ }
518
+ const op = await verifySignedRecord(record, verificationMethods.get(records));
519
+ if (op !== null && isSelfCertifyingIssuer(op.issuer)) {
520
+ if (verifiedOps.size >= VERIFIED_OPS_LIMIT) {
521
+ const oldest = verifiedOps.keys().next().value;
522
+ if (oldest !== undefined) verifiedOps.delete(oldest);
523
+ }
524
+ verifiedOps.set(key, op);
525
+ }
526
+ return op;
527
+ }