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
package/src/backends/postgres.ts
CHANGED
|
@@ -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
|
-
//
|
|
1331
|
-
//
|
|
1332
|
-
//
|
|
1333
|
-
//
|
|
1334
|
-
//
|
|
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
|
-
//
|
|
1338
|
-
|
|
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({
|
|
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
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
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
|
-
|
|
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:
|
|
192
|
+
title: `A ${attribute} is already in use.`,
|
|
185
193
|
detail,
|
|
186
194
|
statusCode: 409,
|
|
187
|
-
problems: [
|
|
195
|
+
problems: [
|
|
196
|
+
{
|
|
197
|
+
detail,
|
|
198
|
+
pointer: variant === 'equality' ? '#/indexes' : '#/indexed'
|
|
199
|
+
}
|
|
200
|
+
]
|
|
188
201
|
})
|
|
189
202
|
}
|
|
190
203
|
}
|