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.
Files changed (47) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +23 -11
  3. package/dist/backends/filesystem.d.ts +86 -2
  4. package/dist/backends/filesystem.d.ts.map +1 -1
  5. package/dist/backends/filesystem.js +220 -21
  6. package/dist/backends/filesystem.js.map +1 -1
  7. package/dist/backends/postgres.d.ts +74 -2
  8. package/dist/backends/postgres.d.ts.map +1 -1
  9. package/dist/backends/postgres.js +175 -13
  10. package/dist/backends/postgres.js.map +1 -1
  11. package/dist/backends/postgresSchema.d.ts.map +1 -1
  12. package/dist/backends/postgresSchema.js +69 -82
  13. package/dist/backends/postgresSchema.js.map +1 -1
  14. package/dist/errors.d.ts +13 -9
  15. package/dist/errors.d.ts.map +1 -1
  16. package/dist/errors.js +22 -12
  17. package/dist/errors.js.map +1 -1
  18. package/dist/lib/equalityIndex.d.ts +334 -0
  19. package/dist/lib/equalityIndex.d.ts.map +1 -0
  20. package/dist/lib/equalityIndex.js +552 -0
  21. package/dist/lib/equalityIndex.js.map +1 -0
  22. package/dist/plugin.d.ts.map +1 -1
  23. package/dist/plugin.js +5 -2
  24. package/dist/plugin.js.map +1 -1
  25. package/dist/requests/CollectionRequest.d.ts +22 -5
  26. package/dist/requests/CollectionRequest.d.ts.map +1 -1
  27. package/dist/requests/CollectionRequest.js +192 -11
  28. package/dist/requests/CollectionRequest.js.map +1 -1
  29. package/dist/requests/ResourceRequest.d.ts.map +1 -1
  30. package/dist/requests/ResourceRequest.js +20 -0
  31. package/dist/requests/ResourceRequest.js.map +1 -1
  32. package/dist/requests/SpaceRequest.d.ts +1 -0
  33. package/dist/requests/SpaceRequest.d.ts.map +1 -1
  34. package/dist/requests/SpaceRequest.js +21 -1
  35. package/dist/requests/SpaceRequest.js.map +1 -1
  36. package/dist/types.d.ts +84 -1
  37. package/dist/types.d.ts.map +1 -1
  38. package/package.json +19 -18
  39. package/src/backends/filesystem.ts +286 -18
  40. package/src/backends/postgres.ts +247 -11
  41. package/src/errors.ts +25 -12
  42. package/src/lib/equalityIndex.ts +769 -0
  43. package/src/plugin.ts +5 -2
  44. package/src/requests/CollectionRequest.ts +221 -12
  45. package/src/requests/ResourceRequest.ts +20 -0
  46. package/src/requests/SpaceRequest.ts +25 -1
  47. 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
- // The EDV unique-attribute invariant (`unique: true` blinded attributes;
2033
- // the `blinded-index-query` feature): a write claiming a unique blinded
2034
- // triple must not collide with another live document's claim. Only a
2035
- // unique-carrying JSON write can create a new claim, so only such writes
2036
- // pay for it: they serialize per Collection (the outer lock, so two racing
2037
- // claimants cannot both pass the scan), evaluate the conflict against the
2038
- // Collection's other live JSON documents, then take the ordinary
2039
- // per-Resource lock nested inside. Distinct-key nesting cannot deadlock:
2040
- // plain writes never hold a Resource key while waiting on a Collection key.
2041
- if (
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
- assertNoUniqueBlindedConflict({
2049
- document: input.data,
2050
- candidates: await this._readJsonCandidates({
2051
- spaceId,
2052
- collectionId,
2053
- excludeResourceId: resourceId
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
  /**