@kokuin/records 0.1.0 → 0.2.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/apply.d.ts +12 -0
- package/lib/apply.js +146 -41
- package/lib/index.d.ts +1 -1
- package/lib/index.js +1 -1
- package/lib/limit.d.ts +7 -0
- package/lib/limit.js +29 -0
- package/lib/op.d.ts +15 -0
- package/lib/op.js +91 -9
- package/lib/store.d.ts +10 -1
- package/lib/store.js +16 -4
- package/lib/visibility.d.ts +3 -0
- package/lib/visibility.js +85 -13
- package/package.json +2 -2
package/lib/apply.d.ts
CHANGED
|
@@ -59,6 +59,12 @@ export type RecordAuthorizeContext = RecordReceiveContext & {
|
|
|
59
59
|
record: SignedRecord;
|
|
60
60
|
stored: SignedRecord | null;
|
|
61
61
|
store: RecordStoreAdapter;
|
|
62
|
+
/**
|
|
63
|
+
* Another record's decision as the chunk has left it, records stored earlier in the chunk
|
|
64
|
+
* included. Apply serves it from the chunk's batched reads. Absent outside apply: read
|
|
65
|
+
* `store` instead.
|
|
66
|
+
*/
|
|
67
|
+
decision?: (key: RecordKey) => Promise<RecordDecision | null>;
|
|
62
68
|
resolveController: (did: string) => Promise<string | null>;
|
|
63
69
|
verifyCapability: (tokens: ReadonlyArray<string>, owner: string, issuer: string) => Promise<boolean>;
|
|
64
70
|
};
|
|
@@ -87,6 +93,9 @@ export type RecordDependency = RecordKey & {
|
|
|
87
93
|
* the payload is not checked against its digest until after the merge decision.
|
|
88
94
|
* - `onEqual` must answer the same for (a, b) as for (b, a).
|
|
89
95
|
* - `afterApply` runs inside the chunk's transaction, once the record is stored.
|
|
96
|
+
*
|
|
97
|
+
* Apply keeps every decision it reads for the rest of the chunk and updates it with its own
|
|
98
|
+
* writes. Hooks may write other state through the store, never a record decision.
|
|
90
99
|
*/
|
|
91
100
|
export type RecordTypeDefinition = {
|
|
92
101
|
type: string;
|
|
@@ -150,6 +159,7 @@ export type ApplyRecordChunkParams = RecordReceiveContext & {
|
|
|
150
159
|
maxDriftMS: number;
|
|
151
160
|
receiveClock: (stamp: HLCTimestamp) => void;
|
|
152
161
|
methods?: MethodRegistry;
|
|
162
|
+
/** Called for several DIDs at once before the chunk locks; must accept concurrent calls. */
|
|
153
163
|
resolveController: (did: string) => Promise<string | null>;
|
|
154
164
|
verifyCapability: (tokens: ReadonlyArray<string>, owner: string, issuer: string) => Promise<boolean>;
|
|
155
165
|
};
|
|
@@ -167,6 +177,8 @@ export declare class RecordContentRefusalError extends Error {
|
|
|
167
177
|
}
|
|
168
178
|
/** Whether the store lacks a dependency, or holds an op for it that does not satisfy it. */
|
|
169
179
|
export declare function isDependencyUnsatisfied(dependency: RecordDependency, tx: RecordStoreAdapter, opKind: string, methods?: MethodRegistry): Promise<boolean>;
|
|
180
|
+
/** As {@link isDependencyUnsatisfied}, against a decision already read. */
|
|
181
|
+
export declare function isDependencyUnsatisfiedBy(dependency: RecordDependency, stored: RecordDecision | null, opKind: string, methods?: MethodRegistry): Promise<boolean>;
|
|
170
182
|
/**
|
|
171
183
|
* Apply a chunk of records received from a peer, in one transaction.
|
|
172
184
|
*
|
package/lib/apply.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { parseBoundedHLC } from './clock.js';
|
|
2
|
-
import {
|
|
2
|
+
import { settleLimited } from './limit.js';
|
|
3
|
+
import { createRecordVerifier, verifySignedRecord } from './op.js';
|
|
3
4
|
import { compareRecordOps } from './policy.js';
|
|
4
5
|
import { recordClaimDigest, recordPayloadMatches, recordSignerKey } from './record.js';
|
|
5
6
|
import { getRecordDecisions, recordKeyText } from './store.js';
|
|
@@ -47,23 +48,72 @@ import { getRecordDecisions, recordKeyText } from './store.js';
|
|
|
47
48
|
}
|
|
48
49
|
return ranks;
|
|
49
50
|
}
|
|
50
|
-
async function
|
|
51
|
-
const id = recordKeyText(key);
|
|
52
|
-
const cached = ctx.decisions.get(id);
|
|
53
|
-
if (cached !== undefined) return cached;
|
|
54
|
-
const decision = await ctx.tx.get(key);
|
|
51
|
+
async function snapshotOf(decision, ctx) {
|
|
55
52
|
const verified = await Promise.all((decision?.copies ?? []).map(async (copy)=>({
|
|
56
53
|
copy,
|
|
57
|
-
op: await
|
|
54
|
+
op: await ctx.verify(copy)
|
|
58
55
|
})));
|
|
59
|
-
|
|
56
|
+
return {
|
|
60
57
|
decision,
|
|
61
58
|
verified,
|
|
62
59
|
checkedPrior: false
|
|
63
60
|
};
|
|
61
|
+
}
|
|
62
|
+
async function decisionSnapshot(key, ctx) {
|
|
63
|
+
const id = recordKeyText(key);
|
|
64
|
+
const cached = ctx.decisions.get(id);
|
|
65
|
+
if (cached !== undefined) return cached;
|
|
66
|
+
let decision;
|
|
67
|
+
try {
|
|
68
|
+
decision = await ctx.tx.get(key);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
// After a failed batch, its error is the store's first failure in this transaction. On a
|
|
71
|
+
// store where a failed statement aborts the transaction, this read only reports that.
|
|
72
|
+
throw ctx.prefetchError ?? error;
|
|
73
|
+
}
|
|
74
|
+
const snapshot = await snapshotOf(decision, ctx);
|
|
64
75
|
ctx.decisions.set(id, snapshot);
|
|
65
76
|
return snapshot;
|
|
66
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* Loads the decisions of `keys` the chunk has not read yet, in one batched read. `known` holds
|
|
80
|
+
* decisions already read in this transaction. A failed batch seeds nothing: each key is then
|
|
81
|
+
* read on first use, and a failing read there reports the batch's error.
|
|
82
|
+
*/ async function prefetchSnapshots(keys, ctx, known, knownKeys) {
|
|
83
|
+
const missing = new Map();
|
|
84
|
+
for (const key of keys){
|
|
85
|
+
const id = recordKeyText(key);
|
|
86
|
+
if (!ctx.decisions.has(id) && !knownKeys.has(id)) missing.set(id, key);
|
|
87
|
+
}
|
|
88
|
+
let loaded = new Map();
|
|
89
|
+
try {
|
|
90
|
+
loaded = await getRecordDecisions(ctx.tx, [
|
|
91
|
+
...missing.values()
|
|
92
|
+
]);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
// Kept, not dropped: a later single read that fails reports this original error.
|
|
95
|
+
ctx.prefetchError = error;
|
|
96
|
+
missing.clear();
|
|
97
|
+
}
|
|
98
|
+
const ids = [
|
|
99
|
+
...new Set([
|
|
100
|
+
...knownKeys,
|
|
101
|
+
...missing.keys()
|
|
102
|
+
])
|
|
103
|
+
].filter((id)=>!ctx.decisions.has(id));
|
|
104
|
+
const snapshots = await Promise.all(ids.map((id)=>snapshotOf((knownKeys.has(id) ? known : loaded).get(id) ?? null, ctx)));
|
|
105
|
+
for (const [index, id] of ids.entries()){
|
|
106
|
+
const snapshot = snapshots[index];
|
|
107
|
+
if (snapshot !== undefined) ctx.decisions.set(id, snapshot);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** The decision a store read would return now: copies follow pruning as well as storing. */ function currentDecision(snapshot) {
|
|
111
|
+
if (snapshot.decision === null) return null;
|
|
112
|
+
return {
|
|
113
|
+
...snapshot.decision,
|
|
114
|
+
copies: snapshot.verified.map(({ copy })=>copy)
|
|
115
|
+
};
|
|
116
|
+
}
|
|
67
117
|
function rememberCopy(snapshot, record, op) {
|
|
68
118
|
const decision = snapshot.decision;
|
|
69
119
|
const signer = recordSignerKey(op.issuer, record.opJWT);
|
|
@@ -92,17 +142,22 @@ function rememberCopy(snapshot, record, op) {
|
|
|
92
142
|
if (decision === null) snapshot.checkedPrior = true;
|
|
93
143
|
}
|
|
94
144
|
/** Whether the store lacks a dependency, or holds an op for it that does not satisfy it. */ export async function isDependencyUnsatisfied(dependency, tx, opKind, methods) {
|
|
95
|
-
|
|
145
|
+
return await isDependencyUnsatisfiedBy(dependency, await tx.get(dependency), opKind, methods);
|
|
146
|
+
}
|
|
147
|
+
/** As {@link isDependencyUnsatisfied}, against a decision already read. */ export async function isDependencyUnsatisfiedBy(dependency, stored, opKind, methods) {
|
|
96
148
|
if (stored === null || stored.key.owner !== dependency.owner) return true;
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
101
|
-
return true;
|
|
149
|
+
// Verification never throws, so every copy is checked together, then matched in order.
|
|
150
|
+
const ops = await Promise.all(stored.copies.map((copy)=>verifySignedRecord(copy, opKind, methods)));
|
|
151
|
+
return !ops.some((op)=>op?.claim.type === dependency.type && op.claim.owner === dependency.owner && op.claim.id === dependency.id && (dependency.satisfiedBy?.(op) ?? true));
|
|
102
152
|
}
|
|
103
|
-
async function isParked(candidate,
|
|
104
|
-
|
|
105
|
-
|
|
153
|
+
async function isParked(candidate, ctx) {
|
|
154
|
+
const dependencies = ctx.dependencies.get(candidate) ?? candidate.definition.dependencies(candidate.record, candidate.op);
|
|
155
|
+
for (const dependency of dependencies){
|
|
156
|
+
// The chunk's view, as `isDependencyUnsatisfied` would read it from the store now.
|
|
157
|
+
const snapshot = await decisionSnapshot(dependency, ctx);
|
|
158
|
+
const stored = currentDecision(snapshot);
|
|
159
|
+
const satisfied = stored !== null && stored.key.owner === dependency.owner && snapshot.verified.some(({ op })=>op?.claim.type === dependency.type && op.claim.owner === dependency.owner && op.claim.id === dependency.id && (dependency.satisfiedBy?.(op) ?? true));
|
|
160
|
+
if (!satisfied) {
|
|
106
161
|
return true;
|
|
107
162
|
}
|
|
108
163
|
}
|
|
@@ -123,7 +178,7 @@ async function isParked(candidate, tx, opKind, methods) {
|
|
|
123
178
|
status: 'noop'
|
|
124
179
|
};
|
|
125
180
|
}
|
|
126
|
-
if (await isParked(candidate,
|
|
181
|
+
if (await isParked(candidate, ctx)) {
|
|
127
182
|
return {
|
|
128
183
|
status: 'parked'
|
|
129
184
|
};
|
|
@@ -141,10 +196,14 @@ async function isParked(candidate, tx, opKind, methods) {
|
|
|
141
196
|
reason: 'owner'
|
|
142
197
|
};
|
|
143
198
|
}
|
|
199
|
+
const decision = async (dependency)=>{
|
|
200
|
+
return currentDecision(await decisionSnapshot(dependency, ctx));
|
|
201
|
+
};
|
|
144
202
|
const authorized = await definition.authorize(op, {
|
|
145
203
|
record,
|
|
146
204
|
stored,
|
|
147
205
|
store: tx,
|
|
206
|
+
decision,
|
|
148
207
|
transportContext: ctx.transportContext,
|
|
149
208
|
resolveController: ctx.resolveController,
|
|
150
209
|
verifyCapability: ctx.verifyCapability
|
|
@@ -175,6 +234,7 @@ async function isParked(candidate, tx, opKind, methods) {
|
|
|
175
234
|
record: copy,
|
|
176
235
|
stored: record,
|
|
177
236
|
store: tx,
|
|
237
|
+
decision,
|
|
178
238
|
transportContext: ctx.transportContext,
|
|
179
239
|
resolveController: ctx.resolveController,
|
|
180
240
|
verifyCapability: ctx.verifyCapability
|
|
@@ -189,20 +249,20 @@ async function isParked(candidate, tx, opKind, methods) {
|
|
|
189
249
|
status: 'applied'
|
|
190
250
|
};
|
|
191
251
|
}
|
|
192
|
-
const
|
|
193
|
-
if (
|
|
252
|
+
const merge = compareRecordOps(op, storedOp, definition.policy, definition.onEqual);
|
|
253
|
+
if (merge.status === 'reject') {
|
|
194
254
|
return {
|
|
195
255
|
status: 'rejected',
|
|
196
|
-
reason:
|
|
256
|
+
reason: merge.reason ?? 'policy'
|
|
197
257
|
};
|
|
198
258
|
}
|
|
199
|
-
const conflict =
|
|
259
|
+
const conflict = merge.conflict === undefined ? undefined : {
|
|
200
260
|
record,
|
|
201
|
-
...
|
|
202
|
-
forked:
|
|
261
|
+
...merge.conflict,
|
|
262
|
+
forked: merge.status === 'fork'
|
|
203
263
|
};
|
|
204
264
|
// A fork still needs one op in the store, and the same one on every peer.
|
|
205
|
-
const wins =
|
|
265
|
+
const wins = merge.status === 'apply' || merge.status === 'fork' && storedOp !== null && recordClaimDigest(op.claim) > recordClaimDigest(storedOp.claim);
|
|
206
266
|
if (!wins) {
|
|
207
267
|
return {
|
|
208
268
|
status: 'noop',
|
|
@@ -267,12 +327,24 @@ class RefusedCandidate extends Error {
|
|
|
267
327
|
*/ export async function applyRecordChunk(params) {
|
|
268
328
|
const { records, store, types, nowMS, maxDriftMS, receiveClock } = params;
|
|
269
329
|
const ranks = rankTypes(types);
|
|
330
|
+
const verify = createRecordVerifier(params.opKind, params.methods);
|
|
270
331
|
const rejected = [];
|
|
271
332
|
const candidates = [];
|
|
272
333
|
// Refused records await one batched read of the store, which may show them as replays.
|
|
273
334
|
const refusedReplays = [];
|
|
274
|
-
for
|
|
335
|
+
// The clock and type checks run first for every record. Signatures of the records that pass
|
|
336
|
+
// them are verified together, since verification never throws; the rest runs in record order.
|
|
337
|
+
const screened = records.map((record)=>{
|
|
275
338
|
const stamp = parseBoundedHLC(record.hlc, nowMS, maxDriftMS);
|
|
339
|
+
const definition = stamp === null ? undefined : types.get(record.type);
|
|
340
|
+
return {
|
|
341
|
+
record,
|
|
342
|
+
stamp,
|
|
343
|
+
definition,
|
|
344
|
+
verifying: definition === undefined ? null : verify(record)
|
|
345
|
+
};
|
|
346
|
+
});
|
|
347
|
+
for (const { record, stamp, definition, verifying } of screened){
|
|
276
348
|
if (stamp === null) {
|
|
277
349
|
rejected.push({
|
|
278
350
|
record,
|
|
@@ -280,15 +352,14 @@ class RefusedCandidate extends Error {
|
|
|
280
352
|
});
|
|
281
353
|
continue;
|
|
282
354
|
}
|
|
283
|
-
|
|
284
|
-
if (definition === undefined) {
|
|
355
|
+
if (definition === undefined || verifying === null) {
|
|
285
356
|
rejected.push({
|
|
286
357
|
record,
|
|
287
358
|
reason: 'unknown-type'
|
|
288
359
|
});
|
|
289
360
|
continue;
|
|
290
361
|
}
|
|
291
|
-
const op = await
|
|
362
|
+
const op = await verifying;
|
|
292
363
|
if (op === null) {
|
|
293
364
|
rejected.push({
|
|
294
365
|
record,
|
|
@@ -338,25 +409,43 @@ class RefusedCandidate extends Error {
|
|
|
338
409
|
const allKeys = new Map();
|
|
339
410
|
const controllers = new Map();
|
|
340
411
|
const prefetched = await getRecordDecisions(store, active.map((candidate)=>candidate.record));
|
|
341
|
-
const verifiedStored = new
|
|
412
|
+
const verifiedStored = new Map();
|
|
342
413
|
const prefetchController = async (did)=>{
|
|
343
|
-
|
|
414
|
+
let resolving = controllers.get(did);
|
|
415
|
+
if (resolving === undefined) {
|
|
416
|
+
resolving = params.resolveController(did);
|
|
417
|
+
controllers.set(did, resolving);
|
|
418
|
+
}
|
|
419
|
+
await resolving;
|
|
344
420
|
};
|
|
345
|
-
|
|
421
|
+
const verifyStored = async (id)=>{
|
|
422
|
+
await Promise.all((prefetched.get(id)?.copies ?? []).map(async (copy)=>{
|
|
423
|
+
const op = await verify(copy);
|
|
424
|
+
if (op !== null && op.issuer !== op.claim.owner) await prefetchController(op.issuer);
|
|
425
|
+
}));
|
|
426
|
+
};
|
|
427
|
+
// Each candidate's controller and stored-copy reads run together, up to `CONCURRENCY_LIMIT`
|
|
428
|
+
// at once, and shared reads are made once. The lock-key hooks then run one at a time, in
|
|
429
|
+
// candidate order, as before. Failures are thrown in candidate order: a candidate's reads,
|
|
430
|
+
// then its hook.
|
|
431
|
+
const reads = await settleLimited(active, async (candidate)=>{
|
|
346
432
|
if (candidate.op.issuer !== candidate.op.claim.owner) {
|
|
347
433
|
await prefetchController(candidate.op.issuer);
|
|
348
434
|
}
|
|
349
435
|
const id = recordKeyText(candidate.record);
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
if (op !== null && op.issuer !== op.claim.owner) await prefetchController(op.issuer);
|
|
355
|
-
}
|
|
436
|
+
let storedChecked = verifiedStored.get(id);
|
|
437
|
+
if (storedChecked === undefined) {
|
|
438
|
+
storedChecked = verifyStored(id);
|
|
439
|
+
verifiedStored.set(id, storedChecked);
|
|
356
440
|
}
|
|
357
|
-
|
|
441
|
+
await storedChecked;
|
|
442
|
+
});
|
|
443
|
+
for (const [index, candidate] of active.entries()){
|
|
444
|
+
const read = reads[index];
|
|
445
|
+
if (read?.status === 'rejected') throw read.reason;
|
|
358
446
|
const hook = candidate.definition.lockKeys;
|
|
359
447
|
if (hook === undefined) continue;
|
|
448
|
+
const stored = prefetched.get(recordKeyText(candidate.record)) ?? null;
|
|
360
449
|
const keys = [
|
|
361
450
|
...new Map((await hook(candidate.record, stored)).map((key)=>[
|
|
362
451
|
JSON.stringify([
|
|
@@ -406,11 +495,27 @@ class RefusedCandidate extends Error {
|
|
|
406
495
|
tx,
|
|
407
496
|
opKind: params.opKind,
|
|
408
497
|
transportContext: params.transportContext,
|
|
409
|
-
resolveController: async (did)=>
|
|
498
|
+
resolveController: async (did)=>await (controllers.get(did) ?? params.resolveController(did)),
|
|
410
499
|
verifyCapability: params.verifyCapability,
|
|
411
500
|
methods: params.methods,
|
|
412
|
-
decisions: new Map()
|
|
501
|
+
decisions: new Map(),
|
|
502
|
+
dependencies: new Map(active.map((candidate)=>[
|
|
503
|
+
candidate,
|
|
504
|
+
candidate.definition.dependencies(candidate.record, candidate.op)
|
|
505
|
+
])),
|
|
506
|
+
// A fresh scope per transaction: a resolved issuer's key removed before the locks must
|
|
507
|
+
// not pass on a verification made before them. Self-certifying issuers still hit the
|
|
508
|
+
// process-wide memo.
|
|
509
|
+
verify: createRecordVerifier(params.opKind, params.methods)
|
|
413
510
|
};
|
|
511
|
+
// One batched read for every decision the chunk will consult: each record's own and its
|
|
512
|
+
// dependencies'. Writes made by this chunk update these snapshots in place.
|
|
513
|
+
await prefetchSnapshots(active.flatMap((candidate)=>[
|
|
514
|
+
candidate.record,
|
|
515
|
+
...ctx.dependencies.get(candidate) ?? []
|
|
516
|
+
]), ctx, checked, new Set([
|
|
517
|
+
...expected.keys()
|
|
518
|
+
].map((candidate)=>recordKeyText(candidate.record))));
|
|
414
519
|
const applied = [];
|
|
415
520
|
const conflicts = [];
|
|
416
521
|
const refused = [];
|
package/lib/index.d.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
export { type ApplyRecordChunkParams, type ApplyResult, applyRecordChunk, type RecordAuthorizeContext, type RecordConflict, RecordContentRefusalError, type RecordDependency, type RecordReceiveContext, type RecordRequester, RecordSourceChangedError, type RecordTypeDefinition, RecordTypeGraphError, type VisibilityContext, } from './apply.js';
|
|
8
8
|
export { compareRecordDecisionHLC, parseBoundedHLC, parseCanonicalHLC } from './clock.js';
|
|
9
9
|
export { type RecordDecision, recordDecisionDigest, recordDecisionFromVerifiedCopies, type VerifiedRecordCopy, } from './decision.js';
|
|
10
|
-
export { RecordOpSigningError, signRecordOp, type VerifiedRecordOp, verifyRecordOp, verifySignedRecord, } from './op.js';
|
|
10
|
+
export { createRecordVerifier, RECORD_OP_MEMO_SIZE, RecordOpSigningError, signRecordOp, type VerifiedRecordOp, verifyRecordOp, verifySignedRecord, } from './op.js';
|
|
11
11
|
export { compareRecordOps, type EqualVersionDecision, type MergeDecision, type MergePolicy, } from './policy.js';
|
|
12
12
|
export { assertListedLeaf, fingerprintRange, RANGE_LEAF_THRESHOLD, RANGE_SPLIT, type RangeBounds, type RangeLeaf, type RangeLeafList, type RangeSummary, RecordRangeError, rangeContains, rangeLeaf, rangePosition, splitRange, summarizeRange, } from './range.js';
|
|
13
13
|
export { advanceRecordPull, beginRecordPull, nextRequesterSteps, type RangeAnswer, type RecordPullState, type RequesterStep, } from './reconcile.js';
|
package/lib/index.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/ export { applyRecordChunk, RecordContentRefusalError, RecordSourceChangedError, RecordTypeGraphError } from './apply.js';
|
|
7
7
|
export { compareRecordDecisionHLC, parseBoundedHLC, parseCanonicalHLC } from './clock.js';
|
|
8
8
|
export { recordDecisionDigest, recordDecisionFromVerifiedCopies } from './decision.js';
|
|
9
|
-
export { RecordOpSigningError, signRecordOp, verifyRecordOp, verifySignedRecord } from './op.js';
|
|
9
|
+
export { createRecordVerifier, RECORD_OP_MEMO_SIZE, RecordOpSigningError, signRecordOp, verifyRecordOp, verifySignedRecord } from './op.js';
|
|
10
10
|
export { compareRecordOps } from './policy.js';
|
|
11
11
|
export { assertListedLeaf, fingerprintRange, RANGE_LEAF_THRESHOLD, RANGE_SPLIT, RecordRangeError, rangeContains, rangeLeaf, rangePosition, splitRange, summarizeRange } from './range.js';
|
|
12
12
|
export { advanceRecordPull, beginRecordPull, nextRequesterSteps } from './reconcile.js';
|
package/lib/limit.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Most tasks a records call runs at once, so a large page cannot fill a connection pool. */
|
|
2
|
+
export declare const CONCURRENCY_LIMIT = 16;
|
|
3
|
+
/**
|
|
4
|
+
* Runs `task` over `items`, at most `limit` at a time, starting them in item order. Every task
|
|
5
|
+
* settles; results keep item order.
|
|
6
|
+
*/
|
|
7
|
+
export declare function settleLimited<Item, Result>(items: ReadonlyArray<Item>, task: (item: Item, index: number) => Promise<Result>, limit?: number): Promise<Array<PromiseSettledResult<Result>>>;
|
package/lib/limit.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Most tasks a records call runs at once, so a large page cannot fill a connection pool. */ export const CONCURRENCY_LIMIT = 16;
|
|
2
|
+
/**
|
|
3
|
+
* Runs `task` over `items`, at most `limit` at a time, starting them in item order. Every task
|
|
4
|
+
* settles; results keep item order.
|
|
5
|
+
*/ export async function settleLimited(items, task, limit = CONCURRENCY_LIMIT) {
|
|
6
|
+
const results = new Array(items.length);
|
|
7
|
+
let next = 0;
|
|
8
|
+
const worker = async ()=>{
|
|
9
|
+
while(next < items.length){
|
|
10
|
+
const index = next;
|
|
11
|
+
next += 1;
|
|
12
|
+
try {
|
|
13
|
+
results[index] = {
|
|
14
|
+
status: 'fulfilled',
|
|
15
|
+
value: await task(items[index], index)
|
|
16
|
+
};
|
|
17
|
+
} catch (reason) {
|
|
18
|
+
results[index] = {
|
|
19
|
+
status: 'rejected',
|
|
20
|
+
reason
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
await Promise.all(Array.from({
|
|
26
|
+
length: Math.min(limit, items.length)
|
|
27
|
+
}, worker));
|
|
28
|
+
return results;
|
|
29
|
+
}
|
package/lib/op.d.ts
CHANGED
|
@@ -6,6 +6,10 @@ export type VerifiedRecordOp = {
|
|
|
6
6
|
claim: RecordClaim;
|
|
7
7
|
opHash: string;
|
|
8
8
|
};
|
|
9
|
+
/** How many verified record ops {@link verifyRecordOp} keeps, least recently used evicted first. */
|
|
10
|
+
export declare const RECORD_OP_MEMO_SIZE = 1024;
|
|
11
|
+
/** Forget every remembered op. Test-only; not exported from the package. */
|
|
12
|
+
export declare function clearRecordOpMemo(): void;
|
|
9
13
|
/** Thrown when an identity signs an envelope that record-op verification cannot accept. */
|
|
10
14
|
export declare class RecordOpSigningError extends Error {
|
|
11
15
|
constructor(message: string);
|
|
@@ -14,6 +18,11 @@ export declare class RecordOpSigningError extends Error {
|
|
|
14
18
|
* Sign a record claim under the caller's required replay domain `opKind`. The token carries the canonical claim bytes rather than the claim
|
|
15
19
|
* as JSON members, so what a verifier reads back is byte-for-byte what was signed.
|
|
16
20
|
*
|
|
21
|
+
* Calls for one `SigningIdentity` object reach it one at a time, in the order they were made.
|
|
22
|
+
* The queue is per object: two objects for the same key are not serialised against each other.
|
|
23
|
+
* `signToken` must settle, since a call that never settles holds every later call for that
|
|
24
|
+
* object.
|
|
25
|
+
*
|
|
17
26
|
* @throws RecordClaimError when the claim is malformed.
|
|
18
27
|
* @throws RecordOpSigningError when the identity produces an unverifiable record-op envelope.
|
|
19
28
|
*/
|
|
@@ -39,3 +48,9 @@ export declare function verifyRecordOp(opJWT: string, opKind: string, methods?:
|
|
|
39
48
|
* it after the merge decision.
|
|
40
49
|
*/
|
|
41
50
|
export declare function verifySignedRecord(record: SignedRecord, opKind: string, methods?: MethodRegistry): Promise<VerifiedRecordOp | null>;
|
|
51
|
+
/**
|
|
52
|
+
* {@link verifySignedRecord} that verifies each distinct op token once for the verifier's
|
|
53
|
+
* lifetime, whatever its issuer's DID method. Scope one to a single operation, such as one apply
|
|
54
|
+
* chunk: a resolved issuer's keys may rotate between operations.
|
|
55
|
+
*/
|
|
56
|
+
export declare function createRecordVerifier(opKind: string, methods?: MethodRegistry): (record: SignedRecord) => Promise<VerifiedRecordOp | null>;
|
package/lib/op.js
CHANGED
|
@@ -1,7 +1,31 @@
|
|
|
1
|
-
import { isVerifiedToken, normalizeDID, stringifyToken, verifyToken } from '@kokuin/token';
|
|
1
|
+
import { findMethodResolver, isVerifiedToken, normalizeDID, stringifyToken, verifyToken } from '@kokuin/token';
|
|
2
2
|
import { blake3 } from '@noble/hashes/blake3.js';
|
|
3
3
|
import { fromB64U, fromUTF, toB64U, toUTF } from '@sozai/codec';
|
|
4
4
|
import { canonicalRecordClaim, isRecordClaim } from './record.js';
|
|
5
|
+
/** How many verified record ops {@link verifyRecordOp} keeps, least recently used evicted first. */ export const RECORD_OP_MEMO_SIZE = 1024;
|
|
6
|
+
// Module-level, so every store, adapter and chunk shares it. Only self-certifying issuers are kept:
|
|
7
|
+
// an op token has no time claims, so verifying it is a pure function of its bytes and the op kind.
|
|
8
|
+
const opMemo = new Map();
|
|
9
|
+
/**
|
|
10
|
+
* Whether `iss` resolves from the token alone: `did:key` carries its key and a `did:peer:4`
|
|
11
|
+
* document is fixed by its hash. A registry entry for the method takes over resolution, and its
|
|
12
|
+
* keys may rotate, so such an issuer is never remembered across calls.
|
|
13
|
+
*/ function selfCertifying(iss, methods) {
|
|
14
|
+
if (!iss.startsWith('did:key:') && !iss.startsWith('did:peer:4')) return false;
|
|
15
|
+
return methods == null || findMethodResolver(methods, iss) === undefined;
|
|
16
|
+
}
|
|
17
|
+
/** A caller may change what it gets back. Never hand out the remembered object itself. */ function copyOp(op) {
|
|
18
|
+
// A claim is decoded from canonical JSON, so a JSON round trip copies it exactly.
|
|
19
|
+
const claim = JSON.parse(JSON.stringify(op.claim));
|
|
20
|
+
return {
|
|
21
|
+
issuer: op.issuer,
|
|
22
|
+
claim,
|
|
23
|
+
opHash: op.opHash
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/** Forget every remembered op. Test-only; not exported from the package. */ export function clearRecordOpMemo() {
|
|
27
|
+
opMemo.clear();
|
|
28
|
+
}
|
|
5
29
|
/** Thrown when an identity signs an envelope that record-op verification cannot accept. */ export class RecordOpSigningError extends Error {
|
|
6
30
|
constructor(message){
|
|
7
31
|
super(message);
|
|
@@ -51,20 +75,35 @@ function hasRecordOpEnvelope(payload, header, opKind) {
|
|
|
51
75
|
const kid = header.kid;
|
|
52
76
|
return !(header.alg !== 'EdDSA' || header.typ !== 'JWT' || (kid === undefined ? headerKeys.length !== 2 || headerKeys[0] !== 'alg' || headerKeys[1] !== 'typ' : typeof kid !== 'string' || headerKeys.length !== 3 || headerKeys[0] !== 'alg' || headerKeys[1] !== 'kid' || headerKeys[2] !== 'typ') || iss.startsWith('did:key:') && kid !== undefined || (iss.startsWith('did:peer:4') || iss.startsWith('did:kokuin:')) && kid === undefined || iss.startsWith('did:peer:4') && normalizeDID(iss) === iss);
|
|
53
77
|
}
|
|
78
|
+
// The last signing call queued for each identity. A hardware or prompting signer must never
|
|
79
|
+
// see overlapping calls, so each call waits for the one before it, settled either way.
|
|
80
|
+
const signingQueues = new WeakMap();
|
|
54
81
|
/**
|
|
55
82
|
* Sign a record claim under the caller's required replay domain `opKind`. The token carries the canonical claim bytes rather than the claim
|
|
56
83
|
* as JSON members, so what a verifier reads back is byte-for-byte what was signed.
|
|
57
84
|
*
|
|
85
|
+
* Calls for one `SigningIdentity` object reach it one at a time, in the order they were made.
|
|
86
|
+
* The queue is per object: two objects for the same key are not serialised against each other.
|
|
87
|
+
* `signToken` must settle, since a call that never settles holds every later call for that
|
|
88
|
+
* object.
|
|
89
|
+
*
|
|
58
90
|
* @throws RecordClaimError when the claim is malformed.
|
|
59
91
|
* @throws RecordOpSigningError when the identity produces an unverifiable record-op envelope.
|
|
60
92
|
*/ export async function signRecordOp(identity, claim, opKind) {
|
|
61
93
|
const bytes = canonicalRecordClaim(claim);
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
94
|
+
const previous = signingQueues.get(identity) ?? Promise.resolve();
|
|
95
|
+
// Queued entries never reject, so a failed call does not stop the next one.
|
|
96
|
+
const signing = previous.then(()=>{
|
|
97
|
+
return identity.signToken({
|
|
98
|
+
kind: opKind,
|
|
99
|
+
claim: toB64U(bytes)
|
|
100
|
+
}, {
|
|
101
|
+
embedLongForm: true
|
|
102
|
+
});
|
|
67
103
|
});
|
|
104
|
+
// The queue only orders calls. Each caller sees its own failure.
|
|
105
|
+
signingQueues.set(identity, signing.catch(()=>{}));
|
|
106
|
+
const signed = await signing;
|
|
68
107
|
const opJWT = stringifyToken(signed);
|
|
69
108
|
if (!isCanonicalCompactToken(opJWT) || !hasRecordOpEnvelope(signed.payload, signed.header, opKind) || typeof signed.payload.iss !== 'string' || normalizeDID(signed.payload.iss) !== normalizeDID(identity.id) || signed.payload.claim !== toB64U(bytes)) {
|
|
70
109
|
throw new RecordOpSigningError('Identity produced a record op envelope that cannot be verified');
|
|
@@ -101,6 +140,19 @@ function decodeClaim(encoded) {
|
|
|
101
140
|
if (!isCanonicalCompactToken(opJWT)) {
|
|
102
141
|
return null;
|
|
103
142
|
}
|
|
143
|
+
const opHash = recordOpHash(opJWT);
|
|
144
|
+
// Keyed on the token's digest: the same bytes under the same kind verify the same way.
|
|
145
|
+
const memoKey = JSON.stringify([
|
|
146
|
+
opKind,
|
|
147
|
+
opHash
|
|
148
|
+
]);
|
|
149
|
+
const known = opMemo.get(memoKey);
|
|
150
|
+
if (known !== undefined && selfCertifying(known.iss, methods)) {
|
|
151
|
+
// Refreshed, so the least recently used entry is the one evicted.
|
|
152
|
+
opMemo.delete(memoKey);
|
|
153
|
+
opMemo.set(memoKey, known);
|
|
154
|
+
return copyOp(known.op);
|
|
155
|
+
}
|
|
104
156
|
let verified;
|
|
105
157
|
try {
|
|
106
158
|
verified = await verifyToken(opJWT, {
|
|
@@ -119,11 +171,23 @@ function decodeClaim(encoded) {
|
|
|
119
171
|
if (decoded === null) {
|
|
120
172
|
return null;
|
|
121
173
|
}
|
|
122
|
-
|
|
174
|
+
const op = {
|
|
123
175
|
issuer: normalizeDID(iss),
|
|
124
176
|
claim: decoded,
|
|
125
|
-
opHash
|
|
177
|
+
opHash
|
|
126
178
|
};
|
|
179
|
+
// Only successes are kept. A refusal is recomputed on every call.
|
|
180
|
+
if (selfCertifying(iss, methods)) {
|
|
181
|
+
opMemo.set(memoKey, {
|
|
182
|
+
iss,
|
|
183
|
+
op: copyOp(op)
|
|
184
|
+
});
|
|
185
|
+
if (opMemo.size > RECORD_OP_MEMO_SIZE) {
|
|
186
|
+
const oldest = opMemo.keys().next().value;
|
|
187
|
+
if (oldest !== undefined) opMemo.delete(oldest);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return op;
|
|
127
191
|
}
|
|
128
192
|
/**
|
|
129
193
|
* Verify a stored or received record's op and check that the record's indexed fields and
|
|
@@ -133,7 +197,9 @@ function decodeClaim(encoded) {
|
|
|
133
197
|
* The payload is not checked here: `recordPayloadMatches` does that, so a caller can order
|
|
134
198
|
* it after the merge decision.
|
|
135
199
|
*/ export async function verifySignedRecord(record, opKind, methods) {
|
|
136
|
-
|
|
200
|
+
return bindRecord(record, await verifyRecordOp(record.opJWT, opKind, methods));
|
|
201
|
+
}
|
|
202
|
+
function bindRecord(record, verified) {
|
|
137
203
|
if (verified === null) {
|
|
138
204
|
return null;
|
|
139
205
|
}
|
|
@@ -143,3 +209,19 @@ function decodeClaim(encoded) {
|
|
|
143
209
|
}
|
|
144
210
|
return verified;
|
|
145
211
|
}
|
|
212
|
+
/**
|
|
213
|
+
* {@link verifySignedRecord} that verifies each distinct op token once for the verifier's
|
|
214
|
+
* lifetime, whatever its issuer's DID method. Scope one to a single operation, such as one apply
|
|
215
|
+
* chunk: a resolved issuer's keys may rotate between operations.
|
|
216
|
+
*/ export function createRecordVerifier(opKind, methods) {
|
|
217
|
+
const ops = new Map();
|
|
218
|
+
return async (record)=>{
|
|
219
|
+
let pending = ops.get(record.opJWT);
|
|
220
|
+
if (pending === undefined) {
|
|
221
|
+
pending = verifyRecordOp(record.opJWT, opKind, methods);
|
|
222
|
+
ops.set(record.opJWT, pending);
|
|
223
|
+
}
|
|
224
|
+
const op = await pending;
|
|
225
|
+
return bindRecord(record, op === null ? null : copyOp(op));
|
|
226
|
+
};
|
|
227
|
+
}
|
package/lib/store.d.ts
CHANGED
|
@@ -27,6 +27,10 @@ export type RecordStoreAdapter = {
|
|
|
27
27
|
/** True when already bound to a caller-owned transaction; required for inline transaction adapters. */
|
|
28
28
|
isTransaction?: boolean;
|
|
29
29
|
transaction<R>(fn: (tx: RecordStoreAdapter) => Promise<R>): Promise<R>;
|
|
30
|
+
/**
|
|
31
|
+
* Must accept concurrent calls on one adapter, including one bound to a transaction:
|
|
32
|
+
* `getRecordDecisions` and apply issue several at once when `getMany` is absent.
|
|
33
|
+
*/
|
|
30
34
|
get(key: RecordKey): Promise<RecordDecision | null>;
|
|
31
35
|
getMany?(keys: ReadonlyArray<RecordKey>): Promise<RecordDecisionMap>;
|
|
32
36
|
list(types: ReadonlyArray<string>): Promise<Array<RecordDecision>>;
|
|
@@ -40,5 +44,10 @@ export type RecordStoreAdapter = {
|
|
|
40
44
|
};
|
|
41
45
|
/** The address of a record key in a {@link RecordDecisionMap}. */
|
|
42
46
|
export declare function recordKeyText(key: RecordKey): string;
|
|
43
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* The stored decisions of `keys`, through `getMany` when the store has it, else concurrent `get`s,
|
|
49
|
+
* at most `CONCURRENCY_LIMIT` at once.
|
|
50
|
+
* The fallback waits for every `get`, so none is left running in the caller's transaction, then
|
|
51
|
+
* throws the first failure in key order.
|
|
52
|
+
*/
|
|
44
53
|
export declare function getRecordDecisions(store: RecordStoreAdapter, keys: ReadonlyArray<RecordKey>): Promise<RecordDecisionMap>;
|
package/lib/store.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { settleLimited } from './limit.js';
|
|
1
2
|
/** The address of a record key in a {@link RecordDecisionMap}. */ export function recordKeyText(key) {
|
|
2
3
|
return JSON.stringify([
|
|
3
4
|
key.type,
|
|
@@ -5,7 +6,12 @@
|
|
|
5
6
|
key.id
|
|
6
7
|
]);
|
|
7
8
|
}
|
|
8
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* The stored decisions of `keys`, through `getMany` when the store has it, else concurrent `get`s,
|
|
11
|
+
* at most `CONCURRENCY_LIMIT` at once.
|
|
12
|
+
* The fallback waits for every `get`, so none is left running in the caller's transaction, then
|
|
13
|
+
* throws the first failure in key order.
|
|
14
|
+
*/ export async function getRecordDecisions(store, keys) {
|
|
9
15
|
const unique = new Map();
|
|
10
16
|
for (const key of keys){
|
|
11
17
|
const text = recordKeyText(key);
|
|
@@ -15,10 +21,16 @@
|
|
|
15
21
|
if (store.getMany !== undefined) return await store.getMany([
|
|
16
22
|
...unique.values()
|
|
17
23
|
]);
|
|
24
|
+
const entries = [
|
|
25
|
+
...unique
|
|
26
|
+
];
|
|
27
|
+
const found = await settleLimited(entries, ([, key])=>store.get(key));
|
|
18
28
|
const decisions = new Map();
|
|
19
|
-
for (const [
|
|
20
|
-
const
|
|
21
|
-
if (
|
|
29
|
+
for (const [index, [text]] of entries.entries()){
|
|
30
|
+
const result = found[index];
|
|
31
|
+
if (result === undefined) continue;
|
|
32
|
+
if (result.status === 'rejected') throw result.reason;
|
|
33
|
+
if (result.value != null) decisions.set(text, result.value);
|
|
22
34
|
}
|
|
23
35
|
return decisions;
|
|
24
36
|
}
|
package/lib/visibility.d.ts
CHANGED
|
@@ -55,5 +55,8 @@ export declare function visibilityRules(rules: ReadonlyArray<VisibilityRule>): (
|
|
|
55
55
|
*
|
|
56
56
|
* A responder runs this over its records before it fingerprints, splits or lists a range and
|
|
57
57
|
* before it answers a fetch, so a hidden record changes none of them.
|
|
58
|
+
*
|
|
59
|
+
* Records are checked together: the `visibleTo` hooks, and the context's resolvers and store,
|
|
60
|
+
* must accept concurrent calls. The first failure in record order is thrown.
|
|
58
61
|
*/
|
|
59
62
|
export declare function filterVisibleRecords(records: ReadonlyArray<SignedRecord>, types: ReadonlyMap<string, RecordTypeDefinition>, ctx: VisibilityContext): Promise<Array<SignedRecord>>;
|
package/lib/visibility.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { normalizeDID } from '@kokuin/token';
|
|
2
|
-
import {
|
|
2
|
+
import { isDependencyUnsatisfiedBy } from './apply.js';
|
|
3
|
+
import { settleLimited } from './limit.js';
|
|
3
4
|
import { verifySignedRecord } from './op.js';
|
|
4
5
|
import { compareRecordOps } from './policy.js';
|
|
5
6
|
import { recordClaimDigest, recordPayloadMatches } from './record.js';
|
|
7
|
+
import { getRecordDecisions, recordKeyText } from './store.js';
|
|
6
8
|
function normalizedOrNull(did) {
|
|
7
9
|
if (typeof did !== 'string') {
|
|
8
10
|
return null;
|
|
@@ -23,6 +25,13 @@ function stringsIn(value) {
|
|
|
23
25
|
/** A key version: a non-negative safe integer, so ordering it against another is exact. */ function isKeyVersion(value) {
|
|
24
26
|
return Number.isSafeInteger(value) && value >= 0;
|
|
25
27
|
}
|
|
28
|
+
const holdingIndexes = new WeakMap();
|
|
29
|
+
function holdingID(owner, keyID) {
|
|
30
|
+
return JSON.stringify([
|
|
31
|
+
owner,
|
|
32
|
+
keyID
|
|
33
|
+
]);
|
|
34
|
+
}
|
|
26
35
|
/**
|
|
27
36
|
* Whether the responder's stored row for a proof's record leaves the proof standing: none,
|
|
28
37
|
* one that no longer verifies (apply treats it as absent too), or a live row of the same
|
|
@@ -65,12 +74,19 @@ function stringsIn(value) {
|
|
|
65
74
|
return null;
|
|
66
75
|
}
|
|
67
76
|
// A proof is weighed against the row the responder holds for the same record, so a
|
|
68
|
-
// revoked, superseded or taken-over wrapping cannot stand in for the live one.
|
|
69
|
-
|
|
77
|
+
// revoked, superseded or taken-over wrapping cannot stand in for the live one. That row and
|
|
78
|
+
// the proof's dependencies are read in one batch.
|
|
79
|
+
const storedKey = {
|
|
70
80
|
type: proof.type,
|
|
71
81
|
owner: op.claim.owner,
|
|
72
82
|
id: proof.id
|
|
73
|
-
}
|
|
83
|
+
};
|
|
84
|
+
const dependencies = definition.dependencies(proof, op);
|
|
85
|
+
const decisions = await getRecordDecisions(ctx.store, [
|
|
86
|
+
storedKey,
|
|
87
|
+
...dependencies
|
|
88
|
+
]);
|
|
89
|
+
const storedDecision = decisions.get(recordKeyText(storedKey)) ?? null;
|
|
74
90
|
const storedRecord = storedDecision?.copies[0] ?? null;
|
|
75
91
|
const storedOp = storedRecord === null ? null : await verifySignedRecord(storedRecord, ctx.opKind, ctx.methods);
|
|
76
92
|
if (!storedRowAdmits(op, storedOp, definition)) {
|
|
@@ -78,10 +94,9 @@ function stringsIn(value) {
|
|
|
78
94
|
}
|
|
79
95
|
// A proof the responder cannot check for lack of a dependency earns nothing, the same as
|
|
80
96
|
// one that fails, rather than parking as it would in apply.
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
97
|
+
const unsatisfied = await Promise.all(dependencies.map((dependency)=>isDependencyUnsatisfiedBy(dependency, decisions.get(recordKeyText(dependency)) ?? null, ctx.opKind, ctx.methods)));
|
|
98
|
+
if (unsatisfied.includes(true)) {
|
|
99
|
+
return null;
|
|
85
100
|
}
|
|
86
101
|
const authorized = await ctx.authorizeProof(proof, storedOp === null ? null : storedRecord);
|
|
87
102
|
return authorized ? {
|
|
@@ -118,6 +133,36 @@ async function isKeyHolder(claim, ctx) {
|
|
|
118
133
|
if (typeof keyID !== 'string' || keyVersion !== undefined && !isKeyVersion(keyVersion) || ctx.holderTypes.size === 0) {
|
|
119
134
|
return false;
|
|
120
135
|
}
|
|
136
|
+
const index = holdingIndexes.get(ctx);
|
|
137
|
+
if (index !== undefined) {
|
|
138
|
+
const wanted = holdingID(claim.owner, keyID);
|
|
139
|
+
if (index.held.has(wanted)) {
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
index.records ??= ctx.store.list([
|
|
143
|
+
...ctx.holderTypes.keys()
|
|
144
|
+
]).then((holdings)=>[
|
|
145
|
+
...holdings.flatMap((decision)=>decision.copies),
|
|
146
|
+
...ctx.proofs
|
|
147
|
+
]);
|
|
148
|
+
const records = await index.records;
|
|
149
|
+
for (const [position, record] of records.entries()){
|
|
150
|
+
let check = index.checks[position];
|
|
151
|
+
if (check === undefined) {
|
|
152
|
+
check = checkedHolding(record, ctx);
|
|
153
|
+
index.checks[position] = check;
|
|
154
|
+
}
|
|
155
|
+
const holding = await check;
|
|
156
|
+
if (holding === null) {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
index.held.add(holdingID(holding.owner, holding.keyID));
|
|
160
|
+
if (holding.keyID === keyID && holding.owner === claim.owner) {
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
121
166
|
const holdings = await ctx.store.list([
|
|
122
167
|
...ctx.holderTypes.keys()
|
|
123
168
|
]);
|
|
@@ -202,13 +247,40 @@ async function satisfiesRule(rule, claim, ctx) {
|
|
|
202
247
|
*
|
|
203
248
|
* A responder runs this over its records before it fingerprints, splits or lists a range and
|
|
204
249
|
* before it answers a fetch, so a hidden record changes none of them.
|
|
250
|
+
*
|
|
251
|
+
* Records are checked together: the `visibleTo` hooks, and the context's resolvers and store,
|
|
252
|
+
* must accept concurrent calls. The first failure in record order is thrown.
|
|
205
253
|
*/ export async function filterVisibleRecords(records, types, ctx) {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
254
|
+
// One context for this call: holdings and key versions are read once, then indexed.
|
|
255
|
+
const versions = new Map();
|
|
256
|
+
const scoped = {
|
|
257
|
+
...ctx,
|
|
258
|
+
currentKeyVersion: (owner, keyID)=>{
|
|
259
|
+
const id = holdingID(owner, keyID);
|
|
260
|
+
let version = versions.get(id);
|
|
261
|
+
if (version === undefined) {
|
|
262
|
+
version = ctx.currentKeyVersion(owner, keyID);
|
|
263
|
+
versions.set(id, version);
|
|
264
|
+
}
|
|
265
|
+
return version;
|
|
211
266
|
}
|
|
267
|
+
};
|
|
268
|
+
holdingIndexes.set(scoped, {
|
|
269
|
+
checks: [],
|
|
270
|
+
held: new Set()
|
|
271
|
+
});
|
|
272
|
+
// Records are checked together, up to `CONCURRENCY_LIMIT` at once. They share the holding
|
|
273
|
+
// index, so each holding is still checked once. The first failure in record order is thrown.
|
|
274
|
+
const admitted = await settleLimited(records, async (record)=>{
|
|
275
|
+
const hook = types.get(record.type)?.visibleTo;
|
|
276
|
+
return hook !== undefined && await hook(record, scoped);
|
|
277
|
+
});
|
|
278
|
+
const visible = [];
|
|
279
|
+
for (const [index, record] of records.entries()){
|
|
280
|
+
const result = admitted[index];
|
|
281
|
+
if (result === undefined) continue;
|
|
282
|
+
if (result.status === 'rejected') throw result.reason;
|
|
283
|
+
if (result.value) visible.push(record);
|
|
212
284
|
}
|
|
213
285
|
return visible;
|
|
214
286
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kokuin/records",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Signed, owner-authorized replicated records with merge policies and range reconciliation",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"records",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"lib/*"
|
|
25
25
|
],
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@kokuin/token": "^0.6.
|
|
27
|
+
"@kokuin/token": "^0.6.3",
|
|
28
28
|
"@noble/hashes": "^2.4.0",
|
|
29
29
|
"@sozai/codec": "^0.4.3",
|
|
30
30
|
"@sozai/hlc": "^0.1.0",
|