was-teaching-server 0.10.0 → 0.11.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/CHANGELOG.md +68 -0
- package/README.md +23 -11
- package/dist/backends/filesystem.d.ts +86 -2
- package/dist/backends/filesystem.d.ts.map +1 -1
- package/dist/backends/filesystem.js +220 -21
- package/dist/backends/filesystem.js.map +1 -1
- package/dist/backends/postgres.d.ts +74 -2
- package/dist/backends/postgres.d.ts.map +1 -1
- package/dist/backends/postgres.js +175 -13
- package/dist/backends/postgres.js.map +1 -1
- package/dist/backends/postgresSchema.d.ts.map +1 -1
- package/dist/backends/postgresSchema.js +69 -82
- package/dist/backends/postgresSchema.js.map +1 -1
- package/dist/errors.d.ts +13 -9
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +22 -12
- package/dist/errors.js.map +1 -1
- package/dist/lib/equalityIndex.d.ts +334 -0
- package/dist/lib/equalityIndex.d.ts.map +1 -0
- package/dist/lib/equalityIndex.js +552 -0
- package/dist/lib/equalityIndex.js.map +1 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +5 -2
- package/dist/plugin.js.map +1 -1
- package/dist/requests/CollectionRequest.d.ts +22 -5
- package/dist/requests/CollectionRequest.d.ts.map +1 -1
- package/dist/requests/CollectionRequest.js +192 -11
- package/dist/requests/CollectionRequest.js.map +1 -1
- package/dist/requests/ResourceRequest.d.ts.map +1 -1
- package/dist/requests/ResourceRequest.js +20 -0
- package/dist/requests/ResourceRequest.js.map +1 -1
- package/dist/requests/SpaceRequest.d.ts +1 -0
- package/dist/requests/SpaceRequest.d.ts.map +1 -1
- package/dist/requests/SpaceRequest.js +21 -1
- package/dist/requests/SpaceRequest.js.map +1 -1
- package/dist/types.d.ts +84 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +19 -18
- package/src/backends/filesystem.ts +286 -18
- package/src/backends/postgres.ts +247 -11
- package/src/errors.ts +25 -12
- package/src/lib/equalityIndex.ts +769 -0
- package/src/plugin.ts +5 -2
- package/src/requests/CollectionRequest.ts +221 -12
- package/src/requests/ResourceRequest.ts +20 -0
- package/src/requests/SpaceRequest.ts +25 -1
- package/src/types.ts +97 -0
|
@@ -76,6 +76,18 @@ import type {
|
|
|
76
76
|
BlindedIndexQuery,
|
|
77
77
|
BlindedIndexQueryPage
|
|
78
78
|
} from '../lib/blindedIndex.js'
|
|
79
|
+
import {
|
|
80
|
+
runEqualityQuery,
|
|
81
|
+
assertNoUniqueEqualityConflict,
|
|
82
|
+
findEqualityUniqueViolation
|
|
83
|
+
} from '../lib/equalityIndex.js'
|
|
84
|
+
import type {
|
|
85
|
+
EqualityQuery,
|
|
86
|
+
EqualityQueryPage,
|
|
87
|
+
EqualityCandidate,
|
|
88
|
+
EqualityValue,
|
|
89
|
+
NormalizedIndexDeclaration
|
|
90
|
+
} from '../lib/equalityIndex.js'
|
|
79
91
|
import {
|
|
80
92
|
assertWritePrecondition,
|
|
81
93
|
assertMetaWritePrecondition,
|
|
@@ -404,10 +416,14 @@ export class FileSystemBackend implements StorageBackend {
|
|
|
404
416
|
// Resources, and conditional (`If-Match`) Collection Description writes.
|
|
405
417
|
// `chunked-streams`: chunk addressing (`/{resourceId}/chunks/{n}`) for a
|
|
406
418
|
// large Resource, each chunk stored opaquely (bytes + content-type).
|
|
419
|
+
// `equality-query`: serves the `equality` profile -- server-extracted
|
|
420
|
+
// plaintext attribute equality over a Collection's declared `indexes`
|
|
421
|
+
// (`queryByEquality`), plus the GET `filter[attr]=value` equality filter.
|
|
407
422
|
features: [
|
|
408
423
|
'conditional-writes',
|
|
409
424
|
'changes-query',
|
|
410
425
|
'blinded-index-query',
|
|
426
|
+
'equality-query',
|
|
411
427
|
'key-epochs',
|
|
412
428
|
'chunked-streams'
|
|
413
429
|
]
|
|
@@ -1997,6 +2013,7 @@ export class FileSystemBackend implements StorageBackend {
|
|
|
1997
2013
|
input,
|
|
1998
2014
|
createdBy,
|
|
1999
2015
|
epoch,
|
|
2016
|
+
uniqueIndexes,
|
|
2000
2017
|
ifMatch,
|
|
2001
2018
|
ifNoneMatch
|
|
2002
2019
|
}: {
|
|
@@ -2006,6 +2023,7 @@ export class FileSystemBackend implements StorageBackend {
|
|
|
2006
2023
|
input: ResourceInput
|
|
2007
2024
|
createdBy?: IDID
|
|
2008
2025
|
epoch?: string
|
|
2026
|
+
uniqueIndexes?: NormalizedIndexDeclaration[]
|
|
2009
2027
|
ifMatch?: string
|
|
2010
2028
|
ifNoneMatch?: boolean
|
|
2011
2029
|
}): Promise<{ version: number }> {
|
|
@@ -2029,30 +2047,57 @@ export class FileSystemBackend implements StorageBackend {
|
|
|
2029
2047
|
})
|
|
2030
2048
|
)
|
|
2031
2049
|
|
|
2032
|
-
//
|
|
2033
|
-
//
|
|
2034
|
-
//
|
|
2035
|
-
//
|
|
2036
|
-
//
|
|
2037
|
-
//
|
|
2038
|
-
// Collection
|
|
2039
|
-
//
|
|
2040
|
-
//
|
|
2041
|
-
|
|
2050
|
+
// Two unique-attribute invariants can force a write to serialize on the
|
|
2051
|
+
// Collection lock before it takes its per-Resource lock: the EDV blinded
|
|
2052
|
+
// one (`unique: true` blinded attributes; the `blinded-index-query`
|
|
2053
|
+
// feature) and the plaintext equality one (a Collection's `unique`-declared
|
|
2054
|
+
// `indexes`; the `equality-query` feature). Only a JSON content write can
|
|
2055
|
+
// create either claim, so only such writes pay for it: they serialize per
|
|
2056
|
+
// Collection (the outer lock, so two racing claimants cannot both pass the
|
|
2057
|
+
// scan), evaluate the conflict against the Collection's other live
|
|
2058
|
+
// documents, then take the ordinary per-Resource lock nested inside.
|
|
2059
|
+
// Distinct-key nesting cannot deadlock: plain writes never hold a Resource
|
|
2060
|
+
// key while waiting on a Collection key. The two conditions are unified so a
|
|
2061
|
+
// write carrying both claims acquires the Collection lock exactly once.
|
|
2062
|
+
const blindedUnique =
|
|
2042
2063
|
input.kind === 'json' &&
|
|
2043
2064
|
collectUniqueBlindedTerms({ document: input.data }).length > 0
|
|
2044
|
-
|
|
2065
|
+
const equalityUnique =
|
|
2066
|
+
input.kind === 'json' &&
|
|
2067
|
+
uniqueIndexes !== undefined &&
|
|
2068
|
+
uniqueIndexes.length > 0
|
|
2069
|
+
if (blindedUnique || equalityUnique) {
|
|
2045
2070
|
return this._writeMutex.run(
|
|
2046
2071
|
this._collectionLockKey({ spaceId, collectionId }),
|
|
2047
2072
|
async () => {
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2073
|
+
if (blindedUnique) {
|
|
2074
|
+
assertNoUniqueBlindedConflict({
|
|
2075
|
+
document: input.kind === 'json' ? input.data : undefined,
|
|
2076
|
+
candidates: await this._readJsonCandidates({
|
|
2077
|
+
spaceId,
|
|
2078
|
+
collectionId,
|
|
2079
|
+
excludeResourceId: resourceId
|
|
2080
|
+
})
|
|
2054
2081
|
})
|
|
2055
|
-
}
|
|
2082
|
+
}
|
|
2083
|
+
if (equalityUnique) {
|
|
2084
|
+
// A content write does not change the Resource's `custom`, so the
|
|
2085
|
+
// custom side of the claim comes from the CURRENT stored sidecar.
|
|
2086
|
+
const priorSidecar = await this._readMetaSidecar({
|
|
2087
|
+
collectionDir,
|
|
2088
|
+
resourceId
|
|
2089
|
+
})
|
|
2090
|
+
assertNoUniqueEqualityConflict({
|
|
2091
|
+
indexes: uniqueIndexes!,
|
|
2092
|
+
content: input.kind === 'json' ? input.data : undefined,
|
|
2093
|
+
custom: priorSidecar?.custom,
|
|
2094
|
+
candidates: await this._readEqualityCandidates({
|
|
2095
|
+
spaceId,
|
|
2096
|
+
collectionId,
|
|
2097
|
+
excludeResourceId: resourceId
|
|
2098
|
+
})
|
|
2099
|
+
})
|
|
2100
|
+
}
|
|
2056
2101
|
return write()
|
|
2057
2102
|
}
|
|
2058
2103
|
)
|
|
@@ -2595,6 +2640,7 @@ export class FileSystemBackend implements StorageBackend {
|
|
|
2595
2640
|
resourceId,
|
|
2596
2641
|
custom,
|
|
2597
2642
|
epoch,
|
|
2643
|
+
uniqueIndexes,
|
|
2598
2644
|
ifMatch,
|
|
2599
2645
|
ifNoneMatch
|
|
2600
2646
|
}: {
|
|
@@ -2603,6 +2649,7 @@ export class FileSystemBackend implements StorageBackend {
|
|
|
2603
2649
|
resourceId: string
|
|
2604
2650
|
custom: ResourceMetadataCustom | Record<string, unknown>
|
|
2605
2651
|
epoch?: string
|
|
2652
|
+
uniqueIndexes?: NormalizedIndexDeclaration[]
|
|
2606
2653
|
ifMatch?: string
|
|
2607
2654
|
ifNoneMatch?: boolean
|
|
2608
2655
|
}): Promise<{ metaVersion: number } | undefined> {
|
|
@@ -2664,6 +2711,38 @@ export class FileSystemBackend implements StorageBackend {
|
|
|
2664
2711
|
})
|
|
2665
2712
|
return { metaVersion }
|
|
2666
2713
|
}
|
|
2714
|
+
// A metadata write can create a plaintext equality unique claim for a
|
|
2715
|
+
// `custom`-sourced attribute (the `equality-query` feature). When the
|
|
2716
|
+
// Collection declares any unique index, serialize on the Collection lock and
|
|
2717
|
+
// scan for a conflict -- content is the Resource's stored JSON content
|
|
2718
|
+
// (unchanged by a metadata write), custom is the incoming value this write
|
|
2719
|
+
// sets -- before taking the per-Resource lock nested inside. Distinct-key
|
|
2720
|
+
// nesting cannot deadlock (plain writes never hold a Resource key while
|
|
2721
|
+
// waiting on a Collection key).
|
|
2722
|
+
if (uniqueIndexes !== undefined && uniqueIndexes.length > 0) {
|
|
2723
|
+
return this._writeMutex.run(
|
|
2724
|
+
this._collectionLockKey({ spaceId, collectionId }),
|
|
2725
|
+
async () => {
|
|
2726
|
+
assertNoUniqueEqualityConflict({
|
|
2727
|
+
indexes: uniqueIndexes,
|
|
2728
|
+
content: await this._readResourceJsonContent({
|
|
2729
|
+
collectionDir,
|
|
2730
|
+
resourceId
|
|
2731
|
+
}),
|
|
2732
|
+
custom,
|
|
2733
|
+
candidates: await this._readEqualityCandidates({
|
|
2734
|
+
spaceId,
|
|
2735
|
+
collectionId,
|
|
2736
|
+
excludeResourceId: resourceId
|
|
2737
|
+
})
|
|
2738
|
+
})
|
|
2739
|
+
return this._writeMutex.run(
|
|
2740
|
+
this._resourceLockKey({ spaceId, collectionId, resourceId }),
|
|
2741
|
+
writeMeta
|
|
2742
|
+
)
|
|
2743
|
+
}
|
|
2744
|
+
)
|
|
2745
|
+
}
|
|
2667
2746
|
return this._writeMutex.run(
|
|
2668
2747
|
this._resourceLockKey({ spaceId, collectionId, resourceId }),
|
|
2669
2748
|
writeMeta
|
|
@@ -3577,6 +3656,195 @@ export class FileSystemBackend implements StorageBackend {
|
|
|
3577
3656
|
).filter(candidate => candidate !== undefined)
|
|
3578
3657
|
}
|
|
3579
3658
|
|
|
3659
|
+
/**
|
|
3660
|
+
* Plaintext equality query (the `equality` query profile; see the
|
|
3661
|
+
* `StorageBackend.queryByEquality` contract). Reads every live Resource of
|
|
3662
|
+
* the Collection -- JSON Resources carrying parsed `content`, blobs carrying
|
|
3663
|
+
* only their sidecar `custom` -- and hands the candidates to the shared
|
|
3664
|
+
* evaluator (`lib/equalityIndex.ts`) for extraction, matching, ordering, and
|
|
3665
|
+
* cursor pagination. O(n) over the Collection per call, with every JSON body
|
|
3666
|
+
* read -- deliberate for this teaching backend; a materialized backend would
|
|
3667
|
+
* answer from an attribute index. Tombstones are excluded naturally (no live
|
|
3668
|
+
* content file).
|
|
3669
|
+
* @param options {object}
|
|
3670
|
+
* @param options.spaceId {string}
|
|
3671
|
+
* @param options.collectionId {string}
|
|
3672
|
+
* @param options.query {EqualityQuery}
|
|
3673
|
+
* @param options.indexes {NormalizedIndexDeclaration[]} the normalized
|
|
3674
|
+
* declared indexes (the request layer resolves them from the description)
|
|
3675
|
+
* @param [options.count] {boolean} return only the match count
|
|
3676
|
+
* @param [options.limit] {number} requested page size
|
|
3677
|
+
* @param [options.cursor] {string} opaque cursor from a prior page
|
|
3678
|
+
* @returns {Promise<{ count: number } | EqualityQueryPage>}
|
|
3679
|
+
*/
|
|
3680
|
+
async queryByEquality({
|
|
3681
|
+
spaceId,
|
|
3682
|
+
collectionId,
|
|
3683
|
+
query,
|
|
3684
|
+
indexes,
|
|
3685
|
+
count,
|
|
3686
|
+
limit,
|
|
3687
|
+
cursor
|
|
3688
|
+
}: {
|
|
3689
|
+
spaceId: string
|
|
3690
|
+
collectionId: string
|
|
3691
|
+
query: EqualityQuery
|
|
3692
|
+
indexes: NormalizedIndexDeclaration[]
|
|
3693
|
+
count?: boolean
|
|
3694
|
+
limit?: number
|
|
3695
|
+
cursor?: string
|
|
3696
|
+
}): Promise<{ count: number } | EqualityQueryPage> {
|
|
3697
|
+
const candidates = await this._readEqualityCandidates({
|
|
3698
|
+
spaceId,
|
|
3699
|
+
collectionId
|
|
3700
|
+
})
|
|
3701
|
+
return runEqualityQuery({
|
|
3702
|
+
candidates,
|
|
3703
|
+
query,
|
|
3704
|
+
indexes,
|
|
3705
|
+
count,
|
|
3706
|
+
limit,
|
|
3707
|
+
cursor
|
|
3708
|
+
})
|
|
3709
|
+
}
|
|
3710
|
+
|
|
3711
|
+
/**
|
|
3712
|
+
* Declare-time uniqueness scan for the `equality` profile (see the
|
|
3713
|
+
* `StorageBackend.findEqualityUniqueViolation` contract): reads the
|
|
3714
|
+
* Collection's live Resources and delegates to the shared scan, which reports
|
|
3715
|
+
* the first `(name, value)` claimed by two different Resources under the given
|
|
3716
|
+
* `unique` declarations (or `undefined` when none is).
|
|
3717
|
+
* @param options {object}
|
|
3718
|
+
* @param options.spaceId {string}
|
|
3719
|
+
* @param options.collectionId {string}
|
|
3720
|
+
* @param options.indexes {NormalizedIndexDeclaration[]}
|
|
3721
|
+
* @returns {Promise<{ name: string, value: EqualityValue } | undefined>}
|
|
3722
|
+
*/
|
|
3723
|
+
async findEqualityUniqueViolation({
|
|
3724
|
+
spaceId,
|
|
3725
|
+
collectionId,
|
|
3726
|
+
indexes
|
|
3727
|
+
}: {
|
|
3728
|
+
spaceId: string
|
|
3729
|
+
collectionId: string
|
|
3730
|
+
indexes: NormalizedIndexDeclaration[]
|
|
3731
|
+
}): Promise<{ name: string; value: EqualityValue } | undefined> {
|
|
3732
|
+
const candidates = await this._readEqualityCandidates({
|
|
3733
|
+
spaceId,
|
|
3734
|
+
collectionId
|
|
3735
|
+
})
|
|
3736
|
+
return findEqualityUniqueViolation({ indexes, candidates })
|
|
3737
|
+
}
|
|
3738
|
+
|
|
3739
|
+
/**
|
|
3740
|
+
* Reads every live Resource of a Collection as an equality candidate -- the
|
|
3741
|
+
* candidate set for the equality query and the plaintext unique-attribute
|
|
3742
|
+
* conflict scans. Unlike `_readJsonCandidates` this INCLUDES blob Resources
|
|
3743
|
+
* (a blob is queryable through its `custom`-sourced attributes): each entry
|
|
3744
|
+
* resolves `{ resourceId, content?, custom? }`, where `content` is the parsed
|
|
3745
|
+
* JSON of a JSON-typed representation (the blob content read is skipped, and
|
|
3746
|
+
* unparsable JSON is dropped, as in `_readJsonCandidates`) and `custom` is the
|
|
3747
|
+
* `.meta.` sidecar's `custom` when present. Tombstones are excluded naturally
|
|
3748
|
+
* (no live `r.` content file); an optional excluded Resource is skipped. An
|
|
3749
|
+
* absent Collection dir resolves empty.
|
|
3750
|
+
* @param options {object}
|
|
3751
|
+
* @param options.spaceId {string}
|
|
3752
|
+
* @param options.collectionId {string}
|
|
3753
|
+
* @param [options.excludeResourceId] {string} omit this Resource (a conflict
|
|
3754
|
+
* scan excludes the Resource being written)
|
|
3755
|
+
* @returns {Promise<EqualityCandidate[]>}
|
|
3756
|
+
*/
|
|
3757
|
+
private async _readEqualityCandidates({
|
|
3758
|
+
spaceId,
|
|
3759
|
+
collectionId,
|
|
3760
|
+
excludeResourceId
|
|
3761
|
+
}: {
|
|
3762
|
+
spaceId: string
|
|
3763
|
+
collectionId: string
|
|
3764
|
+
excludeResourceId?: string
|
|
3765
|
+
}): Promise<EqualityCandidate[]> {
|
|
3766
|
+
const collectionDir = this._collectionDir({ spaceId, collectionId })
|
|
3767
|
+
|
|
3768
|
+
let entries: fs.Dirent[] = []
|
|
3769
|
+
try {
|
|
3770
|
+
entries = await fs.promises.readdir(collectionDir, {
|
|
3771
|
+
withFileTypes: true
|
|
3772
|
+
})
|
|
3773
|
+
} catch (err) {
|
|
3774
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
3775
|
+
throw err
|
|
3776
|
+
}
|
|
3777
|
+
}
|
|
3778
|
+
|
|
3779
|
+
return await Promise.all(
|
|
3780
|
+
entries
|
|
3781
|
+
.filter(entry => entry.isFile() && entry.name.startsWith('r.'))
|
|
3782
|
+
.map(entry => ({
|
|
3783
|
+
fileName: entry.name,
|
|
3784
|
+
...parseResourceFileName(entry.name)
|
|
3785
|
+
}))
|
|
3786
|
+
.filter(({ resourceId }) => resourceId !== excludeResourceId)
|
|
3787
|
+
.map(async ({ resourceId, fileName, contentType }) => {
|
|
3788
|
+
// Parse the content only for a JSON representation; a blob contributes
|
|
3789
|
+
// no content-sourced attributes (its `custom` still makes it
|
|
3790
|
+
// queryable). Unparsable JSON is treated as no content.
|
|
3791
|
+
let content: unknown
|
|
3792
|
+
if (isJson({ contentType })) {
|
|
3793
|
+
try {
|
|
3794
|
+
content = JSON.parse(
|
|
3795
|
+
await fs.promises.readFile(
|
|
3796
|
+
path.join(collectionDir, fileName),
|
|
3797
|
+
'utf8'
|
|
3798
|
+
)
|
|
3799
|
+
) as unknown
|
|
3800
|
+
} catch {
|
|
3801
|
+
content = undefined
|
|
3802
|
+
}
|
|
3803
|
+
}
|
|
3804
|
+
const sidecar = await this._readMetaSidecar({
|
|
3805
|
+
collectionDir,
|
|
3806
|
+
resourceId
|
|
3807
|
+
})
|
|
3808
|
+
return {
|
|
3809
|
+
resourceId,
|
|
3810
|
+
...(content !== undefined && { content }),
|
|
3811
|
+
...(sidecar?.custom !== undefined && { custom: sidecar.custom })
|
|
3812
|
+
}
|
|
3813
|
+
})
|
|
3814
|
+
)
|
|
3815
|
+
}
|
|
3816
|
+
|
|
3817
|
+
/**
|
|
3818
|
+
* Reads and parses a single Resource's stored JSON content, or resolves
|
|
3819
|
+
* `undefined` when the Resource is absent, a blob, or unparsable JSON -- the
|
|
3820
|
+
* content side of a custom-sourced unique-attribute claim on a metadata write.
|
|
3821
|
+
* @param options {object}
|
|
3822
|
+
* @param options.collectionDir {string}
|
|
3823
|
+
* @param options.resourceId {string}
|
|
3824
|
+
* @returns {Promise<unknown>}
|
|
3825
|
+
*/
|
|
3826
|
+
private async _readResourceJsonContent({
|
|
3827
|
+
collectionDir,
|
|
3828
|
+
resourceId
|
|
3829
|
+
}: {
|
|
3830
|
+
collectionDir: string
|
|
3831
|
+
resourceId: string
|
|
3832
|
+
}): Promise<unknown> {
|
|
3833
|
+
const filePath = await this._findFile({ collectionDir, resourceId })
|
|
3834
|
+
if (!filePath) {
|
|
3835
|
+
return undefined
|
|
3836
|
+
}
|
|
3837
|
+
const { contentType } = parseResourceFileName(path.basename(filePath))
|
|
3838
|
+
if (!isJson({ contentType })) {
|
|
3839
|
+
return undefined
|
|
3840
|
+
}
|
|
3841
|
+
try {
|
|
3842
|
+
return JSON.parse(await fs.promises.readFile(filePath, 'utf8')) as unknown
|
|
3843
|
+
} catch {
|
|
3844
|
+
return undefined
|
|
3845
|
+
}
|
|
3846
|
+
}
|
|
3847
|
+
|
|
3580
3848
|
// Policies
|
|
3581
3849
|
|
|
3582
3850
|
/**
|