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
@@ -78,6 +78,18 @@ import type {
78
78
  BlindedIndexQuery,
79
79
  BlindedIndexQueryPage
80
80
  } from '../lib/blindedIndex.js'
81
+ import {
82
+ runEqualityQuery,
83
+ assertNoUniqueEqualityConflict,
84
+ findEqualityUniqueViolation
85
+ } from '../lib/equalityIndex.js'
86
+ import type {
87
+ EqualityQuery,
88
+ EqualityQueryPage,
89
+ EqualityCandidate,
90
+ EqualityValue,
91
+ NormalizedIndexDeclaration
92
+ } from '../lib/equalityIndex.js'
81
93
  import {
82
94
  DEFAULT_MAX_UPLOAD_BYTES,
83
95
  DEFAULT_MAX_SPACES_PER_CONTROLLER,
@@ -446,10 +458,14 @@ export class PostgresBackend implements StorageBackend {
446
458
  // Resources, and conditional (`If-Match`) Collection Description writes.
447
459
  // `chunked-streams`: chunk addressing at `/{resourceId}/chunks/{n}`, opaque
448
460
  // per-chunk raw-bytes storage in the `chunks` table.
461
+ // `equality-query`: serves the `equality` profile -- server-extracted
462
+ // plaintext attribute equality over a Collection's declared `indexes`
463
+ // (`queryByEquality`), plus the GET `filter[attr]=value` equality filter.
449
464
  features: [
450
465
  'conditional-writes',
451
466
  'changes-query',
452
467
  'blinded-index-query',
468
+ 'equality-query',
453
469
  'key-epochs',
454
470
  'chunked-streams'
455
471
  ]
@@ -1280,6 +1296,7 @@ export class PostgresBackend implements StorageBackend {
1280
1296
  input,
1281
1297
  createdBy,
1282
1298
  epoch,
1299
+ uniqueIndexes,
1283
1300
  ifMatch,
1284
1301
  ifNoneMatch
1285
1302
  }: {
@@ -1289,6 +1306,7 @@ export class PostgresBackend implements StorageBackend {
1289
1306
  input: ResourceInput
1290
1307
  createdBy?: IDID
1291
1308
  epoch?: string
1309
+ uniqueIndexes?: NormalizedIndexDeclaration[]
1292
1310
  ifMatch?: string
1293
1311
  ifNoneMatch?: boolean
1294
1312
  }): Promise<{ version: number }> {
@@ -1327,23 +1345,29 @@ export class PostgresBackend implements StorageBackend {
1327
1345
  return this._withTransaction(async client => {
1328
1346
  await this._ensureCollectionRow({ client, spaceId, collectionId })
1329
1347
 
1330
- // The EDV unique-attribute invariant (`unique: true` blinded attributes;
1331
- // the `blinded-index-query` feature): a write claiming a unique blinded
1332
- // triple must not collide with another live document's claim. Only a
1333
- // unique-carrying JSON write can create a new claim, so only such writes
1334
- // pay for it: a per-Collection transaction-scoped advisory lock
1348
+ // Two unique-attribute invariants can force a JSON content write to
1349
+ // serialize before it upserts its row: the EDV blinded one (`unique: true`
1350
+ // blinded attributes; the `blinded-index-query` feature) and the plaintext
1351
+ // equality one (a Collection's `unique`-declared `indexes`; the
1352
+ // `equality-query` feature). Only a unique-carrying JSON write can create
1353
+ // either claim. A per-Collection transaction-scoped advisory lock
1335
1354
  // serializes the claimants (held to commit, so the loser's scan sees the
1336
- // winner's committed row) without entering the row-lock ordering of
1337
- // plain writes, then the conflict is evaluated against the Collection's
1338
- // other live JSON documents.
1339
- if (
1355
+ // winner's committed row) without entering the row-lock ordering of plain
1356
+ // writes; it is taken once and shared by both checks.
1357
+ const blindedUnique =
1340
1358
  input.kind === 'json' &&
1341
1359
  collectUniqueBlindedTerms({ document: input.data }).length > 0
1342
- ) {
1360
+ const equalityUnique =
1361
+ input.kind === 'json' &&
1362
+ uniqueIndexes !== undefined &&
1363
+ uniqueIndexes.length > 0
1364
+ if (blindedUnique || equalityUnique) {
1343
1365
  await client.query(
1344
1366
  'SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))',
1345
1367
  [spaceId, collectionId]
1346
1368
  )
1369
+ }
1370
+ if (blindedUnique) {
1347
1371
  const { rows: candidateRows } = await client.query<{
1348
1372
  resource_id: string
1349
1373
  content: Buffer | null
@@ -1367,7 +1391,32 @@ export class PostgresBackend implements StorageBackend {
1367
1391
  // skip an unparsable body
1368
1392
  }
1369
1393
  }
1370
- assertNoUniqueBlindedConflict({ document: input.data, candidates })
1394
+ assertNoUniqueBlindedConflict({
1395
+ document: input.kind === 'json' ? input.data : undefined,
1396
+ candidates
1397
+ })
1398
+ }
1399
+ if (equalityUnique) {
1400
+ // A content write does not change the Resource's `custom`, so the custom
1401
+ // side of the claim comes from the CURRENT stored row.
1402
+ const { rows: selfRows } = await client.query<{
1403
+ custom: ResourceMetadataCustom | Record<string, unknown> | null
1404
+ }>(
1405
+ `SELECT custom FROM resources
1406
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
1407
+ AND NOT deleted`,
1408
+ [spaceId, collectionId, resourceId]
1409
+ )
1410
+ assertNoUniqueEqualityConflict({
1411
+ indexes: uniqueIndexes!,
1412
+ content: input.kind === 'json' ? input.data : undefined,
1413
+ custom: selfRows[0]?.custom ?? undefined,
1414
+ candidates: await this._readEqualityCandidates(client, {
1415
+ spaceId,
1416
+ collectionId,
1417
+ excludeResourceId: resourceId
1418
+ })
1419
+ })
1371
1420
  }
1372
1421
 
1373
1422
  // Narrow projection: the lock needs the row, not its (possibly multi-MB)
@@ -1735,6 +1784,7 @@ export class PostgresBackend implements StorageBackend {
1735
1784
  resourceId,
1736
1785
  custom,
1737
1786
  epoch,
1787
+ uniqueIndexes,
1738
1788
  ifMatch,
1739
1789
  ifNoneMatch
1740
1790
  }: {
@@ -1743,10 +1793,24 @@ export class PostgresBackend implements StorageBackend {
1743
1793
  resourceId: string
1744
1794
  custom: ResourceMetadataCustom | Record<string, unknown>
1745
1795
  epoch?: string
1796
+ uniqueIndexes?: NormalizedIndexDeclaration[]
1746
1797
  ifMatch?: string
1747
1798
  ifNoneMatch?: boolean
1748
1799
  }): Promise<{ metaVersion: number } | undefined> {
1749
1800
  return this._withTransaction(async client => {
1801
+ // A metadata write can create a plaintext equality unique claim for a
1802
+ // `custom`-sourced attribute (the `equality-query` feature). When the
1803
+ // Collection declares any unique index, take the per-Collection advisory
1804
+ // lock first (held to commit, serializing concurrent claimants) so the
1805
+ // conflict scan below is atomic with the write.
1806
+ const equalityUnique =
1807
+ uniqueIndexes !== undefined && uniqueIndexes.length > 0
1808
+ if (equalityUnique) {
1809
+ await client.query(
1810
+ 'SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))',
1811
+ [spaceId, collectionId]
1812
+ )
1813
+ }
1750
1814
  const { rows } = await client.query<ResourceRow>(
1751
1815
  `SELECT meta_version, deleted FROM resources
1752
1816
  WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
@@ -1763,6 +1827,39 @@ export class PostgresBackend implements StorageBackend {
1763
1827
  ifMatch,
1764
1828
  ifNoneMatch
1765
1829
  })
1830
+ if (equalityUnique) {
1831
+ // Content is the Resource's stored JSON content (unchanged by a
1832
+ // metadata write); custom is the incoming value this write sets. Fetch
1833
+ // the (possibly multi-MB) content only on this uniqueness path -- the
1834
+ // row is already `FOR UPDATE`-locked above.
1835
+ const { rows: selfRows } = await client.query<{
1836
+ content: Buffer | null
1837
+ is_json: boolean
1838
+ }>(
1839
+ `SELECT content, is_json FROM resources
1840
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3`,
1841
+ [spaceId, collectionId, resourceId]
1842
+ )
1843
+ let content: unknown
1844
+ const selfRow = selfRows[0]
1845
+ if (selfRow?.is_json && selfRow.content) {
1846
+ try {
1847
+ content = JSON.parse(selfRow.content.toString('utf8')) as unknown
1848
+ } catch {
1849
+ content = undefined
1850
+ }
1851
+ }
1852
+ assertNoUniqueEqualityConflict({
1853
+ indexes: uniqueIndexes!,
1854
+ content,
1855
+ custom,
1856
+ candidates: await this._readEqualityCandidates(client, {
1857
+ spaceId,
1858
+ collectionId,
1859
+ excludeResourceId: resourceId
1860
+ })
1861
+ })
1862
+ }
1766
1863
  const metaVersion = (prior.meta_version ?? 0) + 1
1767
1864
  const hasCustom = Object.keys(custom).length > 0
1768
1865
  const now = new Date().toISOString()
@@ -2343,6 +2440,145 @@ export class PostgresBackend implements StorageBackend {
2343
2440
  return runBlindedIndexQuery({ candidates, query, count, limit, cursor })
2344
2441
  }
2345
2442
 
2443
+ /**
2444
+ * Plaintext equality query (the `equality` query profile; see the
2445
+ * `StorageBackend.queryByEquality` contract). Reads the Collection's live
2446
+ * Resources -- JSON rows carrying parsed `content`, all rows carrying their
2447
+ * `custom` jsonb -- and hands the candidates to the shared evaluator
2448
+ * (`lib/equalityIndex.ts`) for extraction, matching, ordering, and cursor
2449
+ * pagination -- identical semantics to the filesystem backend. A full scan of
2450
+ * the Collection per call, deliberate for this teaching backend; a
2451
+ * materialized variant would answer from a JSONB expression index.
2452
+ * @param options {object}
2453
+ * @param options.spaceId {string}
2454
+ * @param options.collectionId {string}
2455
+ * @param options.query {EqualityQuery}
2456
+ * @param options.indexes {NormalizedIndexDeclaration[]}
2457
+ * @param [options.count] {boolean} return only the match count
2458
+ * @param [options.limit] {number} requested page size
2459
+ * @param [options.cursor] {string} opaque cursor from a prior page
2460
+ * @returns {Promise<{ count: number } | EqualityQueryPage>}
2461
+ */
2462
+ async queryByEquality({
2463
+ spaceId,
2464
+ collectionId,
2465
+ query,
2466
+ indexes,
2467
+ count,
2468
+ limit,
2469
+ cursor
2470
+ }: {
2471
+ spaceId: string
2472
+ collectionId: string
2473
+ query: EqualityQuery
2474
+ indexes: NormalizedIndexDeclaration[]
2475
+ count?: boolean
2476
+ limit?: number
2477
+ cursor?: string
2478
+ }): Promise<{ count: number } | EqualityQueryPage> {
2479
+ const candidates = await this._readEqualityCandidates(this._pool, {
2480
+ spaceId,
2481
+ collectionId
2482
+ })
2483
+ return runEqualityQuery({
2484
+ candidates,
2485
+ query,
2486
+ indexes,
2487
+ count,
2488
+ limit,
2489
+ cursor
2490
+ })
2491
+ }
2492
+
2493
+ /**
2494
+ * Declare-time uniqueness scan for the `equality` profile (see the
2495
+ * `StorageBackend.findEqualityUniqueViolation` contract): reads the
2496
+ * Collection's live Resources and delegates to the shared scan, which reports
2497
+ * the first `(name, value)` claimed by two different Resources under the given
2498
+ * `unique` declarations (or `undefined` when none is).
2499
+ * @param options {object}
2500
+ * @param options.spaceId {string}
2501
+ * @param options.collectionId {string}
2502
+ * @param options.indexes {NormalizedIndexDeclaration[]}
2503
+ * @returns {Promise<{ name: string, value: EqualityValue } | undefined>}
2504
+ */
2505
+ async findEqualityUniqueViolation({
2506
+ spaceId,
2507
+ collectionId,
2508
+ indexes
2509
+ }: {
2510
+ spaceId: string
2511
+ collectionId: string
2512
+ indexes: NormalizedIndexDeclaration[]
2513
+ }): Promise<{ name: string; value: EqualityValue } | undefined> {
2514
+ const candidates = await this._readEqualityCandidates(this._pool, {
2515
+ spaceId,
2516
+ collectionId
2517
+ })
2518
+ return findEqualityUniqueViolation({ indexes, candidates })
2519
+ }
2520
+
2521
+ /**
2522
+ * Reads every live Resource of a Collection as an equality candidate -- the
2523
+ * candidate set for the equality query and the plaintext unique-attribute
2524
+ * conflict scans. Includes blob Resources (queryable through their
2525
+ * `custom`-sourced attributes): each row resolves `{ resourceId, content?,
2526
+ * custom? }`, where `content` is the parsed JSON of a JSON row (a blob and
2527
+ * unparsable JSON contribute none) and `custom` is the row's jsonb `custom`
2528
+ * when set. Tombstones are excluded (`NOT deleted`); an optional excluded
2529
+ * Resource is skipped. Runs on the given executor -- the pool for a read-only
2530
+ * query, or the write transaction's client for a uniqueness scan (so the scan
2531
+ * shares the advisory lock and sees a consistent snapshot).
2532
+ * @param executor {pg.Pool | pg.PoolClient}
2533
+ * @param options {object}
2534
+ * @param options.spaceId {string}
2535
+ * @param options.collectionId {string}
2536
+ * @param [options.excludeResourceId] {string}
2537
+ * @returns {Promise<EqualityCandidate[]>}
2538
+ */
2539
+ private async _readEqualityCandidates(
2540
+ executor: pg.Pool | pg.PoolClient,
2541
+ {
2542
+ spaceId,
2543
+ collectionId,
2544
+ excludeResourceId
2545
+ }: {
2546
+ spaceId: string
2547
+ collectionId: string
2548
+ excludeResourceId?: string
2549
+ }
2550
+ ): Promise<EqualityCandidate[]> {
2551
+ const { rows } = await executor.query<{
2552
+ resource_id: string
2553
+ content: Buffer | null
2554
+ is_json: boolean
2555
+ custom: ResourceMetadataCustom | Record<string, unknown> | null
2556
+ }>(
2557
+ `SELECT resource_id, content, is_json, custom FROM resources
2558
+ WHERE space_id = $1 AND collection_id = $2 AND NOT deleted
2559
+ AND ($3::text IS NULL OR resource_id <> $3)
2560
+ ORDER BY resource_id`,
2561
+ [spaceId, collectionId, excludeResourceId ?? null]
2562
+ )
2563
+ const candidates: EqualityCandidate[] = []
2564
+ for (const row of rows) {
2565
+ let content: unknown
2566
+ if (row.is_json && row.content) {
2567
+ try {
2568
+ content = JSON.parse(row.content.toString('utf8')) as unknown
2569
+ } catch {
2570
+ // skip an unparsable body -- it contributes no content attributes
2571
+ }
2572
+ }
2573
+ candidates.push({
2574
+ resourceId: row.resource_id,
2575
+ ...(content !== undefined && { content }),
2576
+ ...(row.custom !== null && { custom: row.custom })
2577
+ })
2578
+ }
2579
+ return candidates
2580
+ }
2581
+
2346
2582
  // Policies
2347
2583
 
2348
2584
  /**
package/src/errors.ts CHANGED
@@ -165,26 +165,39 @@ export class IdConflictError extends ProblemError {
165
165
  }
166
166
 
167
167
  /**
168
- * 409 — a Resource write carries an `indexed` blinded attribute marked
169
- * `unique: true` whose (HMAC key id, name, value) triple is already claimed by
170
- * another live Resource in the same Collection (the EDV unique-attribute
171
- * invariant, enforced by backends carrying the `blinded-index-query` feature).
172
- * Reuses the `id-conflict` problem type (like the WebKMS conflicts): a unique
173
- * blinded attribute is a client-chosen identifier-like claim, and the registry
174
- * defines no more specific kind. Only ever observable by a caller already
175
- * authorized to write the target.
168
+ * 409 — a write claims a `unique: true` indexed attribute already claimed by
169
+ * another live Resource in the same Collection. Two invariants share it: the
170
+ * EDV blinded one (an `indexed` blinded attribute's (HMAC key id, name, value)
171
+ * triple; the `blinded-index-query` feature, `variant: 'blinded'`, the
172
+ * default) and the plaintext equality one (a `unique`-declared `indexes`
173
+ * attribute's (name, value) pair; the `equality-query` feature,
174
+ * `variant: 'equality'`). Reuses the `id-conflict` problem type (like the
175
+ * WebKMS conflicts): a unique attribute is a client-chosen identifier-like
176
+ * claim, and the registry defines no more specific kind. Only ever observable
177
+ * by a caller already authorized to write the target.
176
178
  */
177
179
  export class UniqueAttributeConflictError extends ProblemError {
178
- constructor() {
180
+ constructor({
181
+ variant = 'blinded'
182
+ }: { variant?: 'blinded' | 'equality' } = {}) {
183
+ const attribute =
184
+ variant === 'equality'
185
+ ? 'unique indexed attribute'
186
+ : 'unique blinded index attribute'
179
187
  const detail =
180
- 'Could not write document; a unique blinded index attribute is already' +
188
+ `Could not write document; a ${attribute} is already` +
181
189
  ' in use by another Resource in this Collection.'
182
190
  super({
183
191
  type: ProblemTypes.ID_CONFLICT,
184
- title: 'A unique blinded index attribute is already in use.',
192
+ title: `A ${attribute} is already in use.`,
185
193
  detail,
186
194
  statusCode: 409,
187
- problems: [{ detail, pointer: '#/indexed' }]
195
+ problems: [
196
+ {
197
+ detail,
198
+ pointer: variant === 'equality' ? '#/indexes' : '#/indexed'
199
+ }
200
+ ]
188
201
  })
189
202
  }
190
203
  }