@haverstack/adapter-conformance 0.11.0 → 0.12.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/dist/record.d.ts.map +1 -1
- package/dist/record.js +563 -165
- package/dist/record.js.map +1 -1
- package/package.json +2 -2
package/dist/record.js
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import { describe, test, expect, beforeEach, afterEach } from 'vitest';
|
|
23
23
|
import { SYSTEM_TYPES } from '@haverstack/core';
|
|
24
|
+
import { ElementSetMovedError, inReach, resolveQuery } from '@haverstack/core/adapter';
|
|
24
25
|
import { expectStackErrorCode } from './errors.js';
|
|
25
26
|
import { CONFORMANCE_TYPE_ID, conformanceType, makeRecord, uniqueId } from './helpers.js';
|
|
26
27
|
export function runRecordAdapterConformance(options) {
|
|
@@ -48,31 +49,31 @@ export function runRecordAdapterConformance(options) {
|
|
|
48
49
|
describe('capabilities', () => {
|
|
49
50
|
test('declares a well-formed StackCapabilities object', () => {
|
|
50
51
|
expect(['none', 'field', 'path']).toContain(capabilities.filter.content);
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
expect(Array.isArray(capabilities.sort.fields)).toBe(true);
|
|
54
|
-
expect(typeof capabilities.sort.contentField).toBe('boolean');
|
|
55
|
-
for (const cap of [
|
|
52
|
+
for (const flag of [
|
|
53
|
+
capabilities.filter.contentPresent,
|
|
56
54
|
capabilities.filter.contentByType,
|
|
55
|
+
capabilities.filter.search,
|
|
56
|
+
capabilities.sort.contentField,
|
|
57
57
|
capabilities.sort.contentFieldByType,
|
|
58
58
|
]) {
|
|
59
|
-
expect(
|
|
60
|
-
}
|
|
61
|
-
});
|
|
62
|
-
test('the per-Type caps are 0 whenever filter.content is "none"', () => {
|
|
63
|
-
// Both travel in the body of the endpoint a reach of "none" omits.
|
|
64
|
-
if (capabilities.filter.content === 'none') {
|
|
65
|
-
expect(capabilities.filter.contentByType).toBe(0);
|
|
66
|
-
expect(capabilities.sort.contentFieldByType).toBe(0);
|
|
59
|
+
expect(typeof flag).toBe('boolean');
|
|
67
60
|
}
|
|
61
|
+
expect(Array.isArray(capabilities.sort.fields)).toBe(true);
|
|
68
62
|
});
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
63
|
+
// Each travels only in the POST /records/query body, which a reach of
|
|
64
|
+
// "none" does not serve.
|
|
65
|
+
test('what needs the query body is false whenever filter.content is "none"', () => {
|
|
72
66
|
if (capabilities.filter.content === 'none') {
|
|
73
67
|
expect(capabilities.filter.contentPresent).toBe(false);
|
|
68
|
+
expect(capabilities.filter.contentByType).toBe(false);
|
|
69
|
+
expect(capabilities.sort.contentFieldByType).toBe(false);
|
|
74
70
|
}
|
|
75
71
|
});
|
|
72
|
+
// A query naming no sort reads `createdAt`, newest first, so every
|
|
73
|
+
// adapter already orders by it.
|
|
74
|
+
test('sort.fields includes createdAt', () => {
|
|
75
|
+
expect(capabilities.sort.fields).toContain('createdAt');
|
|
76
|
+
});
|
|
76
77
|
});
|
|
77
78
|
// -----------------------------------------------------------------
|
|
78
79
|
// Types
|
|
@@ -122,44 +123,79 @@ export function runRecordAdapterConformance(options) {
|
|
|
122
123
|
await expectStackErrorCode(adapter.createRecord(makeRecord({ id: record.id })), 'conflict');
|
|
123
124
|
expect((await adapter.getRecord(record.id))?.content).toEqual(record.content);
|
|
124
125
|
});
|
|
125
|
-
test('
|
|
126
|
+
test('patchContent merges the patch at the top level and bumps version', async () => {
|
|
126
127
|
const record = makeRecord({ content: { title: 'Before', priority: 1 } });
|
|
127
128
|
await adapter.createRecord(record);
|
|
128
|
-
const updated = await adapter.
|
|
129
|
-
contentPatch: { title: 'After' },
|
|
130
|
-
});
|
|
129
|
+
const updated = await adapter.patchContent(record.id, { title: 'After' });
|
|
131
130
|
expect(updated.content).toEqual({ title: 'After', priority: 1 });
|
|
132
131
|
expect(updated.version).toBe(record.version + 1);
|
|
133
132
|
});
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
// `updatedAt` and the actor stamps do not move.
|
|
133
|
+
// The aspects the journal keeps in full never bump: the column lands,
|
|
134
|
+
// and `version`, `updatedAt` and the actor stamps do not move.
|
|
137
135
|
// See docs/spec/versioning.md § Version history.
|
|
138
|
-
test('
|
|
136
|
+
test('setParent and setUnlisted write their column without moving version', async () => {
|
|
139
137
|
const parent = makeRecord();
|
|
140
138
|
await adapter.createRecord(parent);
|
|
141
139
|
const record = makeRecord({ content: { title: 'Hello' } });
|
|
142
140
|
await adapter.createRecord(record);
|
|
143
|
-
const moved = await adapter.
|
|
141
|
+
const moved = await adapter.setParent(record.id, parent.id);
|
|
144
142
|
expect(moved.parentId).toBe(parent.id);
|
|
145
143
|
expect(moved.version).toBe(record.version);
|
|
146
144
|
expect(moved.updatedAt).toEqual(record.updatedAt);
|
|
147
|
-
const unlisted = await adapter.
|
|
145
|
+
const unlisted = await adapter.setUnlisted(record.id, true);
|
|
148
146
|
expect(unlisted.unlistedAt).toBeInstanceOf(Date);
|
|
149
147
|
expect(unlisted.version).toBe(record.version);
|
|
150
148
|
expect(unlisted.parentId).toBe(parent.id);
|
|
151
|
-
const listed = await adapter.
|
|
149
|
+
const listed = await adapter.setUnlisted(record.id, false);
|
|
152
150
|
expect(listed.unlistedAt).toBeUndefined();
|
|
153
151
|
expect(listed.version).toBe(record.version);
|
|
152
|
+
const rooted = await adapter.setParent(record.id, null);
|
|
153
|
+
expect(rooted.parentId).toBeUndefined();
|
|
154
154
|
});
|
|
155
|
-
test('a
|
|
155
|
+
test('a write that does not bump takes no snapshot', async () => {
|
|
156
156
|
const parent = makeRecord();
|
|
157
157
|
await adapter.createRecord(parent);
|
|
158
158
|
const record = makeRecord();
|
|
159
159
|
await adapter.createRecord(record);
|
|
160
|
-
await adapter.
|
|
160
|
+
await adapter.setParent(record.id, parent.id);
|
|
161
|
+
await adapter.setUnlisted(record.id, true);
|
|
162
|
+
await adapter.setAssociations(record.id, [{ kind: 'tag', label: 'x' }]);
|
|
163
|
+
await adapter.setAccess(record.id, [{ kind: 'anyone', label: 'read' }]);
|
|
161
164
|
expect(await adapter.getVersions(record.id)).toEqual([]);
|
|
162
165
|
});
|
|
166
|
+
// Every write checks its precondition inside its own write, whether
|
|
167
|
+
// or not it bumps. See docs/spec/versioning.md § Optimistic concurrency.
|
|
168
|
+
test('every write refuses a stale ifVersion and changes nothing', async () => {
|
|
169
|
+
const parent = makeRecord();
|
|
170
|
+
await adapter.createRecord(parent);
|
|
171
|
+
const record = makeRecord({ content: { title: 'v1' } });
|
|
172
|
+
await adapter.createRecord(record);
|
|
173
|
+
const stale = { ifVersion: record.version + 1 };
|
|
174
|
+
const tag = { kind: 'tag', label: 'x' };
|
|
175
|
+
const anyone = { kind: 'anyone', label: 'read' };
|
|
176
|
+
for (const write of [
|
|
177
|
+
() => adapter.patchContent(record.id, { title: 'v2' }, stale),
|
|
178
|
+
() => adapter.setParent(record.id, parent.id, stale),
|
|
179
|
+
() => adapter.setUnlisted(record.id, true, stale),
|
|
180
|
+
() => adapter.editAssociations(record.id, [{ op: 'add', association: tag }], stale),
|
|
181
|
+
() => adapter.setAssociations(record.id, [tag], stale),
|
|
182
|
+
() => adapter.editAccess(record.id, [{ op: 'add', association: anyone }], stale),
|
|
183
|
+
() => adapter.setAccess(record.id, [anyone], stale),
|
|
184
|
+
]) {
|
|
185
|
+
await expectStackErrorCode(write(), 'version_conflict');
|
|
186
|
+
}
|
|
187
|
+
const after = await adapter.getRecord(record.id);
|
|
188
|
+
expect(after.content).toEqual({ title: 'v1' });
|
|
189
|
+
expect(after.parentId).toBeUndefined();
|
|
190
|
+
expect(after.unlistedAt).toBeUndefined();
|
|
191
|
+
expect(after.associations ?? []).toEqual([]);
|
|
192
|
+
expect(after.permissions ?? []).toEqual([]);
|
|
193
|
+
const current = { ifVersion: record.version };
|
|
194
|
+
expect((await adapter.setParent(record.id, parent.id, current)).parentId).toBe(parent.id);
|
|
195
|
+
expect((await adapter.setAccess(record.id, [anyone], current)).permissions).toEqual([
|
|
196
|
+
anyone,
|
|
197
|
+
]);
|
|
198
|
+
});
|
|
163
199
|
test('soft deleteRecord sets deletedAt; undeleteRecord clears it', async () => {
|
|
164
200
|
const record = makeRecord();
|
|
165
201
|
await adapter.createRecord(record);
|
|
@@ -245,7 +281,7 @@ export function runRecordAdapterConformance(options) {
|
|
|
245
281
|
// initialization, outside this interface — nothing here writes it.
|
|
246
282
|
// The invariant holds either way: if the adapter under test has one,
|
|
247
283
|
// a generic query must still never surface it.
|
|
248
|
-
const { records } = await adapter.queryRecords({});
|
|
284
|
+
const { records } = await adapter.queryRecords(resolveQuery({}));
|
|
249
285
|
expect(records.some((r) => r.id === SYSTEM_TYPES.CONFIG)).toBe(false);
|
|
250
286
|
});
|
|
251
287
|
});
|
|
@@ -254,15 +290,28 @@ export function runRecordAdapterConformance(options) {
|
|
|
254
290
|
// -----------------------------------------------------------------
|
|
255
291
|
describe('associations', () => {
|
|
256
292
|
const tag = (label) => ({ kind: 'tag', label });
|
|
293
|
+
// A tag filter is conjunctive, and its length is the caller's — far
|
|
294
|
+
// past any statement's parameter cap (100 on Durable Object SQLite).
|
|
295
|
+
test('a tag filter matches the records carrying every tag named, however many', async () => {
|
|
296
|
+
const labels = Array.from({ length: 120 }, (_, i) => `tag-${i}`);
|
|
297
|
+
const all = makeRecord({ associations: labels.map((label) => ({ kind: 'tag', label })) });
|
|
298
|
+
const allButOne = makeRecord({
|
|
299
|
+
associations: labels.slice(1).map((label) => ({ kind: 'tag', label })),
|
|
300
|
+
});
|
|
301
|
+
await adapter.createRecord(all);
|
|
302
|
+
await adapter.createRecord(allButOne);
|
|
303
|
+
const result = await adapter.queryRecords(resolveQuery({ filter: { tags: [...labels, labels[0]] } }));
|
|
304
|
+
expect(result.records.map((r) => r.id)).toEqual([all.id]);
|
|
305
|
+
});
|
|
257
306
|
test('an add puts a tag on the record and a remove takes it off; neither bumps version', async () => {
|
|
258
307
|
const record = makeRecord();
|
|
259
308
|
await adapter.createRecord(record);
|
|
260
|
-
const tagged = await adapter.
|
|
309
|
+
const tagged = await adapter.editAssociations(record.id, [
|
|
261
310
|
{ op: 'add', association: { kind: 'tag', label: 'important' } },
|
|
262
311
|
]);
|
|
263
312
|
expect(tagged.associations).toContainEqual({ kind: 'tag', label: 'important' });
|
|
264
313
|
expect(tagged.version).toBe(record.version);
|
|
265
|
-
const untagged = await adapter.
|
|
314
|
+
const untagged = await adapter.editAssociations(record.id, [
|
|
266
315
|
{
|
|
267
316
|
op: 'remove',
|
|
268
317
|
association: {
|
|
@@ -280,24 +329,23 @@ export function runRecordAdapterConformance(options) {
|
|
|
280
329
|
test('an add is idempotent — a duplicate does not create a second entry', async () => {
|
|
281
330
|
const record = makeRecord();
|
|
282
331
|
await adapter.createRecord(record);
|
|
283
|
-
await adapter.
|
|
332
|
+
await adapter.editAssociations(record.id, [
|
|
284
333
|
{ op: 'add', association: { kind: 'tag', label: 'dup' } },
|
|
285
334
|
]);
|
|
286
|
-
const twice = await adapter.
|
|
335
|
+
const twice = await adapter.editAssociations(record.id, [
|
|
287
336
|
{ op: 'add', association: { kind: 'tag', label: 'dup' } },
|
|
288
337
|
]);
|
|
289
338
|
expect(twice.associations?.filter((a) => a.kind === 'tag' && a.label === 'dup')).toHaveLength(1);
|
|
290
339
|
});
|
|
291
|
-
test('an
|
|
292
|
-
await expectStackErrorCode(adapter.
|
|
340
|
+
test('an edit on a nonexistent record reports not_found rather than creating an orphan', async () => {
|
|
341
|
+
await expectStackErrorCode(adapter.editAssociations(uniqueId('missing'), [
|
|
293
342
|
{ op: 'add', association: { kind: 'tag', label: 'x' } },
|
|
294
343
|
]), 'not_found');
|
|
295
344
|
});
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
// agreement is worth pinning here rather than once per adapter.
|
|
345
|
+
// Data associations and permissions are two aspects, stored apart:
|
|
346
|
+
// a write to one never reads or moves the other.
|
|
299
347
|
// See docs/spec/access-control.md § Record-level permissions.
|
|
300
|
-
test('
|
|
348
|
+
test('permissions are their own aspect, edited and replaced apart from associations', async () => {
|
|
301
349
|
const record = makeRecord();
|
|
302
350
|
await adapter.createRecord(record);
|
|
303
351
|
const grant = {
|
|
@@ -305,20 +353,21 @@ export function runRecordAdapterConformance(options) {
|
|
|
305
353
|
label: 'read',
|
|
306
354
|
grantee: { kind: 'entity', entityId: 'did:key:z6MkConformance' },
|
|
307
355
|
};
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
356
|
+
// A grantee and a relationship target naming the same entity are
|
|
357
|
+
// two different facts.
|
|
358
|
+
const related = {
|
|
359
|
+
kind: 'relationship',
|
|
360
|
+
label: 'read',
|
|
361
|
+
target: { kind: 'entity', entityId: 'did:key:z6MkConformance' },
|
|
362
|
+
};
|
|
363
|
+
await adapter.editAssociations(record.id, [{ op: 'add', association: related }]);
|
|
364
|
+
const granted = await adapter.editAccess(record.id, [{ op: 'add', association: grant }]);
|
|
314
365
|
expect(granted.permissions).toEqual([grant]);
|
|
315
|
-
expect(granted.associations).toEqual([
|
|
366
|
+
expect(granted.associations).toEqual([related]);
|
|
316
367
|
expect(granted.version).toBe(record.version);
|
|
317
|
-
const revoked = await adapter.
|
|
318
|
-
{ op: 'remove', association: grant },
|
|
319
|
-
]);
|
|
368
|
+
const revoked = await adapter.editAccess(record.id, [{ op: 'remove', association: grant }]);
|
|
320
369
|
expect(revoked.permissions ?? []).toEqual([]);
|
|
321
|
-
expect(revoked.associations).toEqual([
|
|
370
|
+
expect(revoked.associations).toEqual([related]);
|
|
322
371
|
});
|
|
323
372
|
test('a group grantee keeps its role — member and admin are two entries', async () => {
|
|
324
373
|
const record = makeRecord();
|
|
@@ -331,18 +380,16 @@ export function runRecordAdapterConformance(options) {
|
|
|
331
380
|
});
|
|
332
381
|
const member = forRole('member');
|
|
333
382
|
const admin = forRole('admin');
|
|
334
|
-
await adapter.
|
|
335
|
-
const both = await adapter.
|
|
336
|
-
{ op: 'add', association: admin },
|
|
337
|
-
]);
|
|
383
|
+
await adapter.editAccess(record.id, [{ op: 'add', association: member }]);
|
|
384
|
+
const both = await adapter.editAccess(record.id, [{ op: 'add', association: admin }]);
|
|
338
385
|
expect(both.permissions).toEqual(expect.arrayContaining([member, admin]));
|
|
339
386
|
expect(both.permissions).toHaveLength(2);
|
|
340
387
|
});
|
|
341
388
|
test('a list of adds and removes lands as one write that bumps no version', async () => {
|
|
342
389
|
const record = makeRecord();
|
|
343
390
|
await adapter.createRecord(record, { journal: { ops: ['create'], kind: 'created' } });
|
|
344
|
-
await adapter.
|
|
345
|
-
const amended = await adapter.
|
|
391
|
+
await adapter.editAssociations(record.id, [{ op: 'add', association: tag('old') }]);
|
|
392
|
+
const amended = await adapter.editAssociations(record.id, [
|
|
346
393
|
{ op: 'remove', association: tag('old') },
|
|
347
394
|
{ op: 'add', association: tag('new') },
|
|
348
395
|
{ op: 'add', association: tag('extra') },
|
|
@@ -353,49 +400,134 @@ export function runRecordAdapterConformance(options) {
|
|
|
353
400
|
expect(amended.updatedAt).toEqual(record.updatedAt);
|
|
354
401
|
expect(await adapter.getJournal(record.id)).toHaveLength(2);
|
|
355
402
|
});
|
|
356
|
-
|
|
403
|
+
// The compare-and-swap Stack retries on: the set its rules were asked
|
|
404
|
+
// of must be the set the write lands on. Order is not part of a set.
|
|
405
|
+
// See docs/spec/adapters.md § Editing associations.
|
|
406
|
+
test('an element write refuses an expectSet the stored set no longer matches, and changes nothing', async () => {
|
|
357
407
|
const record = makeRecord();
|
|
358
408
|
await adapter.createRecord(record);
|
|
359
|
-
const
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
{ op: '
|
|
369
|
-
|
|
370
|
-
])
|
|
371
|
-
|
|
409
|
+
const anyone = { kind: 'anyone', label: 'read' };
|
|
410
|
+
await adapter.setAssociations(record.id, [tag('a'), tag('b')]);
|
|
411
|
+
await adapter.setAccess(record.id, [anyone]);
|
|
412
|
+
const journal = { ops: ['associate'], kind: 'changed' };
|
|
413
|
+
const staleTags = { expectSet: [tag('a')], journal };
|
|
414
|
+
const stalePermissions = { expectSet: [], journal };
|
|
415
|
+
for (const write of [
|
|
416
|
+
() => adapter.editAssociations(record.id, [{ op: 'add', association: tag('c') }], staleTags),
|
|
417
|
+
() => adapter.setAssociations(record.id, [tag('c')], staleTags),
|
|
418
|
+
() => adapter.editAccess(record.id, [{ op: 'remove', association: anyone }], stalePermissions),
|
|
419
|
+
() => adapter.setAccess(record.id, [], stalePermissions),
|
|
420
|
+
]) {
|
|
421
|
+
await expect(write()).rejects.toBeInstanceOf(ElementSetMovedError);
|
|
422
|
+
}
|
|
423
|
+
const after = await adapter.getRecord(record.id);
|
|
424
|
+
expect(after.associations).toEqual(expect.arrayContaining([tag('a'), tag('b')]));
|
|
425
|
+
expect(after.associations).toHaveLength(2);
|
|
426
|
+
expect(after.permissions).toEqual([anyone]);
|
|
427
|
+
expect(await adapter.getJournal(record.id)).toHaveLength(0);
|
|
428
|
+
const held = await adapter.editAssociations(record.id, [{ op: 'add', association: tag('c') }], { expectSet: [tag('b'), tag('a')] });
|
|
429
|
+
expect(held.associations).toHaveLength(3);
|
|
430
|
+
const cleared = await adapter.setAccess(record.id, [], { expectSet: [anyone] });
|
|
431
|
+
expect(cleared.permissions ?? []).toEqual([]);
|
|
372
432
|
});
|
|
373
|
-
|
|
433
|
+
// Asked before ifVersion: the stale version here would otherwise
|
|
434
|
+
// answer 412 for a record whose state is the reason for refusing.
|
|
435
|
+
// See docs/spec/adapters.md § Interface split.
|
|
436
|
+
test('an aspect write refuses a soft-deleted record, and changes nothing', async () => {
|
|
437
|
+
const parent = makeRecord();
|
|
374
438
|
const record = makeRecord();
|
|
439
|
+
await adapter.createRecord(parent);
|
|
375
440
|
await adapter.createRecord(record);
|
|
376
|
-
|
|
377
|
-
await
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
]
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
|
|
441
|
+
await adapter.deleteRecord(record.id);
|
|
442
|
+
const before = await adapter.getRecord(record.id);
|
|
443
|
+
const journalBefore = await adapter.getJournal(record.id);
|
|
444
|
+
const anyone = { kind: 'anyone', label: 'read' };
|
|
445
|
+
const journal = { ops: ['associate'], kind: 'changed' };
|
|
446
|
+
const opts = { ifVersion: record.version, journal };
|
|
447
|
+
for (const write of [
|
|
448
|
+
() => adapter.setParent(record.id, parent.id, opts),
|
|
449
|
+
() => adapter.setUnlisted(record.id, true, opts),
|
|
450
|
+
() => adapter.editAssociations(record.id, [{ op: 'add', association: tag('a') }], opts),
|
|
451
|
+
() => adapter.setAssociations(record.id, [tag('a')], opts),
|
|
452
|
+
() => adapter.editAccess(record.id, [{ op: 'add', association: anyone }], opts),
|
|
453
|
+
() => adapter.setAccess(record.id, [anyone], opts),
|
|
454
|
+
]) {
|
|
455
|
+
await expectStackErrorCode(write(), 'conflict');
|
|
456
|
+
}
|
|
457
|
+
expect(await adapter.getRecord(record.id)).toEqual(before);
|
|
458
|
+
expect(await adapter.getJournal(record.id)).toEqual(journalBefore);
|
|
459
|
+
});
|
|
460
|
+
// Stack's expectSet is what getRecord() answered, so a set read back
|
|
461
|
+
// must match itself in every shape an element can take.
|
|
462
|
+
test('a set read back through getRecord matches itself as expectSet', async () => {
|
|
463
|
+
const record = makeRecord();
|
|
464
|
+
await adapter.createRecord(record);
|
|
465
|
+
const associations = [
|
|
466
|
+
tag('t'),
|
|
467
|
+
{ kind: 'attachment', label: 'cover', fileId: 'a'.repeat(64) },
|
|
468
|
+
{
|
|
469
|
+
kind: 'attachment',
|
|
470
|
+
label: 'cover',
|
|
471
|
+
fileId: 'b'.repeat(64),
|
|
472
|
+
attachmentRecordId: uniqueId('att'),
|
|
473
|
+
},
|
|
474
|
+
{ kind: 'relationship', label: 'cites', target: { kind: 'record', recordId: 'r1' } },
|
|
475
|
+
{
|
|
476
|
+
kind: 'relationship',
|
|
477
|
+
label: 'cites',
|
|
478
|
+
target: { kind: 'record', recordId: 'r1', stackUrl: 'https://example.com' },
|
|
479
|
+
},
|
|
480
|
+
{
|
|
481
|
+
kind: 'relationship',
|
|
482
|
+
label: 'about',
|
|
483
|
+
target: { kind: 'entity', entityId: 'did:key:z6MkConformance' },
|
|
484
|
+
},
|
|
485
|
+
{
|
|
486
|
+
kind: 'relationship',
|
|
487
|
+
label: 'link',
|
|
488
|
+
target: { kind: 'external', ns: 'isbn', id: '1' },
|
|
489
|
+
},
|
|
490
|
+
];
|
|
491
|
+
const permissions = [
|
|
492
|
+
{ kind: 'anyone', label: 'read' },
|
|
493
|
+
{
|
|
494
|
+
kind: 'permission',
|
|
495
|
+
label: 'read',
|
|
496
|
+
grantee: { kind: 'entity', entityId: 'did:key:z6MkConformance' },
|
|
497
|
+
},
|
|
498
|
+
{
|
|
499
|
+
kind: 'permission',
|
|
500
|
+
label: 'write',
|
|
501
|
+
grantee: { kind: 'group', groupId: uniqueId('grp'), role: 'admin' },
|
|
502
|
+
},
|
|
503
|
+
];
|
|
504
|
+
await adapter.setAssociations(record.id, associations);
|
|
505
|
+
await adapter.setAccess(record.id, permissions);
|
|
506
|
+
const read = (await adapter.getRecord(record.id));
|
|
507
|
+
const tagged = await adapter.editAssociations(record.id, [{ op: 'add', association: tag('u') }], { expectSet: read.associations ?? [] });
|
|
508
|
+
expect(tagged.associations).toHaveLength(associations.length + 1);
|
|
509
|
+
const shared = await adapter.setAccess(record.id, permissions.slice(1), {
|
|
510
|
+
expectSet: read.permissions ?? [],
|
|
511
|
+
});
|
|
512
|
+
expect(shared.permissions).toHaveLength(permissions.length - 1);
|
|
384
513
|
});
|
|
385
|
-
test('each
|
|
514
|
+
test('each set verb replaces only its own aspect', async () => {
|
|
386
515
|
const record = makeRecord();
|
|
387
516
|
await adapter.createRecord(record);
|
|
388
517
|
const anyone = { kind: 'anyone', label: 'read' };
|
|
389
|
-
await adapter.
|
|
518
|
+
await adapter.editAssociations(record.id, [
|
|
390
519
|
{ op: 'add', association: { kind: 'tag', label: 'draft' } },
|
|
391
520
|
]);
|
|
392
|
-
await adapter.
|
|
393
|
-
const retagged = await adapter.
|
|
521
|
+
await adapter.editAccess(record.id, [{ op: 'add', association: anyone }]);
|
|
522
|
+
const retagged = await adapter.setAssociations(record.id, [
|
|
523
|
+
{ kind: 'tag', label: 'reviewed' },
|
|
524
|
+
]);
|
|
394
525
|
expect(retagged.associations).toEqual([{ kind: 'tag', label: 'reviewed' }]);
|
|
395
526
|
expect(retagged.permissions).toEqual([anyone]);
|
|
396
|
-
const cleared = await adapter.
|
|
527
|
+
const cleared = await adapter.setAccess(record.id, []);
|
|
397
528
|
expect(cleared.associations).toEqual([{ kind: 'tag', label: 'reviewed' }]);
|
|
398
529
|
expect(cleared.permissions ?? []).toEqual([]);
|
|
530
|
+
expect(cleared.version).toBe(record.version);
|
|
399
531
|
});
|
|
400
532
|
});
|
|
401
533
|
// -----------------------------------------------------------------
|
|
@@ -425,9 +557,9 @@ export function runRecordAdapterConformance(options) {
|
|
|
425
557
|
content: record.content,
|
|
426
558
|
updatedAt: record.updatedAt,
|
|
427
559
|
});
|
|
428
|
-
await adapter.
|
|
429
|
-
await adapter.
|
|
430
|
-
await adapter.
|
|
560
|
+
await adapter.patchContent(record.id, { title: 'v2' });
|
|
561
|
+
await adapter.setParent(record.id, parent.id);
|
|
562
|
+
await adapter.editAssociations(record.id, [
|
|
431
563
|
{ op: 'add', association: { kind: 'tag', label: 'keep-me' } },
|
|
432
564
|
]);
|
|
433
565
|
const restored = await adapter.restoreVersion(record.id, record.version);
|
|
@@ -465,7 +597,7 @@ export function runRecordAdapterConformance(options) {
|
|
|
465
597
|
await adapter.createRecord(record, {
|
|
466
598
|
journal: { ops: ['create'], kind: 'created' },
|
|
467
599
|
});
|
|
468
|
-
await adapter.
|
|
600
|
+
await adapter.patchContent(record.id, { title: 'v2' }, { journal: { ops: ['patch'], kind: 'changed' } });
|
|
469
601
|
const log = await adapter.getJournal(record.id);
|
|
470
602
|
expect(log).toHaveLength(2);
|
|
471
603
|
// version, typeId and parentId are the adapter's to stamp: Stack
|
|
@@ -480,8 +612,8 @@ export function runRecordAdapterConformance(options) {
|
|
|
480
612
|
const second = makeRecord();
|
|
481
613
|
await adapter.createRecord(first, { journal: { ops: ['create'], kind: 'created' } });
|
|
482
614
|
await adapter.createRecord(second, { journal: { ops: ['create'], kind: 'created' } });
|
|
483
|
-
await adapter.
|
|
484
|
-
await adapter.
|
|
615
|
+
await adapter.editAssociations(first.id, [{ op: 'add', association: { kind: 'tag', label: 'a' } }], { journal: { ops: ['associate'], kind: 'changed' } });
|
|
616
|
+
await adapter.editAssociations(first.id, [{ op: 'add', association: { kind: 'tag', label: 'b' } }], { journal: { ops: ['associate'], kind: 'changed' } });
|
|
485
617
|
expect((await adapter.getJournal(first.id)).map((e) => e.seq)).toEqual([1, 2, 3]);
|
|
486
618
|
// Per record, so a busy neighbour never advances this one's log.
|
|
487
619
|
expect((await adapter.getJournal(second.id)).map((e) => e.seq)).toEqual([1]);
|
|
@@ -489,14 +621,14 @@ export function runRecordAdapterConformance(options) {
|
|
|
489
621
|
test('an association change journals a version that stands still', async () => {
|
|
490
622
|
const record = makeRecord();
|
|
491
623
|
await adapter.createRecord(record);
|
|
492
|
-
await adapter.
|
|
624
|
+
await adapter.editAssociations(record.id, [{ op: 'add', association: { kind: 'tag', label: 'starred' } }], {
|
|
493
625
|
journal: {
|
|
494
626
|
ops: ['associate'],
|
|
495
627
|
kind: 'changed',
|
|
496
628
|
associations: [{ op: 'add', association: { kind: 'tag', label: 'starred' } }],
|
|
497
629
|
},
|
|
498
630
|
});
|
|
499
|
-
await adapter.
|
|
631
|
+
await adapter.editAssociations(record.id, [{ op: 'remove', association: { kind: 'tag', label: 'starred' } }], {
|
|
500
632
|
journal: {
|
|
501
633
|
ops: ['dissociate'],
|
|
502
634
|
kind: 'changed',
|
|
@@ -530,7 +662,7 @@ export function runRecordAdapterConformance(options) {
|
|
|
530
662
|
fileId: 'a'.repeat(64),
|
|
531
663
|
attachmentRecordId: uniqueId('new'),
|
|
532
664
|
};
|
|
533
|
-
await adapter.
|
|
665
|
+
await adapter.editAssociations(record.id, [{ op: 'add', association: association }], {
|
|
534
666
|
journal: {
|
|
535
667
|
ops: ['associate'],
|
|
536
668
|
kind: 'changed',
|
|
@@ -552,8 +684,8 @@ export function runRecordAdapterConformance(options) {
|
|
|
552
684
|
fileId: 'b'.repeat(64),
|
|
553
685
|
attachmentRecordId: uniqueId('upload'),
|
|
554
686
|
};
|
|
555
|
-
await adapter.
|
|
556
|
-
await adapter.
|
|
687
|
+
await adapter.editAssociations(record.id, [{ op: 'add', association: previous }]);
|
|
688
|
+
await adapter.editAssociations(record.id, [
|
|
557
689
|
{
|
|
558
690
|
op: 'remove',
|
|
559
691
|
association: { kind: 'attachment', label: 'cover', fileId: 'b'.repeat(64) },
|
|
@@ -568,25 +700,6 @@ export function runRecordAdapterConformance(options) {
|
|
|
568
700
|
const [entry] = await adapter.getJournal(record.id);
|
|
569
701
|
expect(entry.associations).toEqual([{ op: 'remove', association: previous }]);
|
|
570
702
|
});
|
|
571
|
-
test('an association list is keyed by identity, last wins', async () => {
|
|
572
|
-
// Association identity is the association table's primary key, so a
|
|
573
|
-
// list naming one identity twice is a state no store can hold.
|
|
574
|
-
// An adapter that kept both would let a record reach one a SQL
|
|
575
|
-
// adapter cannot represent.
|
|
576
|
-
const record = makeRecord();
|
|
577
|
-
await adapter.createRecord(record);
|
|
578
|
-
const fileId = 'c'.repeat(64);
|
|
579
|
-
const winner = uniqueId('second');
|
|
580
|
-
const updated = await adapter.mutateRecord(record.id, {
|
|
581
|
-
associations: [
|
|
582
|
-
{ kind: 'attachment', label: 'cover', fileId, attachmentRecordId: uniqueId('first') },
|
|
583
|
-
{ kind: 'attachment', label: 'cover', fileId, attachmentRecordId: winner },
|
|
584
|
-
],
|
|
585
|
-
}, { bumpsVersion: false });
|
|
586
|
-
expect(updated.associations).toEqual([
|
|
587
|
-
{ kind: 'attachment', label: 'cover', fileId, attachmentRecordId: winner },
|
|
588
|
-
]);
|
|
589
|
-
});
|
|
590
703
|
test('previousParentId tells "did not move" apart from "moved off the root"', async () => {
|
|
591
704
|
// The one field on any entry where null is a value rather than an
|
|
592
705
|
// input spelling. Collapsing both to absent loses which happened —
|
|
@@ -596,8 +709,7 @@ export function runRecordAdapterConformance(options) {
|
|
|
596
709
|
await adapter.createRecord(parent);
|
|
597
710
|
const record = makeRecord();
|
|
598
711
|
await adapter.createRecord(record, { journal: { ops: ['create'], kind: 'created' } });
|
|
599
|
-
await adapter.
|
|
600
|
-
bumpsVersion: false,
|
|
712
|
+
await adapter.setParent(record.id, parent.id, {
|
|
601
713
|
journal: { ops: ['reparent'], kind: 'changed', previousParentId: null },
|
|
602
714
|
});
|
|
603
715
|
const log = await adapter.getJournal(record.id);
|
|
@@ -609,7 +721,7 @@ export function runRecordAdapterConformance(options) {
|
|
|
609
721
|
const record = makeRecord();
|
|
610
722
|
await adapter.createRecord(record, { journal: { ops: ['create'], kind: 'created' } });
|
|
611
723
|
for (const label of ['a', 'b', 'c']) {
|
|
612
|
-
await adapter.
|
|
724
|
+
await adapter.editAssociations(record.id, [{ op: 'add', association: { kind: 'tag', label } }], { journal: { ops: ['associate'], kind: 'changed' } });
|
|
613
725
|
}
|
|
614
726
|
expect(await adapter.getJournal(record.id)).toHaveLength(4);
|
|
615
727
|
expect((await adapter.getJournal(record.id, { afterSeq: 2 })).map((e) => e.seq)).toEqual([
|
|
@@ -623,7 +735,7 @@ export function runRecordAdapterConformance(options) {
|
|
|
623
735
|
});
|
|
624
736
|
test('a failed mutation leaves no entry behind', async () => {
|
|
625
737
|
const missing = uniqueId('missing');
|
|
626
|
-
await expectStackErrorCode(adapter.
|
|
738
|
+
await expectStackErrorCode(adapter.patchContent(missing, { title: 'x' }, { journal: { ops: ['patch'], kind: 'changed' } }), 'not_found');
|
|
627
739
|
// The record was never created, so its log is refused rather than
|
|
628
740
|
// read — what matters is that the entry did not land somewhere.
|
|
629
741
|
await expectStackErrorCode(adapter.getJournal(missing), 'not_found');
|
|
@@ -676,7 +788,7 @@ export function runRecordAdapterConformance(options) {
|
|
|
676
788
|
const seen = new Set();
|
|
677
789
|
let cursor;
|
|
678
790
|
do {
|
|
679
|
-
const page = await adapter.queryRecords({ limit: 2, cursor: cursor ?? undefined });
|
|
791
|
+
const page = await adapter.queryRecords(resolveQuery({ limit: 2, cursor: cursor ?? undefined }));
|
|
680
792
|
expect(page.records.length).toBeLessThanOrEqual(2);
|
|
681
793
|
for (const r of page.records)
|
|
682
794
|
seen.add(r.id);
|
|
@@ -685,22 +797,275 @@ export function runRecordAdapterConformance(options) {
|
|
|
685
797
|
for (const id of ids)
|
|
686
798
|
expect(seen.has(id)).toBe(true);
|
|
687
799
|
});
|
|
800
|
+
// A cursor names the last record its page held, so the next page
|
|
801
|
+
// starts after that record wherever it now falls in the order.
|
|
802
|
+
test('a page resumes after its cursor whatever was inserted ahead of it', async () => {
|
|
803
|
+
for (let i = 0; i < 5; i++) {
|
|
804
|
+
await adapter.createRecord(makeRecord({ createdAt: new Date(1_000 + i) }));
|
|
805
|
+
}
|
|
806
|
+
const sort = { field: 'createdAt', direction: 'asc' };
|
|
807
|
+
const first = await adapter.queryRecords(resolveQuery({ limit: 2, sort }));
|
|
808
|
+
await adapter.createRecord(makeRecord({ createdAt: new Date(1) }));
|
|
809
|
+
const second = await adapter.queryRecords(resolveQuery({ limit: 2, cursor: first.cursor, sort }));
|
|
810
|
+
const all = (await adapter.queryRecords(resolveQuery({ limit: 100, sort }))).records.map((r) => r.id);
|
|
811
|
+
const after = all.indexOf(first.records[1].id);
|
|
812
|
+
expect(second.records.map((r) => r.id)).toEqual(all.slice(after + 1, after + 3));
|
|
813
|
+
});
|
|
688
814
|
test('a malformed cursor is refused rather than misread', async () => {
|
|
689
|
-
await expectStackErrorCode(adapter.queryRecords({ cursor: 'not-a-real-cursor' }), 'bad_request');
|
|
815
|
+
await expectStackErrorCode(adapter.queryRecords(resolveQuery({ cursor: 'not-a-real-cursor' })), 'bad_request');
|
|
690
816
|
});
|
|
691
817
|
test('a cursor minted under one sort field is refused when replayed under a different one', async () => {
|
|
692
818
|
await adapter.createRecord(makeRecord());
|
|
693
819
|
await adapter.createRecord(makeRecord());
|
|
694
|
-
const minted = await adapter.queryRecords({
|
|
820
|
+
const minted = await adapter.queryRecords(resolveQuery({
|
|
695
821
|
limit: 1,
|
|
696
822
|
sort: { field: 'createdAt', direction: 'asc' },
|
|
697
|
-
});
|
|
823
|
+
}));
|
|
698
824
|
expect(minted.cursor).not.toBeNull();
|
|
699
|
-
await expectStackErrorCode(adapter.queryRecords({
|
|
825
|
+
await expectStackErrorCode(adapter.queryRecords(resolveQuery({
|
|
700
826
|
limit: 1,
|
|
701
827
|
cursor: minted.cursor,
|
|
702
828
|
sort: { field: 'updatedAt', direction: 'asc' },
|
|
703
|
-
}), 'bad_request');
|
|
829
|
+
})), 'bad_request');
|
|
830
|
+
});
|
|
831
|
+
});
|
|
832
|
+
// -----------------------------------------------------------------
|
|
833
|
+
// Querying within a reach — optional, so each test asks whether the
|
|
834
|
+
// adapter has queryReadable() at all. The reference reading is core's
|
|
835
|
+
// inReach(); see docs/spec/access-control.md § Reach.
|
|
836
|
+
// -----------------------------------------------------------------
|
|
837
|
+
describe('querying within a reach', () => {
|
|
838
|
+
const NOTE = 'org.haverstack.conformance/note';
|
|
839
|
+
const OTHER = 'org.haverstack.conformance/other';
|
|
840
|
+
const OTHER_TYPE_ID = `${OTHER}@1`;
|
|
841
|
+
const ALICE = 'did:key:conformance-alice';
|
|
842
|
+
const BOB = 'did:key:conformance-bob';
|
|
843
|
+
const anyone = { kind: 'anyone', label: 'read' };
|
|
844
|
+
const toAlice = {
|
|
845
|
+
kind: 'permission',
|
|
846
|
+
label: 'read',
|
|
847
|
+
grantee: { kind: 'entity', entityId: ALICE },
|
|
848
|
+
};
|
|
849
|
+
const toMembers = {
|
|
850
|
+
kind: 'permission',
|
|
851
|
+
label: 'read',
|
|
852
|
+
grantee: { kind: 'group', groupId: 'conformance-group', role: 'member' },
|
|
853
|
+
};
|
|
854
|
+
const toAdmins = {
|
|
855
|
+
kind: 'permission',
|
|
856
|
+
label: 'read',
|
|
857
|
+
grantee: { kind: 'group', groupId: 'conformance-group', role: 'admin' },
|
|
858
|
+
};
|
|
859
|
+
/** One record per route into a reach, and one beside each that misses it. */
|
|
860
|
+
const populate = async () => {
|
|
861
|
+
const records = [
|
|
862
|
+
makeRecord(),
|
|
863
|
+
makeRecord({ typeId: OTHER_TYPE_ID }),
|
|
864
|
+
makeRecord({ createdBy: { subjectId: ALICE } }),
|
|
865
|
+
makeRecord({ typeId: OTHER_TYPE_ID, createdBy: { subjectId: ALICE } }),
|
|
866
|
+
makeRecord({ createdBy: { subjectId: BOB, principalId: ALICE } }),
|
|
867
|
+
makeRecord({ permissions: [anyone] }),
|
|
868
|
+
makeRecord({ typeId: OTHER_TYPE_ID, permissions: [anyone] }),
|
|
869
|
+
// A family's other versions are in reach with it; one sharing its
|
|
870
|
+
// prefix is not.
|
|
871
|
+
makeRecord({ typeId: `${NOTE}@2` }),
|
|
872
|
+
makeRecord({ typeId: `${NOTE}@2`, createdBy: { subjectId: ALICE } }),
|
|
873
|
+
makeRecord({ typeId: `${NOTE}s@1`, createdBy: { subjectId: ALICE } }),
|
|
874
|
+
makeRecord({ permissions: [toAlice] }),
|
|
875
|
+
makeRecord({ permissions: [{ ...toAlice, label: 'write' }] }),
|
|
876
|
+
makeRecord({ permissions: [toMembers] }),
|
|
877
|
+
makeRecord({ permissions: [toAdmins] }),
|
|
878
|
+
makeRecord({ permissions: [toAlice, toMembers] }),
|
|
879
|
+
makeRecord({ permissions: [toAlice, { ...toAlice, label: 'write' }] }),
|
|
880
|
+
makeRecord({ permissions: [anyone, { ...toAlice, label: 'write' }] }),
|
|
881
|
+
makeRecord({ permissions: [toMembers, toAdmins] }),
|
|
882
|
+
];
|
|
883
|
+
for (const record of records)
|
|
884
|
+
await adapter.createRecord(record);
|
|
885
|
+
return records;
|
|
886
|
+
};
|
|
887
|
+
// Far past any statement's parameter cap — 100 on Durable Object SQLite.
|
|
888
|
+
const manyFamilies = Array.from({ length: 150 }, (_, i) => `org.haverstack.conformance/t${i}`);
|
|
889
|
+
const manyGroups = Array.from({ length: 60 }, (_, i) => `conformance-group-${i}`);
|
|
890
|
+
const reaches = [
|
|
891
|
+
['nothing constrained', {}],
|
|
892
|
+
['every Record of a family', { within: { baseIds: [OTHER], permissions: [] } }],
|
|
893
|
+
[
|
|
894
|
+
'the Records one subject authored in a family',
|
|
895
|
+
{
|
|
896
|
+
within: {
|
|
897
|
+
baseIds: [],
|
|
898
|
+
authored: { subjectId: ALICE, baseIds: [NOTE] },
|
|
899
|
+
permissions: [],
|
|
900
|
+
},
|
|
901
|
+
},
|
|
902
|
+
],
|
|
903
|
+
['Records carrying `anyone`', { within: { baseIds: [], permissions: [[anyone]] } }],
|
|
904
|
+
[
|
|
905
|
+
'Records carrying a read naming an entity',
|
|
906
|
+
{ within: { baseIds: [], permissions: [[toAlice]] } },
|
|
907
|
+
],
|
|
908
|
+
[
|
|
909
|
+
'Records carrying a read naming a group at one role',
|
|
910
|
+
{ within: { baseIds: [], permissions: [[toAdmins]] } },
|
|
911
|
+
],
|
|
912
|
+
[
|
|
913
|
+
'every arm at once',
|
|
914
|
+
{
|
|
915
|
+
within: {
|
|
916
|
+
baseIds: [OTHER],
|
|
917
|
+
authored: { subjectId: ALICE, baseIds: [NOTE] },
|
|
918
|
+
permissions: [[anyone], [toMembers]],
|
|
919
|
+
},
|
|
920
|
+
},
|
|
921
|
+
],
|
|
922
|
+
['a fence alone', { fence: [OTHER] }],
|
|
923
|
+
[
|
|
924
|
+
'arms inside a fence',
|
|
925
|
+
{
|
|
926
|
+
fence: [NOTE],
|
|
927
|
+
within: {
|
|
928
|
+
baseIds: [OTHER],
|
|
929
|
+
authored: { subjectId: ALICE, baseIds: [NOTE, OTHER] },
|
|
930
|
+
permissions: [[anyone]],
|
|
931
|
+
},
|
|
932
|
+
},
|
|
933
|
+
],
|
|
934
|
+
['an empty fence', { fence: [], within: { baseIds: [NOTE], permissions: [[anyone]] } }],
|
|
935
|
+
['no arm at all', { within: { baseIds: [], permissions: [] } }],
|
|
936
|
+
[
|
|
937
|
+
'Records carrying every element of a set',
|
|
938
|
+
{ within: { baseIds: [], permissions: [[toAlice, { ...toAlice, label: 'write' }]] } },
|
|
939
|
+
],
|
|
940
|
+
[
|
|
941
|
+
'Records carrying one whole set of several',
|
|
942
|
+
{
|
|
943
|
+
within: {
|
|
944
|
+
baseIds: [],
|
|
945
|
+
permissions: [
|
|
946
|
+
[{ ...toAlice, label: 'write' }, anyone],
|
|
947
|
+
[toMembers, toAdmins],
|
|
948
|
+
],
|
|
949
|
+
},
|
|
950
|
+
},
|
|
951
|
+
],
|
|
952
|
+
['an empty set, which no Record carries', { within: { baseIds: [], permissions: [[]] } }],
|
|
953
|
+
[
|
|
954
|
+
'a reach of any size',
|
|
955
|
+
{
|
|
956
|
+
fence: [NOTE, ...manyFamilies],
|
|
957
|
+
within: {
|
|
958
|
+
baseIds: manyFamilies,
|
|
959
|
+
authored: { subjectId: ALICE, baseIds: [NOTE, ...manyFamilies] },
|
|
960
|
+
permissions: [
|
|
961
|
+
...manyGroups.flatMap((groupId) => [
|
|
962
|
+
[{ ...toMembers, grantee: { ...toMembers.grantee, groupId } }],
|
|
963
|
+
[
|
|
964
|
+
{
|
|
965
|
+
...toAdmins,
|
|
966
|
+
label: 'write',
|
|
967
|
+
grantee: { ...toAdmins.grantee, groupId },
|
|
968
|
+
},
|
|
969
|
+
anyone,
|
|
970
|
+
],
|
|
971
|
+
]),
|
|
972
|
+
[toAlice],
|
|
973
|
+
],
|
|
974
|
+
},
|
|
975
|
+
},
|
|
976
|
+
],
|
|
977
|
+
];
|
|
978
|
+
test.each(reaches)('returns exactly the Records in reach: %s', async (_name, reach) => {
|
|
979
|
+
if (!adapter.queryReadable)
|
|
980
|
+
return;
|
|
981
|
+
const records = await populate();
|
|
982
|
+
const result = await adapter.queryReadable(resolveQuery({ limit: 100 }), reach);
|
|
983
|
+
const expected = records.filter((r) => inReach(r, reach)).map((r) => r.id);
|
|
984
|
+
expect(result.records.map((r) => r.id).sort()).toEqual(expected.sort());
|
|
985
|
+
expect(result.cursor).toBeNull();
|
|
986
|
+
});
|
|
987
|
+
test("a reach narrows the query's own filter rather than widening it", async () => {
|
|
988
|
+
if (!adapter.queryReadable)
|
|
989
|
+
return;
|
|
990
|
+
const pinned = { kind: 'tag', label: 'pinned' };
|
|
991
|
+
const tagged = makeRecord({ permissions: [anyone], associations: [pinned] });
|
|
992
|
+
await adapter.createRecord(tagged);
|
|
993
|
+
await adapter.createRecord(makeRecord({ permissions: [anyone] }));
|
|
994
|
+
await adapter.createRecord(makeRecord({ associations: [pinned] }));
|
|
995
|
+
const result = await adapter.queryReadable(resolveQuery({ filter: { tags: ['pinned'] } }), {
|
|
996
|
+
within: { baseIds: [], permissions: [[anyone]] },
|
|
997
|
+
});
|
|
998
|
+
expect(result.records.map((r) => r.id)).toEqual([tagged.id]);
|
|
999
|
+
});
|
|
1000
|
+
// Paging is decided among the Records in reach alone: every page is
|
|
1001
|
+
// full, and no cursor comes back once they are exhausted, however
|
|
1002
|
+
// many Records outside the reach remain.
|
|
1003
|
+
const pagesWithin = async (sort) => {
|
|
1004
|
+
if (!adapter.queryReadable)
|
|
1005
|
+
return;
|
|
1006
|
+
const reach = { within: { baseIds: [], permissions: [[anyone]] } };
|
|
1007
|
+
const inside = [];
|
|
1008
|
+
for (let i = 0; i < 9; i++) {
|
|
1009
|
+
const visible = i % 3 === 1;
|
|
1010
|
+
const record = makeRecord({
|
|
1011
|
+
content: { title: `t${i}`, priority: i },
|
|
1012
|
+
...(visible && { permissions: [anyone] }),
|
|
1013
|
+
});
|
|
1014
|
+
await adapter.createRecord(record);
|
|
1015
|
+
if (visible)
|
|
1016
|
+
inside.push(record.id);
|
|
1017
|
+
}
|
|
1018
|
+
const seen = [];
|
|
1019
|
+
let cursor;
|
|
1020
|
+
do {
|
|
1021
|
+
const page = await adapter.queryReadable(resolveQuery({ limit: 1, cursor, sort }), reach);
|
|
1022
|
+
expect(page.records).toHaveLength(1);
|
|
1023
|
+
seen.push(page.records[0].id);
|
|
1024
|
+
cursor = page.cursor ?? undefined;
|
|
1025
|
+
} while (cursor);
|
|
1026
|
+
expect(seen.sort()).toEqual(inside.sort());
|
|
1027
|
+
};
|
|
1028
|
+
test('pages through the Records in reach alone, by a native field', () => pagesWithin({ field: 'createdAt', direction: 'asc' }));
|
|
1029
|
+
if (capabilities.sort.contentField) {
|
|
1030
|
+
test('pages through the Records in reach alone, by a content field', async () => {
|
|
1031
|
+
await adapter.saveType(conformanceType());
|
|
1032
|
+
await pagesWithin({ contentField: 'priority', direction: 'desc' });
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
});
|
|
1036
|
+
// -----------------------------------------------------------------
|
|
1037
|
+
// Type families
|
|
1038
|
+
// -----------------------------------------------------------------
|
|
1039
|
+
describe('filter.baseId', () => {
|
|
1040
|
+
const NOTE = 'org.haverstack.conformance/note';
|
|
1041
|
+
const ids = async (filter) => (await adapter.queryRecords(resolveQuery({ filter }))).records.map((r) => r.id).sort();
|
|
1042
|
+
/** One record per TypeId, keyed by it. */
|
|
1043
|
+
const seed = async (typeIds) => {
|
|
1044
|
+
const seeded = {};
|
|
1045
|
+
for (const typeId of typeIds) {
|
|
1046
|
+
const record = makeRecord({ typeId });
|
|
1047
|
+
await adapter.createRecord(record);
|
|
1048
|
+
seeded[typeId] = record.id;
|
|
1049
|
+
}
|
|
1050
|
+
return seeded;
|
|
1051
|
+
};
|
|
1052
|
+
test('matches every version of the family and nothing that only shares its prefix', async () => {
|
|
1053
|
+
const r = await seed([`${NOTE}@1`, `${NOTE}@2`, `${NOTE}s@1`, `${NOTE}-x@1`, `${NOTE}@10`]);
|
|
1054
|
+
expect(await ids({ baseId: NOTE })).toEqual([r[`${NOTE}@1`], r[`${NOTE}@2`], r[`${NOTE}@10`]].sort());
|
|
1055
|
+
});
|
|
1056
|
+
test('matches any of several families', async () => {
|
|
1057
|
+
const r = await seed([`${NOTE}@1`, `${NOTE}s@1`, 'org.haverstack.conformance/other@1']);
|
|
1058
|
+
expect(await ids({ baseId: [NOTE, `${NOTE}s`] })).toEqual([r[`${NOTE}@1`], r[`${NOTE}s@1`]].sort());
|
|
1059
|
+
});
|
|
1060
|
+
test('intersects with filter.typeId', async () => {
|
|
1061
|
+
const r = await seed([`${NOTE}@1`, `${NOTE}@2`, `${NOTE}s@1`]);
|
|
1062
|
+
expect(await ids({ baseId: NOTE, typeId: [`${NOTE}@2`, `${NOTE}s@1`] })).toEqual([
|
|
1063
|
+
r[`${NOTE}@2`],
|
|
1064
|
+
]);
|
|
1065
|
+
});
|
|
1066
|
+
test('an unknown family matches nothing', async () => {
|
|
1067
|
+
await seed([`${NOTE}@1`]);
|
|
1068
|
+
expect(await ids({ baseId: 'org.haverstack.conformance/none' })).toEqual([]);
|
|
704
1069
|
});
|
|
705
1070
|
});
|
|
706
1071
|
// -----------------------------------------------------------------
|
|
@@ -713,7 +1078,7 @@ export function runRecordAdapterConformance(options) {
|
|
|
713
1078
|
const decoy = makeRecord({ content: { title: 'b', priority: 2 } });
|
|
714
1079
|
await adapter.createRecord(match);
|
|
715
1080
|
await adapter.createRecord(decoy);
|
|
716
|
-
const result = await adapter.queryRecords({ filter: { content: { priority: 1 } } });
|
|
1081
|
+
const result = await adapter.queryRecords(resolveQuery({ filter: { content: { priority: 1 } } }));
|
|
717
1082
|
// The positive assertion first: `not.toContain` and `every` are
|
|
718
1083
|
// both vacuously true on an empty result, so without this an
|
|
719
1084
|
// adapter that declares filter.content and returns nothing at
|
|
@@ -729,7 +1094,7 @@ export function runRecordAdapterConformance(options) {
|
|
|
729
1094
|
await adapter.createRecord(absent);
|
|
730
1095
|
await adapter.createRecord(explicitNull);
|
|
731
1096
|
await adapter.createRecord(present);
|
|
732
|
-
const result = await adapter.queryRecords({ filter: { content: { priority: null } } });
|
|
1097
|
+
const result = await adapter.queryRecords(resolveQuery({ filter: { content: { priority: null } } }));
|
|
733
1098
|
const ids = result.records.map((r) => r.id).sort();
|
|
734
1099
|
expect(ids).toEqual([absent.id, explicitNull.id].sort());
|
|
735
1100
|
expect(ids).not.toContain(present.id);
|
|
@@ -739,7 +1104,7 @@ export function runRecordAdapterConformance(options) {
|
|
|
739
1104
|
const nested = makeRecord({ content: { title: 'y', priority: { high: true } } });
|
|
740
1105
|
await adapter.createRecord(scalarMatch);
|
|
741
1106
|
await adapter.createRecord(nested);
|
|
742
|
-
const result = await adapter.queryRecords({ filter: { content: { priority: 1 } } });
|
|
1107
|
+
const result = await adapter.queryRecords(resolveQuery({ filter: { content: { priority: 1 } } }));
|
|
743
1108
|
expect(result.records.map((r) => r.id)).toContain(scalarMatch.id);
|
|
744
1109
|
expect(result.records.map((r) => r.id)).not.toContain(nested.id);
|
|
745
1110
|
});
|
|
@@ -751,10 +1116,43 @@ export function runRecordAdapterConformance(options) {
|
|
|
751
1116
|
await adapter.createRecord(present);
|
|
752
1117
|
await adapter.createRecord(absent);
|
|
753
1118
|
await adapter.createRecord(nullValue);
|
|
754
|
-
const result = await adapter.queryRecords({ filter: { contentPresent: ['priority'] } });
|
|
1119
|
+
const result = await adapter.queryRecords(resolveQuery({ filter: { contentPresent: ['priority'] } }));
|
|
755
1120
|
expect(result.records.map((r) => r.id)).toEqual([present.id]);
|
|
756
1121
|
});
|
|
757
1122
|
}
|
|
1123
|
+
// A value matches as `===` compares it: its JSON type as well as
|
|
1124
|
+
// its value. See docs/spec/data-model.md § Filter.
|
|
1125
|
+
test('a content filter value matches its own JSON type only', async () => {
|
|
1126
|
+
const bool = makeRecord({ content: { title: 'b', flag: true } });
|
|
1127
|
+
const num = makeRecord({ content: { title: 'n', flag: 1 } });
|
|
1128
|
+
const text = makeRecord({ content: { title: 't', flag: '1' } });
|
|
1129
|
+
for (const record of [bool, num, text])
|
|
1130
|
+
await adapter.createRecord(record);
|
|
1131
|
+
for (const [value, record] of [
|
|
1132
|
+
[true, bool],
|
|
1133
|
+
[1, num],
|
|
1134
|
+
['1', text],
|
|
1135
|
+
]) {
|
|
1136
|
+
const result = await adapter.queryRecords(resolveQuery({ filter: { content: { flag: value } } }));
|
|
1137
|
+
expect(result.records.map((r) => r.id)).toEqual([record.id]);
|
|
1138
|
+
}
|
|
1139
|
+
});
|
|
1140
|
+
if (capabilities.filter.contentPresent && capabilities.filter.content === 'path') {
|
|
1141
|
+
// Both are element-wise, so a path reaching a null and a value
|
|
1142
|
+
// satisfies each. See docs/spec/data-model.md § Filter.
|
|
1143
|
+
test('a multi-valued path satisfies both contentPresent and a null filter', async () => {
|
|
1144
|
+
const mixed = makeRecord({
|
|
1145
|
+
content: { title: 'x', tags: [null, 'a'], emails: [{ value: null }, { value: 1 }] },
|
|
1146
|
+
});
|
|
1147
|
+
await adapter.createRecord(mixed);
|
|
1148
|
+
for (const path of ['tags', 'emails.value']) {
|
|
1149
|
+
const present = await adapter.queryRecords(resolveQuery({ filter: { contentPresent: [path] } }));
|
|
1150
|
+
expect(present.records.map((r) => r.id)).toEqual([mixed.id]);
|
|
1151
|
+
const nulls = await adapter.queryRecords(resolveQuery({ filter: { content: { [path]: null } } }));
|
|
1152
|
+
expect(nulls.records.map((r) => r.id)).toEqual([mixed.id]);
|
|
1153
|
+
}
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
758
1156
|
if (capabilities.filter.content === 'path') {
|
|
759
1157
|
test('an array is spread element-wise when filtering a nested path', async () => {
|
|
760
1158
|
const match = makeRecord({
|
|
@@ -765,9 +1163,9 @@ export function runRecordAdapterConformance(options) {
|
|
|
765
1163
|
});
|
|
766
1164
|
await adapter.createRecord(match);
|
|
767
1165
|
await adapter.createRecord(noMatch);
|
|
768
|
-
const result = await adapter.queryRecords({
|
|
1166
|
+
const result = await adapter.queryRecords(resolveQuery({
|
|
769
1167
|
filter: { content: { 'emails.value': 'a@b.c' } },
|
|
770
|
-
});
|
|
1168
|
+
}));
|
|
771
1169
|
expect(result.records.map((r) => r.id)).toEqual([match.id]);
|
|
772
1170
|
});
|
|
773
1171
|
test('a nested path filter reaches a property inside a plain (non-array) object', async () => {
|
|
@@ -775,16 +1173,16 @@ export function runRecordAdapterConformance(options) {
|
|
|
775
1173
|
const noMatch = makeRecord({ content: { title: 'y', address: { city: 'Lyon' } } });
|
|
776
1174
|
await adapter.createRecord(match);
|
|
777
1175
|
await adapter.createRecord(noMatch);
|
|
778
|
-
const result = await adapter.queryRecords({
|
|
1176
|
+
const result = await adapter.queryRecords(resolveQuery({
|
|
779
1177
|
filter: { content: { 'address.city': 'Berlin' } },
|
|
780
|
-
});
|
|
1178
|
+
}));
|
|
781
1179
|
expect(result.records.map((r) => r.id)).toEqual([match.id]);
|
|
782
1180
|
});
|
|
783
1181
|
test('a filter path descending through a scalar matches nothing, rather than throwing', async () => {
|
|
784
1182
|
await adapter.createRecord(makeRecord({ content: { title: 'x', priority: 1 } }));
|
|
785
|
-
const result = await adapter.queryRecords({
|
|
1183
|
+
const result = await adapter.queryRecords(resolveQuery({
|
|
786
1184
|
filter: { content: { 'priority.nested': 'anything' } },
|
|
787
|
-
});
|
|
1185
|
+
}));
|
|
788
1186
|
expect(result.records).toEqual([]);
|
|
789
1187
|
});
|
|
790
1188
|
}
|
|
@@ -793,7 +1191,7 @@ export function runRecordAdapterConformance(options) {
|
|
|
793
1191
|
// -----------------------------------------------------------------
|
|
794
1192
|
// Content by type — capability-gated on filter.contentByType
|
|
795
1193
|
// -----------------------------------------------------------------
|
|
796
|
-
if (capabilities.filter.contentByType
|
|
1194
|
+
if (capabilities.filter.contentByType) {
|
|
797
1195
|
describe('content by type', () => {
|
|
798
1196
|
const OTHER = 'org.haverstack.conformance/other@1';
|
|
799
1197
|
const THIRD = 'org.haverstack.conformance/third@1';
|
|
@@ -807,14 +1205,14 @@ export function runRecordAdapterConformance(options) {
|
|
|
807
1205
|
];
|
|
808
1206
|
for (const record of [note, other, ...decoys])
|
|
809
1207
|
await adapter.createRecord(record);
|
|
810
|
-
const result = await adapter.queryRecords({
|
|
1208
|
+
const result = await adapter.queryRecords(resolveQuery({
|
|
811
1209
|
filter: {
|
|
812
1210
|
contentByType: {
|
|
813
1211
|
[CONFORMANCE_TYPE_ID]: { content: { title: 'a' } },
|
|
814
1212
|
[OTHER]: { content: { heading: 'a' } },
|
|
815
1213
|
},
|
|
816
1214
|
},
|
|
817
|
-
});
|
|
1215
|
+
}));
|
|
818
1216
|
expect(result.records.map((r) => r.id).sort()).toEqual([note.id, other.id].sort());
|
|
819
1217
|
});
|
|
820
1218
|
test('an arm naming nothing matches every record of its type', async () => {
|
|
@@ -822,11 +1220,11 @@ export function runRecordAdapterConformance(options) {
|
|
|
822
1220
|
const decoy = makeRecord({ typeId: THIRD });
|
|
823
1221
|
for (const record of [...notes, decoy])
|
|
824
1222
|
await adapter.createRecord(record);
|
|
825
|
-
const result = await adapter.queryRecords({
|
|
1223
|
+
const result = await adapter.queryRecords(resolveQuery({
|
|
826
1224
|
filter: {
|
|
827
1225
|
contentByType: { [CONFORMANCE_TYPE_ID]: {}, [OTHER]: { content: { a: 1 } } },
|
|
828
1226
|
},
|
|
829
|
-
});
|
|
1227
|
+
}));
|
|
830
1228
|
expect(result.records.map((r) => r.id).sort()).toEqual(notes.map((r) => r.id).sort());
|
|
831
1229
|
});
|
|
832
1230
|
test('intersects with every other key of the filter', async () => {
|
|
@@ -835,12 +1233,12 @@ export function runRecordAdapterConformance(options) {
|
|
|
835
1233
|
const child = makeRecord({ content: { title: 'a' }, parentId: parent.id });
|
|
836
1234
|
await adapter.createRecord(kept);
|
|
837
1235
|
await adapter.createRecord(child);
|
|
838
|
-
const result = await adapter.queryRecords({
|
|
1236
|
+
const result = await adapter.queryRecords(resolveQuery({
|
|
839
1237
|
filter: {
|
|
840
1238
|
parentId: parent.id,
|
|
841
1239
|
contentByType: { [CONFORMANCE_TYPE_ID]: { content: { title: 'a' } }, [OTHER]: {} },
|
|
842
1240
|
},
|
|
843
|
-
});
|
|
1241
|
+
}));
|
|
844
1242
|
expect(result.records.map((r) => r.id)).toEqual([child.id]);
|
|
845
1243
|
});
|
|
846
1244
|
if (capabilities.filter.contentPresent) {
|
|
@@ -850,14 +1248,14 @@ export function runRecordAdapterConformance(options) {
|
|
|
850
1248
|
const other = makeRecord({ typeId: OTHER, content: { priority: 1 } });
|
|
851
1249
|
for (const record of [present, absent, other])
|
|
852
1250
|
await adapter.createRecord(record);
|
|
853
|
-
const result = await adapter.queryRecords({
|
|
1251
|
+
const result = await adapter.queryRecords(resolveQuery({
|
|
854
1252
|
filter: {
|
|
855
1253
|
contentByType: {
|
|
856
1254
|
[CONFORMANCE_TYPE_ID]: { contentPresent: ['priority'] },
|
|
857
1255
|
[OTHER]: { contentPresent: ['missing'] },
|
|
858
1256
|
},
|
|
859
1257
|
},
|
|
860
|
-
});
|
|
1258
|
+
}));
|
|
861
1259
|
expect(result.records.map((r) => r.id)).toEqual([present.id]);
|
|
862
1260
|
});
|
|
863
1261
|
}
|
|
@@ -866,7 +1264,7 @@ export function runRecordAdapterConformance(options) {
|
|
|
866
1264
|
// -----------------------------------------------------------------
|
|
867
1265
|
// Sort by content field per type — gated on sort.contentFieldByType
|
|
868
1266
|
// -----------------------------------------------------------------
|
|
869
|
-
if (capabilities.sort.contentFieldByType
|
|
1267
|
+
if (capabilities.sort.contentFieldByType) {
|
|
870
1268
|
describe('sort by content field per type', () => {
|
|
871
1269
|
const OTHER = 'org.haverstack.conformance/other@1';
|
|
872
1270
|
const THIRD = 'org.haverstack.conformance/third@1';
|
|
@@ -899,9 +1297,9 @@ export function runRecordAdapterConformance(options) {
|
|
|
899
1297
|
const titles = (records) => records.map((r) => r.content.title);
|
|
900
1298
|
test.each(['asc', 'desc'])('orders each type by its own field or constant, %s, holding no value last', async (direction) => {
|
|
901
1299
|
await seed();
|
|
902
|
-
const result = await adapter.queryRecords({
|
|
1300
|
+
const result = await adapter.queryRecords(resolveQuery({
|
|
903
1301
|
sort: { contentFieldByType: BY_TYPE, direction },
|
|
904
|
-
});
|
|
1302
|
+
}));
|
|
905
1303
|
const ordered = ['a', 'b', 'c', 'd', 'e'];
|
|
906
1304
|
expect(titles(result.records)).toEqual([
|
|
907
1305
|
...(direction === 'asc' ? ordered : ordered.reverse()),
|
|
@@ -914,11 +1312,11 @@ export function runRecordAdapterConformance(options) {
|
|
|
914
1312
|
const seen = [];
|
|
915
1313
|
let cursor;
|
|
916
1314
|
do {
|
|
917
|
-
const page = await adapter.queryRecords({
|
|
1315
|
+
const page = await adapter.queryRecords(resolveQuery({
|
|
918
1316
|
limit: 1,
|
|
919
1317
|
cursor: cursor ?? undefined,
|
|
920
1318
|
sort: { contentFieldByType: BY_TYPE, direction: 'asc' },
|
|
921
|
-
});
|
|
1319
|
+
}));
|
|
922
1320
|
seen.push(...titles(page.records));
|
|
923
1321
|
cursor = page.cursor;
|
|
924
1322
|
} while (cursor);
|
|
@@ -927,15 +1325,15 @@ export function runRecordAdapterConformance(options) {
|
|
|
927
1325
|
if (capabilities.sort.contentField) {
|
|
928
1326
|
test('a cursor minted under one field is refused under a map', async () => {
|
|
929
1327
|
await seed();
|
|
930
|
-
const minted = await adapter.queryRecords({
|
|
1328
|
+
const minted = await adapter.queryRecords(resolveQuery({
|
|
931
1329
|
limit: 1,
|
|
932
1330
|
sort: { contentField: 'priority', direction: 'asc' },
|
|
933
|
-
});
|
|
934
|
-
await expectStackErrorCode(adapter.queryRecords({
|
|
1331
|
+
}));
|
|
1332
|
+
await expectStackErrorCode(adapter.queryRecords(resolveQuery({
|
|
935
1333
|
limit: 1,
|
|
936
1334
|
cursor: minted.cursor,
|
|
937
1335
|
sort: { contentFieldByType: BY_TYPE, direction: 'asc' },
|
|
938
|
-
}), 'bad_request');
|
|
1336
|
+
})), 'bad_request');
|
|
939
1337
|
});
|
|
940
1338
|
}
|
|
941
1339
|
});
|
|
@@ -948,7 +1346,7 @@ export function runRecordAdapterConformance(options) {
|
|
|
948
1346
|
const a = await adapter.createRecord(makeRecord());
|
|
949
1347
|
await new Promise((r) => setTimeout(r, 5));
|
|
950
1348
|
const b = await adapter.createRecord(makeRecord());
|
|
951
|
-
const result = await adapter.queryRecords({ sort: { field: 'createdAt', direction } });
|
|
1349
|
+
const result = await adapter.queryRecords(resolveQuery({ sort: { field: 'createdAt', direction } }));
|
|
952
1350
|
const ids = result.records.map((r) => r.id);
|
|
953
1351
|
expect(ids).toEqual(direction === 'asc' ? [a.id, b.id] : [b.id, a.id]);
|
|
954
1352
|
});
|
|
@@ -959,9 +1357,9 @@ export function runRecordAdapterConformance(options) {
|
|
|
959
1357
|
await adapter.saveType(conformanceType());
|
|
960
1358
|
await adapter.createRecord(makeRecord({ content: { title: 'x', priority: 1 } }));
|
|
961
1359
|
await adapter.createRecord(makeRecord({ content: { title: 'y', priority: 2 } }));
|
|
962
|
-
const result = await adapter.queryRecords({
|
|
1360
|
+
const result = await adapter.queryRecords(resolveQuery({
|
|
963
1361
|
sort: { contentField: 'priority', direction },
|
|
964
|
-
});
|
|
1362
|
+
}));
|
|
965
1363
|
const priorities = result.records.map((r) => r.content.priority);
|
|
966
1364
|
expect(priorities).toEqual(direction === 'asc' ? [1, 2] : [2, 1]);
|
|
967
1365
|
});
|
|
@@ -973,9 +1371,9 @@ export function runRecordAdapterConformance(options) {
|
|
|
973
1371
|
await adapter.createRecord(makeRecord({ content: { title: 'x', priority: 3 } }));
|
|
974
1372
|
await adapter.createRecord(makeRecord({ content: { title: 'y', priority: 1 } }));
|
|
975
1373
|
await adapter.createRecord(makeRecord({ content: { title: 'z', priority: 2 } }));
|
|
976
|
-
const result = await adapter.queryRecords({
|
|
1374
|
+
const result = await adapter.queryRecords(resolveQuery({
|
|
977
1375
|
sort: { contentField: 'priority', direction: 'asc' },
|
|
978
|
-
});
|
|
1376
|
+
}));
|
|
979
1377
|
expect(result.records.map((r) => r.content.priority)).toEqual([
|
|
980
1378
|
1, 2, 3,
|
|
981
1379
|
]);
|
|
@@ -990,9 +1388,9 @@ export function runRecordAdapterConformance(options) {
|
|
|
990
1388
|
for (const status of ['want', 'finished', 'reading']) {
|
|
991
1389
|
await adapter.createRecord(makeRecord({ content: { title: status, status } }));
|
|
992
1390
|
}
|
|
993
|
-
const result = await adapter.queryRecords({
|
|
1391
|
+
const result = await adapter.queryRecords(resolveQuery({
|
|
994
1392
|
sort: { contentField: 'status', direction: 'asc' },
|
|
995
|
-
});
|
|
1393
|
+
}));
|
|
996
1394
|
expect(result.records.map((r) => r.content.status)).toEqual([
|
|
997
1395
|
'finished',
|
|
998
1396
|
'reading',
|
|
@@ -1011,7 +1409,7 @@ export function runRecordAdapterConformance(options) {
|
|
|
1011
1409
|
const noMatch = makeRecord({ content: { title: 'something unrelated' } });
|
|
1012
1410
|
await adapter.createRecord(match);
|
|
1013
1411
|
await adapter.createRecord(noMatch);
|
|
1014
|
-
const result = await adapter.queryRecords({ filter: { search: 'brown' } });
|
|
1412
|
+
const result = await adapter.queryRecords(resolveQuery({ filter: { search: 'brown' } }));
|
|
1015
1413
|
expect(result.records.map((r) => r.id)).toContain(match.id);
|
|
1016
1414
|
expect(result.records.map((r) => r.id)).not.toContain(noMatch.id);
|
|
1017
1415
|
});
|
|
@@ -1022,17 +1420,17 @@ export function runRecordAdapterConformance(options) {
|
|
|
1022
1420
|
test('a content patch is reflected in search — the old content is no longer searchable', async () => {
|
|
1023
1421
|
const record = makeRecord({ content: { title: 'original wording' } });
|
|
1024
1422
|
await adapter.createRecord(record);
|
|
1025
|
-
await adapter.
|
|
1026
|
-
const stale = await adapter.queryRecords({ filter: { search: 'original' } });
|
|
1423
|
+
await adapter.patchContent(record.id, { title: 'updated wording' });
|
|
1424
|
+
const stale = await adapter.queryRecords(resolveQuery({ filter: { search: 'original' } }));
|
|
1027
1425
|
expect(stale.records.map((r) => r.id)).not.toContain(record.id);
|
|
1028
|
-
const fresh = await adapter.queryRecords({ filter: { search: 'updated' } });
|
|
1426
|
+
const fresh = await adapter.queryRecords(resolveQuery({ filter: { search: 'updated' } }));
|
|
1029
1427
|
expect(fresh.records.map((r) => r.id)).toContain(record.id);
|
|
1030
1428
|
});
|
|
1031
1429
|
test('a purged record is no longer found by search', async () => {
|
|
1032
1430
|
const record = makeRecord({ content: { title: 'ephemeral content' } });
|
|
1033
1431
|
await adapter.createRecord(record);
|
|
1034
1432
|
await adapter.deleteRecord(record.id, { purge: true });
|
|
1035
|
-
const result = await adapter.queryRecords({ filter: { search: 'ephemeral' } });
|
|
1433
|
+
const result = await adapter.queryRecords(resolveQuery({ filter: { search: 'ephemeral' } }));
|
|
1036
1434
|
expect(result.records.map((r) => r.id)).not.toContain(record.id);
|
|
1037
1435
|
});
|
|
1038
1436
|
});
|