@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 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 { verifySignedRecord } from './op.js';
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 decisionSnapshot(key, ctx) {
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 verifySignedRecord(copy, ctx.opKind, ctx.methods)
54
+ op: await ctx.verify(copy)
58
55
  })));
59
- const snapshot = {
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
- const stored = await tx.get(dependency);
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
- for (const copy of stored.copies){
98
- const op = await verifySignedRecord(copy, opKind, methods);
99
- if (op?.claim.type === dependency.type && op.claim.owner === dependency.owner && op.claim.id === dependency.id && (dependency.satisfiedBy?.(op) ?? true)) return false;
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, tx, opKind, methods) {
104
- for (const dependency of candidate.definition.dependencies(candidate.record, candidate.op)){
105
- if (await isDependencyUnsatisfied(dependency, tx, opKind, methods)) {
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, tx, ctx.opKind, ctx.methods)) {
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 decision = compareRecordOps(op, storedOp, definition.policy, definition.onEqual);
193
- if (decision.status === 'reject') {
252
+ const merge = compareRecordOps(op, storedOp, definition.policy, definition.onEqual);
253
+ if (merge.status === 'reject') {
194
254
  return {
195
255
  status: 'rejected',
196
- reason: decision.reason ?? 'policy'
256
+ reason: merge.reason ?? 'policy'
197
257
  };
198
258
  }
199
- const conflict = decision.conflict === undefined ? undefined : {
259
+ const conflict = merge.conflict === undefined ? undefined : {
200
260
  record,
201
- ...decision.conflict,
202
- forked: decision.status === 'fork'
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 = decision.status === 'apply' || decision.status === 'fork' && storedOp !== null && recordClaimDigest(op.claim) > recordClaimDigest(storedOp.claim);
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 (const record of records){
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
- const definition = types.get(record.type);
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 verifySignedRecord(record, params.opKind, params.methods);
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 Set();
412
+ const verifiedStored = new Map();
342
413
  const prefetchController = async (did)=>{
343
- if (!controllers.has(did)) controllers.set(did, await params.resolveController(did));
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
- for (const candidate of active){
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
- if (!verifiedStored.has(id)) {
351
- verifiedStored.add(id);
352
- for (const copy of prefetched.get(id)?.copies ?? []){
353
- const op = await verifySignedRecord(copy, params.opKind, params.methods);
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
- const stored = prefetched.get(id) ?? null;
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)=>controllers.has(did) ? controllers.get(did) ?? null : await params.resolveController(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 signed = await identity.signToken({
63
- kind: opKind,
64
- claim: toB64U(bytes)
65
- }, {
66
- embedLongForm: true
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
- return {
174
+ const op = {
123
175
  issuer: normalizeDID(iss),
124
176
  claim: decoded,
125
- opHash: recordOpHash(opJWT)
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
- const verified = await verifyRecordOp(record.opJWT, opKind, methods);
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
- /** The stored decisions of `keys`, through `getMany` when the store has it, else one `get` each. */
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
- /** The stored decisions of `keys`, through `getMany` when the store has it, else one `get` each. */ export async function getRecordDecisions(store, keys) {
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 [text, key] of unique){
20
- const decision = await store.get(key);
21
- if (decision !== null) decisions.set(text, decision);
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
  }
@@ -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 { isDependencyUnsatisfied } from './apply.js';
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
- const storedDecision = await ctx.store.get({
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
- for (const dependency of definition.dependencies(proof, op)){
82
- if (await isDependencyUnsatisfied(dependency, ctx.store, ctx.opKind, ctx.methods)) {
83
- return null;
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
- const visible = [];
207
- for (const record of records){
208
- const hook = types.get(record.type)?.visibleTo;
209
- if (hook !== undefined && await hook(record, ctx)) {
210
- visible.push(record);
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.1.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.2",
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",