@enbox/dwn-sdk-js 0.4.15 → 0.4.17
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/dist/browser.mjs +1 -1
- package/dist/browser.mjs.map +4 -4
- package/dist/esm/generated/precompiled-validators.js +734 -747
- package/dist/esm/generated/precompiled-validators.js.map +1 -1
- package/dist/esm/json-schemas/definitions.json +5 -0
- package/dist/esm/src/core/dwn-constant.js +2 -2
- package/dist/esm/src/core/dwn-error.js +0 -1
- package/dist/esm/src/core/dwn-error.js.map +1 -1
- package/dist/esm/src/core/protocol-authorization-validation.js +1 -24
- package/dist/esm/src/core/protocol-authorization-validation.js.map +1 -1
- package/dist/esm/src/core/protocol-authorization.js +1 -5
- package/dist/esm/src/core/protocol-authorization.js.map +1 -1
- package/dist/esm/src/core/validation-state-reader.js +2 -3
- package/dist/esm/src/core/validation-state-reader.js.map +1 -1
- package/dist/esm/src/handlers/messages-query.js +2 -29
- package/dist/esm/src/handlers/messages-query.js.map +1 -1
- package/dist/esm/src/handlers/messages-subscribe.js +52 -1
- package/dist/esm/src/handlers/messages-subscribe.js.map +1 -1
- package/dist/esm/src/handlers/records-count.js +18 -10
- package/dist/esm/src/handlers/records-count.js.map +1 -1
- package/dist/esm/src/handlers/records-query.js +10 -7
- package/dist/esm/src/handlers/records-query.js.map +1 -1
- package/dist/esm/src/handlers/records-subscribe.js +10 -7
- package/dist/esm/src/handlers/records-subscribe.js.map +1 -1
- package/dist/esm/src/handlers/records-write.js +8 -26
- package/dist/esm/src/handlers/records-write.js.map +1 -1
- package/dist/esm/src/index.js +1 -1
- package/dist/esm/src/index.js.map +1 -1
- package/dist/esm/src/interfaces/protocols-configure.js +4 -9
- package/dist/esm/src/interfaces/protocols-configure.js.map +1 -1
- package/dist/esm/src/store/index-level.js +143 -6
- package/dist/esm/src/store/index-level.js.map +1 -1
- package/dist/esm/src/store/message-store-level.js +162 -23
- package/dist/esm/src/store/message-store-level.js.map +1 -1
- package/dist/esm/src/store/storage-controller.js +1 -3
- package/dist/esm/src/store/storage-controller.js.map +1 -1
- package/dist/esm/src/types/protocols-types.js +0 -10
- package/dist/esm/src/types/protocols-types.js.map +1 -1
- package/dist/esm/src/utils/filter.js +35 -1
- package/dist/esm/src/utils/filter.js.map +1 -1
- package/dist/esm/src/utils/grant-key-coverage.js +4 -6
- package/dist/esm/src/utils/grant-key-coverage.js.map +1 -1
- package/dist/esm/src/utils/messages.js +40 -14
- package/dist/esm/src/utils/messages.js.map +1 -1
- package/dist/esm/src/utils/permission-scope.js +3 -9
- package/dist/esm/src/utils/permission-scope.js.map +1 -1
- package/dist/esm/src/utils/record-limit-occupancy.js +70 -202
- package/dist/esm/src/utils/record-limit-occupancy.js.map +1 -1
- package/dist/esm/src/utils/records.js +9 -9
- package/dist/esm/src/utils/records.js.map +1 -1
- package/dist/esm/src/utils/replication.js +14 -0
- package/dist/esm/src/utils/replication.js.map +1 -1
- package/dist/esm/tests/core/records-grant-authorization.spec.js +13 -0
- package/dist/esm/tests/core/records-grant-authorization.spec.js.map +1 -1
- package/dist/esm/tests/core/replication-replay-property.spec.js +2 -2
- package/dist/esm/tests/core/replication-replay-property.spec.js.map +1 -1
- package/dist/esm/tests/core/validation-read-closure.spec.js +1 -1
- package/dist/esm/tests/core/validation-read-closure.spec.js.map +1 -1
- package/dist/esm/tests/dwn.spec.js +2 -2
- package/dist/esm/tests/dwn.spec.js.map +1 -1
- package/dist/esm/tests/features/records-delivery.spec.js +2 -5
- package/dist/esm/tests/features/records-delivery.spec.js.map +1 -1
- package/dist/esm/tests/features/records-nested-query-scope.spec.js +138 -73
- package/dist/esm/tests/features/records-nested-query-scope.spec.js.map +1 -1
- package/dist/esm/tests/features/records-record-limit.spec.js +264 -95
- package/dist/esm/tests/features/records-record-limit.spec.js.map +1 -1
- package/dist/esm/tests/fuzz/filter.fuzz.spec.js +19 -4
- package/dist/esm/tests/fuzz/filter.fuzz.spec.js.map +1 -1
- package/dist/esm/tests/handlers/messages-query.spec.js +7 -15
- package/dist/esm/tests/handlers/messages-query.spec.js.map +1 -1
- package/dist/esm/tests/handlers/messages-subscribe.spec.js +129 -1
- package/dist/esm/tests/handlers/messages-subscribe.spec.js.map +1 -1
- package/dist/esm/tests/handlers/records-write.spec.js +57 -3
- package/dist/esm/tests/handlers/records-write.spec.js.map +1 -1
- package/dist/esm/tests/store/index-level.spec.js +114 -1
- package/dist/esm/tests/store/index-level.spec.js.map +1 -1
- package/dist/esm/tests/store/message-store-cross-context.spec.js +46 -0
- package/dist/esm/tests/store/message-store-cross-context.spec.js.map +1 -0
- package/dist/esm/tests/store/message-store-level.spec.js +27 -0
- package/dist/esm/tests/store/message-store-level.spec.js.map +1 -1
- package/dist/esm/tests/store/message-store.spec.js +81 -0
- package/dist/esm/tests/store/message-store.spec.js.map +1 -1
- package/dist/esm/tests/utils/filters.spec.js +25 -4
- package/dist/esm/tests/utils/filters.spec.js.map +1 -1
- package/dist/esm/tests/utils/permission-scope.spec.js +4 -0
- package/dist/esm/tests/utils/permission-scope.spec.js.map +1 -1
- package/dist/esm/tests/utils/records.spec.js +12 -0
- package/dist/esm/tests/utils/records.spec.js.map +1 -1
- package/dist/esm/tests/validation/json-schemas/records/records-write.spec.js +3 -1
- package/dist/esm/tests/validation/json-schemas/records/records-write.spec.js.map +1 -1
- package/dist/types/generated/precompiled-validators.d.ts.map +1 -1
- package/dist/types/src/core/dwn-constant.d.ts +2 -2
- package/dist/types/src/core/dwn-error.d.ts +0 -1
- package/dist/types/src/core/dwn-error.d.ts.map +1 -1
- package/dist/types/src/core/protocol-authorization-validation.d.ts +0 -10
- package/dist/types/src/core/protocol-authorization-validation.d.ts.map +1 -1
- package/dist/types/src/core/protocol-authorization.d.ts.map +1 -1
- package/dist/types/src/core/validation-state-reader.d.ts.map +1 -1
- package/dist/types/src/handlers/messages-query.d.ts +0 -2
- package/dist/types/src/handlers/messages-query.d.ts.map +1 -1
- package/dist/types/src/handlers/messages-subscribe.d.ts +16 -0
- package/dist/types/src/handlers/messages-subscribe.d.ts.map +1 -1
- package/dist/types/src/handlers/records-count.d.ts +1 -0
- package/dist/types/src/handlers/records-count.d.ts.map +1 -1
- package/dist/types/src/handlers/records-query.d.ts.map +1 -1
- package/dist/types/src/handlers/records-subscribe.d.ts.map +1 -1
- package/dist/types/src/handlers/records-write.d.ts +3 -0
- package/dist/types/src/handlers/records-write.d.ts.map +1 -1
- package/dist/types/src/index.d.ts +3 -3
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/interfaces/protocols-configure.d.ts.map +1 -1
- package/dist/types/src/store/index-level.d.ts +37 -0
- package/dist/types/src/store/index-level.d.ts.map +1 -1
- package/dist/types/src/store/message-store-level.d.ts +23 -6
- package/dist/types/src/store/message-store-level.d.ts.map +1 -1
- package/dist/types/src/store/storage-controller.d.ts.map +1 -1
- package/dist/types/src/types/message-store.d.ts +23 -2
- package/dist/types/src/types/message-store.d.ts.map +1 -1
- package/dist/types/src/types/messages-types.d.ts +14 -0
- package/dist/types/src/types/messages-types.d.ts.map +1 -1
- package/dist/types/src/types/protocols-types.d.ts +7 -24
- package/dist/types/src/types/protocols-types.d.ts.map +1 -1
- package/dist/types/src/types/query-types.d.ts +8 -1
- package/dist/types/src/types/query-types.d.ts.map +1 -1
- package/dist/types/src/types/records-types.d.ts +2 -1
- package/dist/types/src/types/records-types.d.ts.map +1 -1
- package/dist/types/src/utils/filter.d.ts +14 -1
- package/dist/types/src/utils/filter.d.ts.map +1 -1
- package/dist/types/src/utils/grant-key-coverage.d.ts.map +1 -1
- package/dist/types/src/utils/messages.d.ts +8 -0
- package/dist/types/src/utils/messages.d.ts.map +1 -1
- package/dist/types/src/utils/permission-scope.d.ts +0 -2
- package/dist/types/src/utils/permission-scope.d.ts.map +1 -1
- package/dist/types/src/utils/record-limit-occupancy.d.ts +25 -12
- package/dist/types/src/utils/record-limit-occupancy.d.ts.map +1 -1
- package/dist/types/src/utils/records.d.ts +3 -2
- package/dist/types/src/utils/records.d.ts.map +1 -1
- package/dist/types/src/utils/replication.d.ts +8 -1
- package/dist/types/src/utils/replication.d.ts.map +1 -1
- package/dist/types/tests/features/records-delivery.spec.d.ts.map +1 -1
- package/dist/types/tests/features/records-nested-query-scope.spec.d.ts.map +1 -1
- package/dist/types/tests/features/records-record-limit.spec.d.ts.map +1 -1
- package/dist/types/tests/handlers/messages-query.spec.d.ts.map +1 -1
- package/dist/types/tests/handlers/messages-subscribe.spec.d.ts.map +1 -1
- package/dist/types/tests/handlers/records-write.spec.d.ts.map +1 -1
- package/dist/types/tests/store/message-store-cross-context.spec.d.ts +2 -0
- package/dist/types/tests/store/message-store-cross-context.spec.d.ts.map +1 -0
- package/dist/types/tests/store/message-store.spec.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/core/dwn-constant.ts +2 -2
- package/src/core/dwn-error.ts +0 -2
- package/src/core/protocol-authorization-validation.ts +1 -33
- package/src/core/protocol-authorization.ts +0 -7
- package/src/core/validation-state-reader.ts +2 -3
- package/src/handlers/messages-query.ts +3 -41
- package/src/handlers/messages-subscribe.ts +67 -3
- package/src/handlers/records-count.ts +34 -13
- package/src/handlers/records-query.ts +14 -11
- package/src/handlers/records-subscribe.ts +18 -11
- package/src/handlers/records-write.ts +17 -32
- package/src/index.ts +3 -3
- package/src/interfaces/protocols-configure.ts +4 -13
- package/src/store/index-level.ts +197 -10
- package/src/store/message-store-level.ts +220 -27
- package/src/store/storage-controller.ts +1 -3
- package/src/types/message-store.ts +25 -2
- package/src/types/messages-types.ts +14 -0
- package/src/types/protocols-types.ts +7 -26
- package/src/types/query-types.ts +10 -2
- package/src/types/records-types.ts +2 -1
- package/src/utils/filter.ts +40 -3
- package/src/utils/grant-key-coverage.ts +4 -7
- package/src/utils/messages.ts +49 -14
- package/src/utils/permission-scope.ts +4 -11
- package/src/utils/record-limit-occupancy.ts +105 -293
- package/src/utils/records.ts +9 -9
- package/src/utils/replication.ts +19 -1
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
|
|
10
10
|
import { AbstractMessage } from '../core/abstract-message.js';
|
|
11
11
|
import { DwnConstant } from '../core/dwn-constant.js';
|
|
12
|
+
import { FilterUtility } from '../utils/filter.js';
|
|
12
13
|
import { Message } from '../core/message.js';
|
|
13
14
|
import { PermissionGrant } from '../protocols/permission-grant.js';
|
|
14
15
|
import { ProtocolsGrantAuthorization } from '../core/protocols-grant-authorization.js';
|
|
@@ -18,7 +19,7 @@ import { DwnError, DwnErrorCode } from '../core/dwn-error.js';
|
|
|
18
19
|
import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js';
|
|
19
20
|
import { getRoleAudienceContextId, getRuleSetAtPath, isCrossProtocolRef, parseCrossProtocolRef } from '../utils/protocols.js';
|
|
20
21
|
import { normalizeProtocolUrl, normalizeSchemaUrl, validateProtocolUrlNormalized, validateSchemaUrlNormalized } from '../utils/url.js';
|
|
21
|
-
import { ProtocolAction, ProtocolActor
|
|
22
|
+
import { ProtocolAction, ProtocolActor } from '../types/protocols-types.js';
|
|
22
23
|
|
|
23
24
|
export type ProtocolsConfigureOptions = {
|
|
24
25
|
messageTimestamp?: string;
|
|
@@ -300,7 +301,7 @@ export class ProtocolsConfigure extends AbstractMessage<ProtocolsConfigureMessag
|
|
|
300
301
|
|
|
301
302
|
// Validate $recordLimit
|
|
302
303
|
if (ruleSet.$recordLimit !== undefined) {
|
|
303
|
-
const { max
|
|
304
|
+
const { max } = ruleSet.$recordLimit;
|
|
304
305
|
|
|
305
306
|
if (!Number.isInteger(max) || max < 1) {
|
|
306
307
|
throw new DwnError(
|
|
@@ -315,15 +316,6 @@ export class ProtocolsConfigure extends AbstractMessage<ProtocolsConfigureMessag
|
|
|
315
316
|
`Invalid $recordLimit.max value ${max} at protocol path '${ruleSetProtocolPath}': must be <= ${DwnConstant.maxRecordLimit}.`
|
|
316
317
|
);
|
|
317
318
|
}
|
|
318
|
-
|
|
319
|
-
const validStrategies = Object.values(ProtocolRecordLimitStrategy) as string[];
|
|
320
|
-
if (!validStrategies.includes(strategy as string)) {
|
|
321
|
-
throw new DwnError(
|
|
322
|
-
DwnErrorCode.ProtocolsConfigureInvalidRecordLimit,
|
|
323
|
-
`Invalid $recordLimit.strategy '${strategy}' at protocol path '${ruleSetProtocolPath}': ` +
|
|
324
|
-
`must be one of ${validStrategies.join(', ')}.`
|
|
325
|
-
);
|
|
326
|
-
}
|
|
327
319
|
}
|
|
328
320
|
|
|
329
321
|
if (ruleSet.$tags !== undefined) {
|
|
@@ -412,8 +404,7 @@ export class ProtocolsConfigure extends AbstractMessage<ProtocolsConfigureMessag
|
|
|
412
404
|
ProtocolsConfigure.validateCrossProtocolAlias(actionRule.of, uses, ruleSetProtocolPath, 'of');
|
|
413
405
|
} else {
|
|
414
406
|
// Local `of`: must be self or ancestor
|
|
415
|
-
const isSelfOrAncestor =
|
|
416
|
-
|| ruleSetProtocolPath.startsWith(actionRule.of + '/');
|
|
407
|
+
const isSelfOrAncestor = FilterUtility.matchesSubtree(actionRule.of, ruleSetProtocolPath);
|
|
417
408
|
if (!isSelfOrAncestor) {
|
|
418
409
|
throw new DwnError(
|
|
419
410
|
DwnErrorCode.ProtocolsConfigureInvalidActionOfNotAnAncestor,
|
package/src/store/index-level.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { LevelWrapperBatchOperation, LevelWrapperIteratorOptions, } from '.
|
|
|
4
4
|
import { isEmptyObject } from '@enbox/common';
|
|
5
5
|
import { lexicographicalCompare } from '../utils/string.js';
|
|
6
6
|
import { SortDirection } from '../types/query-types.js';
|
|
7
|
+
import { assertValidSubtreeFilters, FilterSelector, FilterUtility, isSubtreeFilter } from '../utils/filter.js';
|
|
7
8
|
import {
|
|
8
9
|
countWithCompoundIndex,
|
|
9
10
|
createCompoundIndexDeleteOperation,
|
|
@@ -13,7 +14,6 @@ import {
|
|
|
13
14
|
} from './index-level-compound.js';
|
|
14
15
|
import { createLevelDatabase, LevelWrapper } from './level-wrapper.js';
|
|
15
16
|
import { DwnError, DwnErrorCode } from '../core/dwn-error.js';
|
|
16
|
-
import { FilterSelector, FilterUtility } from '../utils/filter.js';
|
|
17
17
|
|
|
18
18
|
export type IndexLevelConfig = {
|
|
19
19
|
location: string,
|
|
@@ -318,17 +318,12 @@ export class IndexLevel {
|
|
|
318
318
|
* @returns {IndexedItem[]} an array of `IndexedItem` that match the given filters.
|
|
319
319
|
*/
|
|
320
320
|
async query(tenant: string, filters: Filter[], queryOptions: QueryOptions, options?: IndexLevelOptions): Promise<IndexedItem[]> {
|
|
321
|
+
assertValidSubtreeFilters(filters);
|
|
321
322
|
|
|
322
323
|
// Strategy 1: try compound index for single-filter queries
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
return queryWithCompoundIndex(
|
|
327
|
-
this.db, tenant, filters[0], queryOptions, compoundResult,
|
|
328
|
-
IndexLevel.encodeValue, IndexLevel.delimiter,
|
|
329
|
-
this.queryWithIteratorPaging.bind(this), options
|
|
330
|
-
);
|
|
331
|
-
}
|
|
324
|
+
const compoundQuery = this.queryUsingCompoundIndex(tenant, filters, queryOptions, options);
|
|
325
|
+
if (compoundQuery !== undefined) {
|
|
326
|
+
return compoundQuery;
|
|
332
327
|
}
|
|
333
328
|
|
|
334
329
|
// Strategy 2: in-memory paging for concise filters
|
|
@@ -340,6 +335,47 @@ export class IndexLevel {
|
|
|
340
335
|
return this.queryWithIteratorPaging(tenant, filters, queryOptions, options);
|
|
341
336
|
}
|
|
342
337
|
|
|
338
|
+
/**
|
|
339
|
+
* Queries one bounded page without selecting the in-memory strategy.
|
|
340
|
+
*
|
|
341
|
+
* Projection callers use this when they must close the outer iterator before
|
|
342
|
+
* opening related index ranges. A matching compound index is preferred;
|
|
343
|
+
* otherwise the requested-sort index is streamed directly.
|
|
344
|
+
*/
|
|
345
|
+
async queryWithBoundedPaging(
|
|
346
|
+
tenant: string,
|
|
347
|
+
filters: Filter[],
|
|
348
|
+
queryOptions: QueryOptions,
|
|
349
|
+
options?: IndexLevelOptions,
|
|
350
|
+
): Promise<IndexedItem[]> {
|
|
351
|
+
assertValidSubtreeFilters(filters);
|
|
352
|
+
|
|
353
|
+
const compoundQuery = this.queryUsingCompoundIndex(tenant, filters, queryOptions, options);
|
|
354
|
+
return compoundQuery ?? this.queryWithIteratorPaging(tenant, filters, queryOptions, options);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private queryUsingCompoundIndex(
|
|
358
|
+
tenant: string,
|
|
359
|
+
filters: Filter[],
|
|
360
|
+
queryOptions: QueryOptions,
|
|
361
|
+
options?: IndexLevelOptions,
|
|
362
|
+
): Promise<IndexedItem[]> | undefined {
|
|
363
|
+
if (filters.length !== 1 || isEmptyObject(filters[0])) {
|
|
364
|
+
return undefined;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const compoundIndex = selectCompoundIndex(filters[0], queryOptions, this._compoundIndexes);
|
|
368
|
+
if (compoundIndex === undefined) {
|
|
369
|
+
return undefined;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return queryWithCompoundIndex(
|
|
373
|
+
this.db, tenant, filters[0], queryOptions, compoundIndex,
|
|
374
|
+
IndexLevel.encodeValue, IndexLevel.delimiter,
|
|
375
|
+
this.queryWithIteratorPaging.bind(this), options
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
343
379
|
/**
|
|
344
380
|
* Counts the number of items that match the given filters without loading full records.
|
|
345
381
|
*
|
|
@@ -348,6 +384,7 @@ export class IndexLevel {
|
|
|
348
384
|
*/
|
|
349
385
|
async count(tenant: string, filters: Filter[], queryOptions: Omit<QueryOptions, 'limit' | 'cursor'>,
|
|
350
386
|
options?: IndexLevelOptions): Promise<number> {
|
|
387
|
+
assertValidSubtreeFilters(filters);
|
|
351
388
|
|
|
352
389
|
// try compound index for single-filter queries
|
|
353
390
|
if (filters.length === 1 && !isEmptyObject(filters[0])) {
|
|
@@ -365,6 +402,117 @@ export class IndexLevel {
|
|
|
365
402
|
return results.length;
|
|
366
403
|
}
|
|
367
404
|
|
|
405
|
+
/**
|
|
406
|
+
* Finds the inclusive rank boundary for one `$recordLimit` parent group.
|
|
407
|
+
*
|
|
408
|
+
* The existing `parentId` index keeps the scan isolated to one nested-record
|
|
409
|
+
* group. Root records have no `parentId`, so they are read from the exact
|
|
410
|
+
* `protocolPath` range and explicitly fenced to rows without a parent. The
|
|
411
|
+
* bounded max-heap retains only the earliest `max` rank keys while the range
|
|
412
|
+
* is streamed; no group-sized array is materialized.
|
|
413
|
+
*/
|
|
414
|
+
public async findRecordLimitRankCutoff(
|
|
415
|
+
tenant: string,
|
|
416
|
+
candidateFilter: Filter,
|
|
417
|
+
parentId: string | undefined,
|
|
418
|
+
max: number,
|
|
419
|
+
options?: IndexLevelOptions,
|
|
420
|
+
): Promise<string | undefined> {
|
|
421
|
+
if (!Number.isInteger(max) || max < 1) {
|
|
422
|
+
throw new TypeError(`IndexLevel: record-limit max must be a positive integer.`);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const protocolPath = candidateFilter.protocolPath;
|
|
426
|
+
if (typeof protocolPath !== 'string') {
|
|
427
|
+
throw new TypeError(`IndexLevel: record-limit candidate filter must contain one protocolPath.`);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const indexName = parentId === undefined ? 'protocolPath' : 'parentId';
|
|
431
|
+
const indexValue = parentId ?? protocolPath;
|
|
432
|
+
const matchPrefix = IndexLevel.keySegmentJoin(IndexLevel.encodeValue(indexValue), '');
|
|
433
|
+
const partition = await this.getIndexPartition(tenant, indexName);
|
|
434
|
+
const rankHeap: string[] = [];
|
|
435
|
+
|
|
436
|
+
for await (const [key, value] of partition.iterator({ gt: matchPrefix }, options)) {
|
|
437
|
+
if (!key.startsWith(matchPrefix)) {
|
|
438
|
+
break;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const item = JSON.parse(value) as IndexedItem;
|
|
442
|
+
if ((parentId === undefined && item.indexes.parentId !== undefined) ||
|
|
443
|
+
!FilterUtility.matchFilter(item.indexes, candidateFilter)) {
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
IndexLevel.addToBoundedMaxHeap(rankHeap, IndexLevel.createRecordLimitRankKey(item.indexes), max);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return rankHeap[0];
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Builds the deterministic `$recordLimit` rank key: oldest creation time,
|
|
455
|
+
* then record ID. Message CID deliberately does not participate because
|
|
456
|
+
* occupancy belongs to the logical record, not a particular update.
|
|
457
|
+
*/
|
|
458
|
+
public static createRecordLimitRankKey(indexes: KeyValues): string {
|
|
459
|
+
const { dateCreated, recordId } = indexes;
|
|
460
|
+
if (typeof dateCreated !== 'string' || typeof recordId !== 'string') {
|
|
461
|
+
throw new TypeError(`IndexLevel: record-limit candidates require string dateCreated and recordId indexes.`);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return IndexLevel.keySegmentJoin(IndexLevel.encodeValue(dateCreated), IndexLevel.encodeValue(recordId));
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
private static addToBoundedMaxHeap(heap: string[], value: string, max: number): void {
|
|
468
|
+
if (heap.length < max) {
|
|
469
|
+
heap.push(value);
|
|
470
|
+
IndexLevel.siftRecordLimitRankUp(heap, heap.length - 1);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (lexicographicalCompare(value, heap[0]) >= 0) {
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
heap[0] = value;
|
|
479
|
+
IndexLevel.siftRecordLimitRankDown(heap, 0);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
private static siftRecordLimitRankUp(heap: string[], startIndex: number): void {
|
|
483
|
+
let index = startIndex;
|
|
484
|
+
while (index > 0) {
|
|
485
|
+
const parentIndex = (index - 1) >>> 1;
|
|
486
|
+
if (lexicographicalCompare(heap[parentIndex], heap[index]) >= 0) {
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
[heap[parentIndex], heap[index]] = [heap[index], heap[parentIndex]];
|
|
491
|
+
index = parentIndex;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
private static siftRecordLimitRankDown(heap: string[], startIndex: number): void {
|
|
496
|
+
let index = startIndex;
|
|
497
|
+
while (true) {
|
|
498
|
+
const leftIndex = index * 2 + 1;
|
|
499
|
+
if (leftIndex >= heap.length) {
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const rightIndex = leftIndex + 1;
|
|
504
|
+
const largerChildIndex = rightIndex < heap.length && lexicographicalCompare(heap[rightIndex], heap[leftIndex]) > 0
|
|
505
|
+
? rightIndex
|
|
506
|
+
: leftIndex;
|
|
507
|
+
if (lexicographicalCompare(heap[index], heap[largerChildIndex]) >= 0) {
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
[heap[index], heap[largerChildIndex]] = [heap[largerChildIndex], heap[index]];
|
|
512
|
+
index = largerChildIndex;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
368
516
|
/**
|
|
369
517
|
* Queries the sort property index for items that match the filters. If no filters are provided, all items are returned.
|
|
370
518
|
* This query is a linear iterator over the sorted index, checking each item for a match.
|
|
@@ -589,6 +737,17 @@ export class IndexLevel {
|
|
|
589
737
|
const exactMatches = await this.filterExactMatches(tenant, propertyName, propertyValue, levelOptions);
|
|
590
738
|
processResults(exactMatches);
|
|
591
739
|
}
|
|
740
|
+
} else if (isSubtreeFilter(propertyFilter)) {
|
|
741
|
+
const exactMatches = await this.filterExactMatches(tenant, propertyName, propertyFilter.subtree, levelOptions);
|
|
742
|
+
processResults(exactMatches);
|
|
743
|
+
|
|
744
|
+
const descendantMatches = await this.filterSubtreeDescendantMatches(
|
|
745
|
+
tenant,
|
|
746
|
+
propertyName,
|
|
747
|
+
propertyFilter.subtree,
|
|
748
|
+
levelOptions,
|
|
749
|
+
);
|
|
750
|
+
processResults(descendantMatches);
|
|
592
751
|
} else if (FilterUtility.isRangeFilter(propertyFilter)) {
|
|
593
752
|
// `propertyFilter` is a `RangeFilter`
|
|
594
753
|
const rangeMatches = await this.filterRangeMatches(tenant, propertyName, propertyFilter, levelOptions);
|
|
@@ -633,6 +792,34 @@ export class IndexLevel {
|
|
|
633
792
|
return matches;
|
|
634
793
|
}
|
|
635
794
|
|
|
795
|
+
/**
|
|
796
|
+
* Returns every slash-delimited descendant of one hierarchical path.
|
|
797
|
+
*
|
|
798
|
+
* The lower bound removes JSON's closing quote so it is the encoded key
|
|
799
|
+
* prefix itself. ASCII `/` and `0` are adjacent in the store's lexicographic
|
|
800
|
+
* ordering, making `path0` the first value outside that prefix without
|
|
801
|
+
* relying on a finite Unicode sentinel.
|
|
802
|
+
*/
|
|
803
|
+
private async filterSubtreeDescendantMatches(
|
|
804
|
+
tenant: string,
|
|
805
|
+
propertyName: string,
|
|
806
|
+
subtree: string,
|
|
807
|
+
options?: IndexLevelOptions,
|
|
808
|
+
): Promise<IndexedItem[]> {
|
|
809
|
+
const encodedPrefix = IndexLevel.encodeValue(`${subtree}/`).slice(0, -1);
|
|
810
|
+
const iteratorOptions: LevelWrapperIteratorOptions<string> = {
|
|
811
|
+
gte : encodedPrefix,
|
|
812
|
+
lt : IndexLevel.encodeValue(`${subtree}0`),
|
|
813
|
+
};
|
|
814
|
+
|
|
815
|
+
const filterPartition = await this.getIndexPartition(tenant, propertyName);
|
|
816
|
+
const matches: IndexedItem[] = [];
|
|
817
|
+
for await (const [ _key, value ] of filterPartition.iterator(iteratorOptions, options)) {
|
|
818
|
+
matches.push(JSON.parse(value) as IndexedItem);
|
|
819
|
+
}
|
|
820
|
+
return matches;
|
|
821
|
+
}
|
|
822
|
+
|
|
636
823
|
/**
|
|
637
824
|
* Returns items that match the range filter.
|
|
638
825
|
*
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
import type { CompoundIndexDefinition } from './index-level.js';
|
|
2
|
+
import type { CompoundIndexDefinition, IndexedItem } from './index-level.js';
|
|
3
3
|
import type {
|
|
4
4
|
EventLogEntry,
|
|
5
5
|
EventLogReadOptions,
|
|
@@ -13,22 +13,32 @@ import type {
|
|
|
13
13
|
import type { Filter, KeyValues, PaginationCursor, QueryOptions } from '../types/query-types.js';
|
|
14
14
|
import type { GenericMessage, MessageSort, Pagination } from '../types/message-types.js';
|
|
15
15
|
import type { LevelDatabase, LevelWrapperBatchOperation } from './level-wrapper.js';
|
|
16
|
-
import type {
|
|
16
|
+
import type {
|
|
17
|
+
MessageStore,
|
|
18
|
+
MessageStoreLatestStateTransition,
|
|
19
|
+
MessageStoreOptions,
|
|
20
|
+
MessageStorePutResult,
|
|
21
|
+
MessageStoreQueryOptions,
|
|
22
|
+
RecordLimitOccupancy,
|
|
23
|
+
} from '../types/message-store.js';
|
|
17
24
|
|
|
18
25
|
import * as block from 'multiformats/block';
|
|
19
26
|
import * as cbor from '@ipld/dag-cbor';
|
|
20
27
|
|
|
28
|
+
import { runWithCrossContextLock } from '@enbox/common';
|
|
29
|
+
|
|
21
30
|
import { Cid } from '../utils/cid.js';
|
|
22
31
|
import { CID } from 'multiformats/cid';
|
|
23
32
|
import { executeUnlessAborted } from '../utils/abort.js';
|
|
24
|
-
import { FilterUtility } from '../utils/filter.js';
|
|
25
33
|
import { IndexLevel } from './index-level.js';
|
|
26
34
|
import { Message } from '../core/message.js';
|
|
27
35
|
import { Replication } from '../utils/replication.js';
|
|
28
36
|
import { sha256 } from 'multiformats/hashes/sha2';
|
|
29
37
|
import { SortDirection } from '../types/query-types.js';
|
|
38
|
+
import { assertValidSubtreeFilters, FilterUtility } from '../utils/filter.js';
|
|
30
39
|
import { createLevelDatabase, LevelWrapper } from './level-wrapper.js';
|
|
31
40
|
import { DwnError, DwnErrorCode } from '../core/dwn-error.js';
|
|
41
|
+
import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js';
|
|
32
42
|
|
|
33
43
|
|
|
34
44
|
/**
|
|
@@ -89,6 +99,8 @@ const HEADS_PARTITION = 'heads';
|
|
|
89
99
|
const META_PARTITION = 'meta';
|
|
90
100
|
|
|
91
101
|
const EPOCH_KEY = 'epoch';
|
|
102
|
+
const RECORD_LIMIT_RAW_PAGE_SIZE = 128;
|
|
103
|
+
const RECORD_LIMIT_GROUP_CACHE_SIZE = 128;
|
|
92
104
|
const CURRENT_PARTITIONS = new Set([
|
|
93
105
|
BLOCKS_PARTITION,
|
|
94
106
|
INDEX_PARTITION,
|
|
@@ -136,6 +148,29 @@ type StorePartitions = {
|
|
|
136
148
|
meta: LevelWrapper<string>;
|
|
137
149
|
};
|
|
138
150
|
|
|
151
|
+
type RecordLimitPopulationInput = {
|
|
152
|
+
tenant: string;
|
|
153
|
+
filters: Filter[];
|
|
154
|
+
queryOptions: QueryOptions;
|
|
155
|
+
recordLimit: RecordLimitOccupancy;
|
|
156
|
+
options?: MessageStoreQueryOptions;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
type RecordLimitOccupantInput = {
|
|
160
|
+
tenant: string;
|
|
161
|
+
index: IndexLevel;
|
|
162
|
+
item: IndexedItem;
|
|
163
|
+
candidateFilter: Filter;
|
|
164
|
+
recordLimit: RecordLimitOccupancy;
|
|
165
|
+
groupCutoffs: Map<string, string>;
|
|
166
|
+
options?: MessageStoreQueryOptions;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
type RecordLimitPageInput = Omit<RecordLimitOccupantInput, 'item'> & {
|
|
170
|
+
items: IndexedItem[];
|
|
171
|
+
limit?: number;
|
|
172
|
+
};
|
|
173
|
+
|
|
139
174
|
/**
|
|
140
175
|
* A {@link MessageStore} and {@link ReplicationFeedReader} implementation that works in both the
|
|
141
176
|
* browser and server-side, backed by a SINGLE LevelDB root: message blocks, query indexes, the
|
|
@@ -143,8 +178,9 @@ type StorePartitions = {
|
|
|
143
178
|
* the store epoch are all sublevels of one Level instance, so every mutation commits as one fully
|
|
144
179
|
* atomic batch.
|
|
145
180
|
*
|
|
146
|
-
* A per-tenant
|
|
147
|
-
* seq order by construction
|
|
181
|
+
* A per-tenant cross-context write lock spans seq assignment through batch write, so commit order
|
|
182
|
+
* equals seq order by construction even when browser tabs, workers, or service workers share the
|
|
183
|
+
* same IndexedDB database. Seq assignment is gap-free (a failed batch never persists the head),
|
|
148
184
|
* while the readable log stays sparse after compaction and under filters — readers never assume
|
|
149
185
|
* contiguity.
|
|
150
186
|
*/
|
|
@@ -154,7 +190,6 @@ export class MessageStoreLevel implements MessageStore, ReplicationFeedReader {
|
|
|
154
190
|
private readonly wakePublisher?: WakePublisher;
|
|
155
191
|
private partitionsPromise?: Promise<StorePartitions>;
|
|
156
192
|
private epochPromise?: Promise<string>;
|
|
157
|
-
private readonly writeLocks: Map<string, Promise<void>> = new Map();
|
|
158
193
|
|
|
159
194
|
/**
|
|
160
195
|
* @param {MessageStoreLevelConfig} config
|
|
@@ -304,7 +339,7 @@ export class MessageStoreLevel implements MessageStore, ReplicationFeedReader {
|
|
|
304
339
|
filters: Filter[],
|
|
305
340
|
messageSort?: MessageSort,
|
|
306
341
|
pagination?: Pagination,
|
|
307
|
-
options?:
|
|
342
|
+
options?: MessageStoreQueryOptions
|
|
308
343
|
): Promise<{ messages: GenericMessage[], cursor?: PaginationCursor}> {
|
|
309
344
|
options?.signal?.throwIfAborted();
|
|
310
345
|
|
|
@@ -312,7 +347,15 @@ export class MessageStoreLevel implements MessageStore, ReplicationFeedReader {
|
|
|
312
347
|
// this adds 1 to the limit if provided, that way we can check to see if there are additional results and provide a return cursor.
|
|
313
348
|
const queryOptions = MessageStoreLevel.buildQueryOptions(messageSort, pagination);
|
|
314
349
|
const index = await this.index();
|
|
315
|
-
const results =
|
|
350
|
+
const results = options?.recordLimit === undefined
|
|
351
|
+
? await index.query(tenant, filters, queryOptions, options)
|
|
352
|
+
: await this.collectRecordLimitPopulation({
|
|
353
|
+
tenant,
|
|
354
|
+
filters,
|
|
355
|
+
queryOptions,
|
|
356
|
+
recordLimit: options.recordLimit,
|
|
357
|
+
options,
|
|
358
|
+
});
|
|
316
359
|
|
|
317
360
|
let cursor: PaginationCursor | undefined;
|
|
318
361
|
// checks to see if the returned results are greater than the limit, which would indicate additional results.
|
|
@@ -338,13 +381,170 @@ export class MessageStoreLevel implements MessageStore, ReplicationFeedReader {
|
|
|
338
381
|
tenant: string,
|
|
339
382
|
filters: Filter[],
|
|
340
383
|
messageSort?: MessageSort,
|
|
341
|
-
options?:
|
|
384
|
+
options?: MessageStoreQueryOptions
|
|
342
385
|
): Promise<number> {
|
|
343
386
|
options?.signal?.throwIfAborted();
|
|
344
387
|
|
|
345
388
|
const queryOptions = MessageStoreLevel.buildQueryOptions(messageSort);
|
|
346
389
|
const index = await this.index();
|
|
347
|
-
|
|
390
|
+
if (options?.recordLimit === undefined) {
|
|
391
|
+
return index.count(tenant, filters, queryOptions, options);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
let count = 0;
|
|
395
|
+
for await (const _item of this.iterateRecordLimitPopulation({
|
|
396
|
+
tenant,
|
|
397
|
+
filters,
|
|
398
|
+
queryOptions,
|
|
399
|
+
recordLimit: options.recordLimit,
|
|
400
|
+
options,
|
|
401
|
+
})) {
|
|
402
|
+
count++;
|
|
403
|
+
}
|
|
404
|
+
return count;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Materializes one bounded page from the shared record-limit population iterator.
|
|
409
|
+
*/
|
|
410
|
+
private async collectRecordLimitPopulation(input: RecordLimitPopulationInput): Promise<IndexedItem[]> {
|
|
411
|
+
const results: IndexedItem[] = [];
|
|
412
|
+
for await (const item of this.iterateRecordLimitPopulation(input)) {
|
|
413
|
+
results.push(item);
|
|
414
|
+
}
|
|
415
|
+
return results;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Streams caller-filtered records in requested order and admits only records
|
|
420
|
+
* inside their direct-parent group's deterministic rank boundary.
|
|
421
|
+
*
|
|
422
|
+
* Raw candidates are fetched into a small page before any parent lookup is
|
|
423
|
+
* opened. This is deliberate: browser-level/IndexedDB cursors must not be
|
|
424
|
+
* held open while another index transaction is awaited.
|
|
425
|
+
*/
|
|
426
|
+
private async * iterateRecordLimitPopulation(input: RecordLimitPopulationInput): AsyncGenerator<IndexedItem> {
|
|
427
|
+
const { filters, options, queryOptions, recordLimit, tenant } = input;
|
|
428
|
+
const index = await this.index();
|
|
429
|
+
const candidateFilter = MessageStoreLevel.buildRecordLimitCandidateFilter(recordLimit);
|
|
430
|
+
const groupCutoffs = new Map<string, string>();
|
|
431
|
+
const outputLimit = queryOptions.limit;
|
|
432
|
+
let outputCount = 0;
|
|
433
|
+
let rawCursor = queryOptions.cursor;
|
|
434
|
+
|
|
435
|
+
if (outputLimit !== undefined && outputLimit <= 0) {
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
while (true) {
|
|
440
|
+
const rawItems = await index.queryWithBoundedPaging(tenant, filters, {
|
|
441
|
+
...queryOptions,
|
|
442
|
+
cursor : rawCursor,
|
|
443
|
+
limit : RECORD_LIMIT_RAW_PAGE_SIZE,
|
|
444
|
+
}, options);
|
|
445
|
+
if (rawItems.length === 0) {
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const occupants = await this.filterRecordLimitOccupants({
|
|
450
|
+
tenant,
|
|
451
|
+
index,
|
|
452
|
+
items : rawItems,
|
|
453
|
+
candidateFilter,
|
|
454
|
+
recordLimit,
|
|
455
|
+
groupCutoffs,
|
|
456
|
+
options,
|
|
457
|
+
limit : outputLimit === undefined ? undefined : outputLimit - outputCount,
|
|
458
|
+
});
|
|
459
|
+
for (const occupant of occupants) {
|
|
460
|
+
yield occupant;
|
|
461
|
+
outputCount++;
|
|
462
|
+
if (outputLimit !== undefined && outputCount >= outputLimit) {
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (rawItems.length < RECORD_LIMIT_RAW_PAGE_SIZE) {
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
rawCursor = IndexLevel.createCursorFromItem(rawItems.at(-1)!, queryOptions.sortProperty);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
private async filterRecordLimitOccupants(input: RecordLimitPageInput): Promise<IndexedItem[]> {
|
|
475
|
+
const { items, limit, ...occupantInput } = input;
|
|
476
|
+
const occupants: IndexedItem[] = [];
|
|
477
|
+
for (const item of items) {
|
|
478
|
+
if (await this.isWithinRecordLimit({ ...occupantInput, item })) {
|
|
479
|
+
occupants.push(item);
|
|
480
|
+
if (limit !== undefined && occupants.length >= limit) {
|
|
481
|
+
break;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return occupants;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
private async isWithinRecordLimit(input: RecordLimitOccupantInput): Promise<boolean> {
|
|
489
|
+
if (!FilterUtility.matchFilter(input.item.indexes, input.candidateFilter)) {
|
|
490
|
+
return false;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const indexedParentId = input.item.indexes.parentId;
|
|
494
|
+
if (indexedParentId !== undefined && typeof indexedParentId !== 'string') {
|
|
495
|
+
throw new TypeError(`MessageStoreLevel: record-limit candidate parentId must be a string.`);
|
|
496
|
+
}
|
|
497
|
+
const parentId = indexedParentId as string | undefined;
|
|
498
|
+
const groupKey = parentId === undefined ? 'root' : `parent:${parentId}`;
|
|
499
|
+
|
|
500
|
+
let cutoff = input.groupCutoffs.get(groupKey);
|
|
501
|
+
if (cutoff !== undefined) {
|
|
502
|
+
// Refresh insertion order so the fixed-size map acts as a small LRU.
|
|
503
|
+
input.groupCutoffs.delete(groupKey);
|
|
504
|
+
input.groupCutoffs.set(groupKey, cutoff);
|
|
505
|
+
} else {
|
|
506
|
+
cutoff = await input.index.findRecordLimitRankCutoff(
|
|
507
|
+
input.tenant,
|
|
508
|
+
input.candidateFilter,
|
|
509
|
+
parentId,
|
|
510
|
+
input.recordLimit.max,
|
|
511
|
+
input.options,
|
|
512
|
+
);
|
|
513
|
+
if (cutoff === undefined) {
|
|
514
|
+
return false;
|
|
515
|
+
}
|
|
516
|
+
MessageStoreLevel.cacheRecordLimitCutoff(input.groupCutoffs, groupKey, cutoff);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
return IndexLevel.createRecordLimitRankKey(input.item.indexes) <= cutoff;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
private static cacheRecordLimitCutoff(
|
|
523
|
+
cache: Map<string, string>,
|
|
524
|
+
groupKey: string,
|
|
525
|
+
cutoff: string,
|
|
526
|
+
): void {
|
|
527
|
+
if (cache.size >= RECORD_LIMIT_GROUP_CACHE_SIZE) {
|
|
528
|
+
const oldestKey = cache.keys().next().value as string | undefined;
|
|
529
|
+
if (oldestKey !== undefined) {
|
|
530
|
+
cache.delete(oldestKey);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
cache.set(groupKey, cutoff);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
private static buildRecordLimitCandidateFilter(recordLimit: RecordLimitOccupancy): Filter {
|
|
537
|
+
const filter: Filter = {
|
|
538
|
+
interface : DwnInterfaceName.Records,
|
|
539
|
+
method : DwnMethodName.Write,
|
|
540
|
+
isLatestBaseState : true,
|
|
541
|
+
protocol : recordLimit.protocol,
|
|
542
|
+
protocolPath : recordLimit.protocolPath,
|
|
543
|
+
};
|
|
544
|
+
if (recordLimit.contextId !== undefined) {
|
|
545
|
+
filter.contextId = { subtree: recordLimit.contextId };
|
|
546
|
+
}
|
|
547
|
+
return filter;
|
|
348
548
|
}
|
|
349
549
|
|
|
350
550
|
/**
|
|
@@ -718,8 +918,11 @@ export class MessageStoreLevel implements MessageStore, ReplicationFeedReader {
|
|
|
718
918
|
async logRead(tenant: string, options: EventLogReadOptions = {}): Promise<EventLogReadResult> {
|
|
719
919
|
const partitions = await this.partitions();
|
|
720
920
|
const { cursor, limit, filters } = options;
|
|
921
|
+
if (filters !== undefined) {
|
|
922
|
+
assertValidSubtreeFilters(filters);
|
|
923
|
+
}
|
|
721
924
|
|
|
722
|
-
// Head-captured-first: the per-tenant write
|
|
925
|
+
// Head-captured-first: the per-tenant write lock serializes commits in position order, so an
|
|
723
926
|
// observed head H is a visibility barrier — every position <= H is already committed when the
|
|
724
927
|
// range scans below run, and anything committed afterward has a position > H and waits for
|
|
725
928
|
// the next page.
|
|
@@ -731,8 +934,10 @@ export class MessageStoreLevel implements MessageStore, ReplicationFeedReader {
|
|
|
731
934
|
const startPosition = cursor === undefined ? 0n : BigInt(cursor.position);
|
|
732
935
|
|
|
733
936
|
if (head === 0n) {
|
|
734
|
-
// Nothing to scan — caught up at the input position.
|
|
735
|
-
|
|
937
|
+
// Nothing to scan — caught up at the input position. A caller without a
|
|
938
|
+
// cursor still gets the position-zero anchor so an empty log yields a
|
|
939
|
+
// checkpointable token instead of forcing a rescan on every pass.
|
|
940
|
+
return { events: [], cursor: cursor ?? await this.buildToken(tenant, 0n), drained: true };
|
|
736
941
|
}
|
|
737
942
|
|
|
738
943
|
const maxResults = limit ?? Number.MAX_SAFE_INTEGER;
|
|
@@ -863,20 +1068,8 @@ export class MessageStoreLevel implements MessageStore, ReplicationFeedReader {
|
|
|
863
1068
|
* a later batch could land first).
|
|
864
1069
|
*/
|
|
865
1070
|
private async withTenantWriteLock<T>(tenant: string, task: () => Promise<T>): Promise<T> {
|
|
866
|
-
const
|
|
867
|
-
|
|
868
|
-
const current = new Promise<void>((resolve) => { release = resolve; });
|
|
869
|
-
this.writeLocks.set(tenant, current);
|
|
870
|
-
|
|
871
|
-
await previous;
|
|
872
|
-
try {
|
|
873
|
-
return await task();
|
|
874
|
-
} finally {
|
|
875
|
-
release();
|
|
876
|
-
if (this.writeLocks.get(tenant) === current) {
|
|
877
|
-
this.writeLocks.delete(tenant);
|
|
878
|
-
}
|
|
879
|
-
}
|
|
1071
|
+
const lockName = `enbox:dwn-message-store:${JSON.stringify([this.config.location!, tenant])}`;
|
|
1072
|
+
return runWithCrossContextLock(lockName, task);
|
|
880
1073
|
}
|
|
881
1074
|
|
|
882
1075
|
/**
|
|
@@ -6,7 +6,6 @@ import type { MessageStore, MessageStorePutResult } from '../types/message-store
|
|
|
6
6
|
import type { RecordsDeleteMessage, RecordsWriteMessage } from '../types/records-types.js';
|
|
7
7
|
|
|
8
8
|
import { DwnConstant } from '../core/dwn-constant.js';
|
|
9
|
-
import { FilterUtility } from '../utils/filter.js';
|
|
10
9
|
import { Message } from '../core/message.js';
|
|
11
10
|
import { Records } from '../utils/records.js';
|
|
12
11
|
import { RecordsDelete } from '../interfaces/records-delete.js';
|
|
@@ -123,8 +122,7 @@ export class StorageController {
|
|
|
123
122
|
// Scope by parent context for nested records
|
|
124
123
|
const parentContextId = Records.getParentContextFromOfContextId(message.contextId);
|
|
125
124
|
if (parentContextId !== undefined && parentContextId !== '') {
|
|
126
|
-
|
|
127
|
-
filter.contextId = prefixFilter;
|
|
125
|
+
filter.contextId = { subtree: parentContextId };
|
|
128
126
|
}
|
|
129
127
|
|
|
130
128
|
// Query for all records at this path and context
|