@haverstack/adapter-conformance 0.10.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 +715 -135
- 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,18 +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
|
-
|
|
52
|
+
for (const flag of [
|
|
53
|
+
capabilities.filter.contentPresent,
|
|
54
|
+
capabilities.filter.contentByType,
|
|
55
|
+
capabilities.filter.search,
|
|
56
|
+
capabilities.sort.contentField,
|
|
57
|
+
capabilities.sort.contentFieldByType,
|
|
58
|
+
]) {
|
|
59
|
+
expect(typeof flag).toBe('boolean');
|
|
60
|
+
}
|
|
53
61
|
expect(Array.isArray(capabilities.sort.fields)).toBe(true);
|
|
54
|
-
expect(typeof capabilities.sort.contentField).toBe('boolean');
|
|
55
62
|
});
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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"', () => {
|
|
59
66
|
if (capabilities.filter.content === 'none') {
|
|
60
67
|
expect(capabilities.filter.contentPresent).toBe(false);
|
|
68
|
+
expect(capabilities.filter.contentByType).toBe(false);
|
|
69
|
+
expect(capabilities.sort.contentFieldByType).toBe(false);
|
|
61
70
|
}
|
|
62
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
|
+
});
|
|
63
77
|
});
|
|
64
78
|
// -----------------------------------------------------------------
|
|
65
79
|
// Types
|
|
@@ -109,44 +123,79 @@ export function runRecordAdapterConformance(options) {
|
|
|
109
123
|
await expectStackErrorCode(adapter.createRecord(makeRecord({ id: record.id })), 'conflict');
|
|
110
124
|
expect((await adapter.getRecord(record.id))?.content).toEqual(record.content);
|
|
111
125
|
});
|
|
112
|
-
test('
|
|
126
|
+
test('patchContent merges the patch at the top level and bumps version', async () => {
|
|
113
127
|
const record = makeRecord({ content: { title: 'Before', priority: 1 } });
|
|
114
128
|
await adapter.createRecord(record);
|
|
115
|
-
const updated = await adapter.
|
|
116
|
-
contentPatch: { title: 'After' },
|
|
117
|
-
});
|
|
129
|
+
const updated = await adapter.patchContent(record.id, { title: 'After' });
|
|
118
130
|
expect(updated.content).toEqual({ title: 'After', priority: 1 });
|
|
119
131
|
expect(updated.version).toBe(record.version + 1);
|
|
120
132
|
});
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
// `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.
|
|
124
135
|
// See docs/spec/versioning.md § Version history.
|
|
125
|
-
test('
|
|
136
|
+
test('setParent and setUnlisted write their column without moving version', async () => {
|
|
126
137
|
const parent = makeRecord();
|
|
127
138
|
await adapter.createRecord(parent);
|
|
128
139
|
const record = makeRecord({ content: { title: 'Hello' } });
|
|
129
140
|
await adapter.createRecord(record);
|
|
130
|
-
const moved = await adapter.
|
|
141
|
+
const moved = await adapter.setParent(record.id, parent.id);
|
|
131
142
|
expect(moved.parentId).toBe(parent.id);
|
|
132
143
|
expect(moved.version).toBe(record.version);
|
|
133
144
|
expect(moved.updatedAt).toEqual(record.updatedAt);
|
|
134
|
-
const unlisted = await adapter.
|
|
145
|
+
const unlisted = await adapter.setUnlisted(record.id, true);
|
|
135
146
|
expect(unlisted.unlistedAt).toBeInstanceOf(Date);
|
|
136
147
|
expect(unlisted.version).toBe(record.version);
|
|
137
148
|
expect(unlisted.parentId).toBe(parent.id);
|
|
138
|
-
const listed = await adapter.
|
|
149
|
+
const listed = await adapter.setUnlisted(record.id, false);
|
|
139
150
|
expect(listed.unlistedAt).toBeUndefined();
|
|
140
151
|
expect(listed.version).toBe(record.version);
|
|
152
|
+
const rooted = await adapter.setParent(record.id, null);
|
|
153
|
+
expect(rooted.parentId).toBeUndefined();
|
|
141
154
|
});
|
|
142
|
-
test('a
|
|
155
|
+
test('a write that does not bump takes no snapshot', async () => {
|
|
143
156
|
const parent = makeRecord();
|
|
144
157
|
await adapter.createRecord(parent);
|
|
145
158
|
const record = makeRecord();
|
|
146
159
|
await adapter.createRecord(record);
|
|
147
|
-
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' }]);
|
|
148
164
|
expect(await adapter.getVersions(record.id)).toEqual([]);
|
|
149
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
|
+
});
|
|
150
199
|
test('soft deleteRecord sets deletedAt; undeleteRecord clears it', async () => {
|
|
151
200
|
const record = makeRecord();
|
|
152
201
|
await adapter.createRecord(record);
|
|
@@ -232,7 +281,7 @@ export function runRecordAdapterConformance(options) {
|
|
|
232
281
|
// initialization, outside this interface — nothing here writes it.
|
|
233
282
|
// The invariant holds either way: if the adapter under test has one,
|
|
234
283
|
// a generic query must still never surface it.
|
|
235
|
-
const { records } = await adapter.queryRecords({});
|
|
284
|
+
const { records } = await adapter.queryRecords(resolveQuery({}));
|
|
236
285
|
expect(records.some((r) => r.id === SYSTEM_TYPES.CONFIG)).toBe(false);
|
|
237
286
|
});
|
|
238
287
|
});
|
|
@@ -241,15 +290,28 @@ export function runRecordAdapterConformance(options) {
|
|
|
241
290
|
// -----------------------------------------------------------------
|
|
242
291
|
describe('associations', () => {
|
|
243
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
|
+
});
|
|
244
306
|
test('an add puts a tag on the record and a remove takes it off; neither bumps version', async () => {
|
|
245
307
|
const record = makeRecord();
|
|
246
308
|
await adapter.createRecord(record);
|
|
247
|
-
const tagged = await adapter.
|
|
309
|
+
const tagged = await adapter.editAssociations(record.id, [
|
|
248
310
|
{ op: 'add', association: { kind: 'tag', label: 'important' } },
|
|
249
311
|
]);
|
|
250
312
|
expect(tagged.associations).toContainEqual({ kind: 'tag', label: 'important' });
|
|
251
313
|
expect(tagged.version).toBe(record.version);
|
|
252
|
-
const untagged = await adapter.
|
|
314
|
+
const untagged = await adapter.editAssociations(record.id, [
|
|
253
315
|
{
|
|
254
316
|
op: 'remove',
|
|
255
317
|
association: {
|
|
@@ -267,24 +329,23 @@ export function runRecordAdapterConformance(options) {
|
|
|
267
329
|
test('an add is idempotent — a duplicate does not create a second entry', async () => {
|
|
268
330
|
const record = makeRecord();
|
|
269
331
|
await adapter.createRecord(record);
|
|
270
|
-
await adapter.
|
|
332
|
+
await adapter.editAssociations(record.id, [
|
|
271
333
|
{ op: 'add', association: { kind: 'tag', label: 'dup' } },
|
|
272
334
|
]);
|
|
273
|
-
const twice = await adapter.
|
|
335
|
+
const twice = await adapter.editAssociations(record.id, [
|
|
274
336
|
{ op: 'add', association: { kind: 'tag', label: 'dup' } },
|
|
275
337
|
]);
|
|
276
338
|
expect(twice.associations?.filter((a) => a.kind === 'tag' && a.label === 'dup')).toHaveLength(1);
|
|
277
339
|
});
|
|
278
|
-
test('an
|
|
279
|
-
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'), [
|
|
280
342
|
{ op: 'add', association: { kind: 'tag', label: 'x' } },
|
|
281
343
|
]), 'not_found');
|
|
282
344
|
});
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
// 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.
|
|
286
347
|
// See docs/spec/access-control.md § Record-level permissions.
|
|
287
|
-
test('
|
|
348
|
+
test('permissions are their own aspect, edited and replaced apart from associations', async () => {
|
|
288
349
|
const record = makeRecord();
|
|
289
350
|
await adapter.createRecord(record);
|
|
290
351
|
const grant = {
|
|
@@ -292,20 +353,21 @@ export function runRecordAdapterConformance(options) {
|
|
|
292
353
|
label: 'read',
|
|
293
354
|
grantee: { kind: 'entity', entityId: 'did:key:z6MkConformance' },
|
|
294
355
|
};
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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 }]);
|
|
301
365
|
expect(granted.permissions).toEqual([grant]);
|
|
302
|
-
expect(granted.associations).toEqual([
|
|
366
|
+
expect(granted.associations).toEqual([related]);
|
|
303
367
|
expect(granted.version).toBe(record.version);
|
|
304
|
-
const revoked = await adapter.
|
|
305
|
-
{ op: 'remove', association: grant },
|
|
306
|
-
]);
|
|
368
|
+
const revoked = await adapter.editAccess(record.id, [{ op: 'remove', association: grant }]);
|
|
307
369
|
expect(revoked.permissions ?? []).toEqual([]);
|
|
308
|
-
expect(revoked.associations).toEqual([
|
|
370
|
+
expect(revoked.associations).toEqual([related]);
|
|
309
371
|
});
|
|
310
372
|
test('a group grantee keeps its role — member and admin are two entries', async () => {
|
|
311
373
|
const record = makeRecord();
|
|
@@ -318,18 +380,16 @@ export function runRecordAdapterConformance(options) {
|
|
|
318
380
|
});
|
|
319
381
|
const member = forRole('member');
|
|
320
382
|
const admin = forRole('admin');
|
|
321
|
-
await adapter.
|
|
322
|
-
const both = await adapter.
|
|
323
|
-
{ op: 'add', association: admin },
|
|
324
|
-
]);
|
|
383
|
+
await adapter.editAccess(record.id, [{ op: 'add', association: member }]);
|
|
384
|
+
const both = await adapter.editAccess(record.id, [{ op: 'add', association: admin }]);
|
|
325
385
|
expect(both.permissions).toEqual(expect.arrayContaining([member, admin]));
|
|
326
386
|
expect(both.permissions).toHaveLength(2);
|
|
327
387
|
});
|
|
328
388
|
test('a list of adds and removes lands as one write that bumps no version', async () => {
|
|
329
389
|
const record = makeRecord();
|
|
330
390
|
await adapter.createRecord(record, { journal: { ops: ['create'], kind: 'created' } });
|
|
331
|
-
await adapter.
|
|
332
|
-
const amended = await adapter.
|
|
391
|
+
await adapter.editAssociations(record.id, [{ op: 'add', association: tag('old') }]);
|
|
392
|
+
const amended = await adapter.editAssociations(record.id, [
|
|
333
393
|
{ op: 'remove', association: tag('old') },
|
|
334
394
|
{ op: 'add', association: tag('new') },
|
|
335
395
|
{ op: 'add', association: tag('extra') },
|
|
@@ -340,49 +400,134 @@ export function runRecordAdapterConformance(options) {
|
|
|
340
400
|
expect(amended.updatedAt).toEqual(record.updatedAt);
|
|
341
401
|
expect(await adapter.getJournal(record.id)).toHaveLength(2);
|
|
342
402
|
});
|
|
343
|
-
|
|
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 () => {
|
|
344
407
|
const record = makeRecord();
|
|
345
408
|
await adapter.createRecord(record);
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
{ op: '
|
|
356
|
-
|
|
357
|
-
])
|
|
358
|
-
|
|
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([]);
|
|
359
432
|
});
|
|
360
|
-
|
|
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();
|
|
361
438
|
const record = makeRecord();
|
|
439
|
+
await adapter.createRecord(parent);
|
|
362
440
|
await adapter.createRecord(record);
|
|
363
|
-
|
|
364
|
-
await
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
]
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
|
|
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);
|
|
371
513
|
});
|
|
372
|
-
test('each
|
|
514
|
+
test('each set verb replaces only its own aspect', async () => {
|
|
373
515
|
const record = makeRecord();
|
|
374
516
|
await adapter.createRecord(record);
|
|
375
517
|
const anyone = { kind: 'anyone', label: 'read' };
|
|
376
|
-
await adapter.
|
|
518
|
+
await adapter.editAssociations(record.id, [
|
|
377
519
|
{ op: 'add', association: { kind: 'tag', label: 'draft' } },
|
|
378
520
|
]);
|
|
379
|
-
await adapter.
|
|
380
|
-
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
|
+
]);
|
|
381
525
|
expect(retagged.associations).toEqual([{ kind: 'tag', label: 'reviewed' }]);
|
|
382
526
|
expect(retagged.permissions).toEqual([anyone]);
|
|
383
|
-
const cleared = await adapter.
|
|
527
|
+
const cleared = await adapter.setAccess(record.id, []);
|
|
384
528
|
expect(cleared.associations).toEqual([{ kind: 'tag', label: 'reviewed' }]);
|
|
385
529
|
expect(cleared.permissions ?? []).toEqual([]);
|
|
530
|
+
expect(cleared.version).toBe(record.version);
|
|
386
531
|
});
|
|
387
532
|
});
|
|
388
533
|
// -----------------------------------------------------------------
|
|
@@ -412,9 +557,9 @@ export function runRecordAdapterConformance(options) {
|
|
|
412
557
|
content: record.content,
|
|
413
558
|
updatedAt: record.updatedAt,
|
|
414
559
|
});
|
|
415
|
-
await adapter.
|
|
416
|
-
await adapter.
|
|
417
|
-
await adapter.
|
|
560
|
+
await adapter.patchContent(record.id, { title: 'v2' });
|
|
561
|
+
await adapter.setParent(record.id, parent.id);
|
|
562
|
+
await adapter.editAssociations(record.id, [
|
|
418
563
|
{ op: 'add', association: { kind: 'tag', label: 'keep-me' } },
|
|
419
564
|
]);
|
|
420
565
|
const restored = await adapter.restoreVersion(record.id, record.version);
|
|
@@ -452,7 +597,7 @@ export function runRecordAdapterConformance(options) {
|
|
|
452
597
|
await adapter.createRecord(record, {
|
|
453
598
|
journal: { ops: ['create'], kind: 'created' },
|
|
454
599
|
});
|
|
455
|
-
await adapter.
|
|
600
|
+
await adapter.patchContent(record.id, { title: 'v2' }, { journal: { ops: ['patch'], kind: 'changed' } });
|
|
456
601
|
const log = await adapter.getJournal(record.id);
|
|
457
602
|
expect(log).toHaveLength(2);
|
|
458
603
|
// version, typeId and parentId are the adapter's to stamp: Stack
|
|
@@ -467,8 +612,8 @@ export function runRecordAdapterConformance(options) {
|
|
|
467
612
|
const second = makeRecord();
|
|
468
613
|
await adapter.createRecord(first, { journal: { ops: ['create'], kind: 'created' } });
|
|
469
614
|
await adapter.createRecord(second, { journal: { ops: ['create'], kind: 'created' } });
|
|
470
|
-
await adapter.
|
|
471
|
-
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' } });
|
|
472
617
|
expect((await adapter.getJournal(first.id)).map((e) => e.seq)).toEqual([1, 2, 3]);
|
|
473
618
|
// Per record, so a busy neighbour never advances this one's log.
|
|
474
619
|
expect((await adapter.getJournal(second.id)).map((e) => e.seq)).toEqual([1]);
|
|
@@ -476,14 +621,14 @@ export function runRecordAdapterConformance(options) {
|
|
|
476
621
|
test('an association change journals a version that stands still', async () => {
|
|
477
622
|
const record = makeRecord();
|
|
478
623
|
await adapter.createRecord(record);
|
|
479
|
-
await adapter.
|
|
624
|
+
await adapter.editAssociations(record.id, [{ op: 'add', association: { kind: 'tag', label: 'starred' } }], {
|
|
480
625
|
journal: {
|
|
481
626
|
ops: ['associate'],
|
|
482
627
|
kind: 'changed',
|
|
483
628
|
associations: [{ op: 'add', association: { kind: 'tag', label: 'starred' } }],
|
|
484
629
|
},
|
|
485
630
|
});
|
|
486
|
-
await adapter.
|
|
631
|
+
await adapter.editAssociations(record.id, [{ op: 'remove', association: { kind: 'tag', label: 'starred' } }], {
|
|
487
632
|
journal: {
|
|
488
633
|
ops: ['dissociate'],
|
|
489
634
|
kind: 'changed',
|
|
@@ -517,7 +662,7 @@ export function runRecordAdapterConformance(options) {
|
|
|
517
662
|
fileId: 'a'.repeat(64),
|
|
518
663
|
attachmentRecordId: uniqueId('new'),
|
|
519
664
|
};
|
|
520
|
-
await adapter.
|
|
665
|
+
await adapter.editAssociations(record.id, [{ op: 'add', association: association }], {
|
|
521
666
|
journal: {
|
|
522
667
|
ops: ['associate'],
|
|
523
668
|
kind: 'changed',
|
|
@@ -539,8 +684,8 @@ export function runRecordAdapterConformance(options) {
|
|
|
539
684
|
fileId: 'b'.repeat(64),
|
|
540
685
|
attachmentRecordId: uniqueId('upload'),
|
|
541
686
|
};
|
|
542
|
-
await adapter.
|
|
543
|
-
await adapter.
|
|
687
|
+
await adapter.editAssociations(record.id, [{ op: 'add', association: previous }]);
|
|
688
|
+
await adapter.editAssociations(record.id, [
|
|
544
689
|
{
|
|
545
690
|
op: 'remove',
|
|
546
691
|
association: { kind: 'attachment', label: 'cover', fileId: 'b'.repeat(64) },
|
|
@@ -555,25 +700,6 @@ export function runRecordAdapterConformance(options) {
|
|
|
555
700
|
const [entry] = await adapter.getJournal(record.id);
|
|
556
701
|
expect(entry.associations).toEqual([{ op: 'remove', association: previous }]);
|
|
557
702
|
});
|
|
558
|
-
test('an association list is keyed by identity, last wins', async () => {
|
|
559
|
-
// Association identity is the association table's primary key, so a
|
|
560
|
-
// list naming one identity twice is a state no store can hold.
|
|
561
|
-
// An adapter that kept both would let a record reach one a SQL
|
|
562
|
-
// adapter cannot represent.
|
|
563
|
-
const record = makeRecord();
|
|
564
|
-
await adapter.createRecord(record);
|
|
565
|
-
const fileId = 'c'.repeat(64);
|
|
566
|
-
const winner = uniqueId('second');
|
|
567
|
-
const updated = await adapter.mutateRecord(record.id, {
|
|
568
|
-
associations: [
|
|
569
|
-
{ kind: 'attachment', label: 'cover', fileId, attachmentRecordId: uniqueId('first') },
|
|
570
|
-
{ kind: 'attachment', label: 'cover', fileId, attachmentRecordId: winner },
|
|
571
|
-
],
|
|
572
|
-
}, { bumpsVersion: false });
|
|
573
|
-
expect(updated.associations).toEqual([
|
|
574
|
-
{ kind: 'attachment', label: 'cover', fileId, attachmentRecordId: winner },
|
|
575
|
-
]);
|
|
576
|
-
});
|
|
577
703
|
test('previousParentId tells "did not move" apart from "moved off the root"', async () => {
|
|
578
704
|
// The one field on any entry where null is a value rather than an
|
|
579
705
|
// input spelling. Collapsing both to absent loses which happened —
|
|
@@ -583,8 +709,7 @@ export function runRecordAdapterConformance(options) {
|
|
|
583
709
|
await adapter.createRecord(parent);
|
|
584
710
|
const record = makeRecord();
|
|
585
711
|
await adapter.createRecord(record, { journal: { ops: ['create'], kind: 'created' } });
|
|
586
|
-
await adapter.
|
|
587
|
-
bumpsVersion: false,
|
|
712
|
+
await adapter.setParent(record.id, parent.id, {
|
|
588
713
|
journal: { ops: ['reparent'], kind: 'changed', previousParentId: null },
|
|
589
714
|
});
|
|
590
715
|
const log = await adapter.getJournal(record.id);
|
|
@@ -596,7 +721,7 @@ export function runRecordAdapterConformance(options) {
|
|
|
596
721
|
const record = makeRecord();
|
|
597
722
|
await adapter.createRecord(record, { journal: { ops: ['create'], kind: 'created' } });
|
|
598
723
|
for (const label of ['a', 'b', 'c']) {
|
|
599
|
-
await adapter.
|
|
724
|
+
await adapter.editAssociations(record.id, [{ op: 'add', association: { kind: 'tag', label } }], { journal: { ops: ['associate'], kind: 'changed' } });
|
|
600
725
|
}
|
|
601
726
|
expect(await adapter.getJournal(record.id)).toHaveLength(4);
|
|
602
727
|
expect((await adapter.getJournal(record.id, { afterSeq: 2 })).map((e) => e.seq)).toEqual([
|
|
@@ -610,7 +735,7 @@ export function runRecordAdapterConformance(options) {
|
|
|
610
735
|
});
|
|
611
736
|
test('a failed mutation leaves no entry behind', async () => {
|
|
612
737
|
const missing = uniqueId('missing');
|
|
613
|
-
await expectStackErrorCode(adapter.
|
|
738
|
+
await expectStackErrorCode(adapter.patchContent(missing, { title: 'x' }, { journal: { ops: ['patch'], kind: 'changed' } }), 'not_found');
|
|
614
739
|
// The record was never created, so its log is refused rather than
|
|
615
740
|
// read — what matters is that the entry did not land somewhere.
|
|
616
741
|
await expectStackErrorCode(adapter.getJournal(missing), 'not_found');
|
|
@@ -663,7 +788,7 @@ export function runRecordAdapterConformance(options) {
|
|
|
663
788
|
const seen = new Set();
|
|
664
789
|
let cursor;
|
|
665
790
|
do {
|
|
666
|
-
const page = await adapter.queryRecords({ limit: 2, cursor: cursor ?? undefined });
|
|
791
|
+
const page = await adapter.queryRecords(resolveQuery({ limit: 2, cursor: cursor ?? undefined }));
|
|
667
792
|
expect(page.records.length).toBeLessThanOrEqual(2);
|
|
668
793
|
for (const r of page.records)
|
|
669
794
|
seen.add(r.id);
|
|
@@ -672,22 +797,275 @@ export function runRecordAdapterConformance(options) {
|
|
|
672
797
|
for (const id of ids)
|
|
673
798
|
expect(seen.has(id)).toBe(true);
|
|
674
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
|
+
});
|
|
675
814
|
test('a malformed cursor is refused rather than misread', async () => {
|
|
676
|
-
await expectStackErrorCode(adapter.queryRecords({ cursor: 'not-a-real-cursor' }), 'bad_request');
|
|
815
|
+
await expectStackErrorCode(adapter.queryRecords(resolveQuery({ cursor: 'not-a-real-cursor' })), 'bad_request');
|
|
677
816
|
});
|
|
678
817
|
test('a cursor minted under one sort field is refused when replayed under a different one', async () => {
|
|
679
818
|
await adapter.createRecord(makeRecord());
|
|
680
819
|
await adapter.createRecord(makeRecord());
|
|
681
|
-
const minted = await adapter.queryRecords({
|
|
820
|
+
const minted = await adapter.queryRecords(resolveQuery({
|
|
682
821
|
limit: 1,
|
|
683
822
|
sort: { field: 'createdAt', direction: 'asc' },
|
|
684
|
-
});
|
|
823
|
+
}));
|
|
685
824
|
expect(minted.cursor).not.toBeNull();
|
|
686
|
-
await expectStackErrorCode(adapter.queryRecords({
|
|
825
|
+
await expectStackErrorCode(adapter.queryRecords(resolveQuery({
|
|
687
826
|
limit: 1,
|
|
688
827
|
cursor: minted.cursor,
|
|
689
828
|
sort: { field: 'updatedAt', direction: 'asc' },
|
|
690
|
-
}), '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([]);
|
|
691
1069
|
});
|
|
692
1070
|
});
|
|
693
1071
|
// -----------------------------------------------------------------
|
|
@@ -700,7 +1078,7 @@ export function runRecordAdapterConformance(options) {
|
|
|
700
1078
|
const decoy = makeRecord({ content: { title: 'b', priority: 2 } });
|
|
701
1079
|
await adapter.createRecord(match);
|
|
702
1080
|
await adapter.createRecord(decoy);
|
|
703
|
-
const result = await adapter.queryRecords({ filter: { content: { priority: 1 } } });
|
|
1081
|
+
const result = await adapter.queryRecords(resolveQuery({ filter: { content: { priority: 1 } } }));
|
|
704
1082
|
// The positive assertion first: `not.toContain` and `every` are
|
|
705
1083
|
// both vacuously true on an empty result, so without this an
|
|
706
1084
|
// adapter that declares filter.content and returns nothing at
|
|
@@ -716,7 +1094,7 @@ export function runRecordAdapterConformance(options) {
|
|
|
716
1094
|
await adapter.createRecord(absent);
|
|
717
1095
|
await adapter.createRecord(explicitNull);
|
|
718
1096
|
await adapter.createRecord(present);
|
|
719
|
-
const result = await adapter.queryRecords({ filter: { content: { priority: null } } });
|
|
1097
|
+
const result = await adapter.queryRecords(resolveQuery({ filter: { content: { priority: null } } }));
|
|
720
1098
|
const ids = result.records.map((r) => r.id).sort();
|
|
721
1099
|
expect(ids).toEqual([absent.id, explicitNull.id].sort());
|
|
722
1100
|
expect(ids).not.toContain(present.id);
|
|
@@ -726,7 +1104,7 @@ export function runRecordAdapterConformance(options) {
|
|
|
726
1104
|
const nested = makeRecord({ content: { title: 'y', priority: { high: true } } });
|
|
727
1105
|
await adapter.createRecord(scalarMatch);
|
|
728
1106
|
await adapter.createRecord(nested);
|
|
729
|
-
const result = await adapter.queryRecords({ filter: { content: { priority: 1 } } });
|
|
1107
|
+
const result = await adapter.queryRecords(resolveQuery({ filter: { content: { priority: 1 } } }));
|
|
730
1108
|
expect(result.records.map((r) => r.id)).toContain(scalarMatch.id);
|
|
731
1109
|
expect(result.records.map((r) => r.id)).not.toContain(nested.id);
|
|
732
1110
|
});
|
|
@@ -738,10 +1116,43 @@ export function runRecordAdapterConformance(options) {
|
|
|
738
1116
|
await adapter.createRecord(present);
|
|
739
1117
|
await adapter.createRecord(absent);
|
|
740
1118
|
await adapter.createRecord(nullValue);
|
|
741
|
-
const result = await adapter.queryRecords({ filter: { contentPresent: ['priority'] } });
|
|
1119
|
+
const result = await adapter.queryRecords(resolveQuery({ filter: { contentPresent: ['priority'] } }));
|
|
742
1120
|
expect(result.records.map((r) => r.id)).toEqual([present.id]);
|
|
743
1121
|
});
|
|
744
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
|
+
}
|
|
745
1156
|
if (capabilities.filter.content === 'path') {
|
|
746
1157
|
test('an array is spread element-wise when filtering a nested path', async () => {
|
|
747
1158
|
const match = makeRecord({
|
|
@@ -752,9 +1163,9 @@ export function runRecordAdapterConformance(options) {
|
|
|
752
1163
|
});
|
|
753
1164
|
await adapter.createRecord(match);
|
|
754
1165
|
await adapter.createRecord(noMatch);
|
|
755
|
-
const result = await adapter.queryRecords({
|
|
1166
|
+
const result = await adapter.queryRecords(resolveQuery({
|
|
756
1167
|
filter: { content: { 'emails.value': 'a@b.c' } },
|
|
757
|
-
});
|
|
1168
|
+
}));
|
|
758
1169
|
expect(result.records.map((r) => r.id)).toEqual([match.id]);
|
|
759
1170
|
});
|
|
760
1171
|
test('a nested path filter reaches a property inside a plain (non-array) object', async () => {
|
|
@@ -762,22 +1173,172 @@ export function runRecordAdapterConformance(options) {
|
|
|
762
1173
|
const noMatch = makeRecord({ content: { title: 'y', address: { city: 'Lyon' } } });
|
|
763
1174
|
await adapter.createRecord(match);
|
|
764
1175
|
await adapter.createRecord(noMatch);
|
|
765
|
-
const result = await adapter.queryRecords({
|
|
1176
|
+
const result = await adapter.queryRecords(resolveQuery({
|
|
766
1177
|
filter: { content: { 'address.city': 'Berlin' } },
|
|
767
|
-
});
|
|
1178
|
+
}));
|
|
768
1179
|
expect(result.records.map((r) => r.id)).toEqual([match.id]);
|
|
769
1180
|
});
|
|
770
1181
|
test('a filter path descending through a scalar matches nothing, rather than throwing', async () => {
|
|
771
1182
|
await adapter.createRecord(makeRecord({ content: { title: 'x', priority: 1 } }));
|
|
772
|
-
const result = await adapter.queryRecords({
|
|
1183
|
+
const result = await adapter.queryRecords(resolveQuery({
|
|
773
1184
|
filter: { content: { 'priority.nested': 'anything' } },
|
|
774
|
-
});
|
|
1185
|
+
}));
|
|
775
1186
|
expect(result.records).toEqual([]);
|
|
776
1187
|
});
|
|
777
1188
|
}
|
|
778
1189
|
});
|
|
779
1190
|
}
|
|
780
1191
|
// -----------------------------------------------------------------
|
|
1192
|
+
// Content by type — capability-gated on filter.contentByType
|
|
1193
|
+
// -----------------------------------------------------------------
|
|
1194
|
+
if (capabilities.filter.contentByType) {
|
|
1195
|
+
describe('content by type', () => {
|
|
1196
|
+
const OTHER = 'org.haverstack.conformance/other@1';
|
|
1197
|
+
const THIRD = 'org.haverstack.conformance/third@1';
|
|
1198
|
+
test('matches each record against the arm for its own type only', async () => {
|
|
1199
|
+
const note = makeRecord({ content: { title: 'a' } });
|
|
1200
|
+
const other = makeRecord({ typeId: OTHER, content: { heading: 'a' } });
|
|
1201
|
+
const decoys = [
|
|
1202
|
+
makeRecord({ content: { title: 'b', heading: 'a' } }),
|
|
1203
|
+
makeRecord({ typeId: OTHER, content: { title: 'a', heading: 'b' } }),
|
|
1204
|
+
makeRecord({ typeId: THIRD, content: { title: 'a', heading: 'a' } }),
|
|
1205
|
+
];
|
|
1206
|
+
for (const record of [note, other, ...decoys])
|
|
1207
|
+
await adapter.createRecord(record);
|
|
1208
|
+
const result = await adapter.queryRecords(resolveQuery({
|
|
1209
|
+
filter: {
|
|
1210
|
+
contentByType: {
|
|
1211
|
+
[CONFORMANCE_TYPE_ID]: { content: { title: 'a' } },
|
|
1212
|
+
[OTHER]: { content: { heading: 'a' } },
|
|
1213
|
+
},
|
|
1214
|
+
},
|
|
1215
|
+
}));
|
|
1216
|
+
expect(result.records.map((r) => r.id).sort()).toEqual([note.id, other.id].sort());
|
|
1217
|
+
});
|
|
1218
|
+
test('an arm naming nothing matches every record of its type', async () => {
|
|
1219
|
+
const notes = [makeRecord(), makeRecord({ content: { title: 'x' } })];
|
|
1220
|
+
const decoy = makeRecord({ typeId: THIRD });
|
|
1221
|
+
for (const record of [...notes, decoy])
|
|
1222
|
+
await adapter.createRecord(record);
|
|
1223
|
+
const result = await adapter.queryRecords(resolveQuery({
|
|
1224
|
+
filter: {
|
|
1225
|
+
contentByType: { [CONFORMANCE_TYPE_ID]: {}, [OTHER]: { content: { a: 1 } } },
|
|
1226
|
+
},
|
|
1227
|
+
}));
|
|
1228
|
+
expect(result.records.map((r) => r.id).sort()).toEqual(notes.map((r) => r.id).sort());
|
|
1229
|
+
});
|
|
1230
|
+
test('intersects with every other key of the filter', async () => {
|
|
1231
|
+
const kept = makeRecord({ content: { title: 'a' }, parentId: undefined });
|
|
1232
|
+
const parent = await adapter.createRecord(makeRecord());
|
|
1233
|
+
const child = makeRecord({ content: { title: 'a' }, parentId: parent.id });
|
|
1234
|
+
await adapter.createRecord(kept);
|
|
1235
|
+
await adapter.createRecord(child);
|
|
1236
|
+
const result = await adapter.queryRecords(resolveQuery({
|
|
1237
|
+
filter: {
|
|
1238
|
+
parentId: parent.id,
|
|
1239
|
+
contentByType: { [CONFORMANCE_TYPE_ID]: { content: { title: 'a' } }, [OTHER]: {} },
|
|
1240
|
+
},
|
|
1241
|
+
}));
|
|
1242
|
+
expect(result.records.map((r) => r.id)).toEqual([child.id]);
|
|
1243
|
+
});
|
|
1244
|
+
if (capabilities.filter.contentPresent) {
|
|
1245
|
+
test("an arm's contentPresent is applied to its own type", async () => {
|
|
1246
|
+
const present = makeRecord({ content: { title: 'x', priority: 1 } });
|
|
1247
|
+
const absent = makeRecord({ content: { title: 'y' } });
|
|
1248
|
+
const other = makeRecord({ typeId: OTHER, content: { priority: 1 } });
|
|
1249
|
+
for (const record of [present, absent, other])
|
|
1250
|
+
await adapter.createRecord(record);
|
|
1251
|
+
const result = await adapter.queryRecords(resolveQuery({
|
|
1252
|
+
filter: {
|
|
1253
|
+
contentByType: {
|
|
1254
|
+
[CONFORMANCE_TYPE_ID]: { contentPresent: ['priority'] },
|
|
1255
|
+
[OTHER]: { contentPresent: ['missing'] },
|
|
1256
|
+
},
|
|
1257
|
+
},
|
|
1258
|
+
}));
|
|
1259
|
+
expect(result.records.map((r) => r.id)).toEqual([present.id]);
|
|
1260
|
+
});
|
|
1261
|
+
}
|
|
1262
|
+
});
|
|
1263
|
+
}
|
|
1264
|
+
// -----------------------------------------------------------------
|
|
1265
|
+
// Sort by content field per type — gated on sort.contentFieldByType
|
|
1266
|
+
// -----------------------------------------------------------------
|
|
1267
|
+
if (capabilities.sort.contentFieldByType) {
|
|
1268
|
+
describe('sort by content field per type', () => {
|
|
1269
|
+
const OTHER = 'org.haverstack.conformance/other@1';
|
|
1270
|
+
const THIRD = 'org.haverstack.conformance/third@1';
|
|
1271
|
+
const UNNAMED = 'org.haverstack.conformance/unnamed@1';
|
|
1272
|
+
const BY_TYPE = {
|
|
1273
|
+
[CONFORMANCE_TYPE_ID]: { field: 'priority' },
|
|
1274
|
+
[OTHER]: { field: 'rank' },
|
|
1275
|
+
[THIRD]: { value: 2.5 },
|
|
1276
|
+
};
|
|
1277
|
+
/** Each title names the position the record sorts to, ascending. */
|
|
1278
|
+
const seed = async () => {
|
|
1279
|
+
await adapter.saveType(conformanceType());
|
|
1280
|
+
await adapter.saveType(conformanceType({
|
|
1281
|
+
id: OTHER,
|
|
1282
|
+
baseId: 'org.haverstack.conformance/other',
|
|
1283
|
+
schema: { title: { kind: 'string' }, rank: { kind: 'number' } },
|
|
1284
|
+
}));
|
|
1285
|
+
for (const record of [
|
|
1286
|
+
makeRecord({ content: { title: 'd', priority: 3 } }),
|
|
1287
|
+
makeRecord({ typeId: OTHER, content: { title: 'a', rank: 1, priority: 9 } }),
|
|
1288
|
+
makeRecord({ typeId: THIRD, content: { title: 'c', priority: 0 } }),
|
|
1289
|
+
makeRecord({ content: { title: 'b', priority: 2 } }),
|
|
1290
|
+
makeRecord({ typeId: OTHER, content: { title: 'e', rank: 4 } }),
|
|
1291
|
+
makeRecord({ content: { title: 'z' } }),
|
|
1292
|
+
makeRecord({ typeId: UNNAMED, content: { title: 'z', priority: 1 } }),
|
|
1293
|
+
]) {
|
|
1294
|
+
await adapter.createRecord(record);
|
|
1295
|
+
}
|
|
1296
|
+
};
|
|
1297
|
+
const titles = (records) => records.map((r) => r.content.title);
|
|
1298
|
+
test.each(['asc', 'desc'])('orders each type by its own field or constant, %s, holding no value last', async (direction) => {
|
|
1299
|
+
await seed();
|
|
1300
|
+
const result = await adapter.queryRecords(resolveQuery({
|
|
1301
|
+
sort: { contentFieldByType: BY_TYPE, direction },
|
|
1302
|
+
}));
|
|
1303
|
+
const ordered = ['a', 'b', 'c', 'd', 'e'];
|
|
1304
|
+
expect(titles(result.records)).toEqual([
|
|
1305
|
+
...(direction === 'asc' ? ordered : ordered.reverse()),
|
|
1306
|
+
'z',
|
|
1307
|
+
'z',
|
|
1308
|
+
]);
|
|
1309
|
+
});
|
|
1310
|
+
test('pages through the ordering one record at a time', async () => {
|
|
1311
|
+
await seed();
|
|
1312
|
+
const seen = [];
|
|
1313
|
+
let cursor;
|
|
1314
|
+
do {
|
|
1315
|
+
const page = await adapter.queryRecords(resolveQuery({
|
|
1316
|
+
limit: 1,
|
|
1317
|
+
cursor: cursor ?? undefined,
|
|
1318
|
+
sort: { contentFieldByType: BY_TYPE, direction: 'asc' },
|
|
1319
|
+
}));
|
|
1320
|
+
seen.push(...titles(page.records));
|
|
1321
|
+
cursor = page.cursor;
|
|
1322
|
+
} while (cursor);
|
|
1323
|
+
expect(seen).toEqual(['a', 'b', 'c', 'd', 'e', 'z', 'z']);
|
|
1324
|
+
});
|
|
1325
|
+
if (capabilities.sort.contentField) {
|
|
1326
|
+
test('a cursor minted under one field is refused under a map', async () => {
|
|
1327
|
+
await seed();
|
|
1328
|
+
const minted = await adapter.queryRecords(resolveQuery({
|
|
1329
|
+
limit: 1,
|
|
1330
|
+
sort: { contentField: 'priority', direction: 'asc' },
|
|
1331
|
+
}));
|
|
1332
|
+
await expectStackErrorCode(adapter.queryRecords(resolveQuery({
|
|
1333
|
+
limit: 1,
|
|
1334
|
+
cursor: minted.cursor,
|
|
1335
|
+
sort: { contentFieldByType: BY_TYPE, direction: 'asc' },
|
|
1336
|
+
})), 'bad_request');
|
|
1337
|
+
});
|
|
1338
|
+
}
|
|
1339
|
+
});
|
|
1340
|
+
}
|
|
1341
|
+
// -----------------------------------------------------------------
|
|
781
1342
|
// Sort by content field — capability-gated on sort.contentField
|
|
782
1343
|
// -----------------------------------------------------------------
|
|
783
1344
|
describe('sort direction', () => {
|
|
@@ -785,7 +1346,7 @@ export function runRecordAdapterConformance(options) {
|
|
|
785
1346
|
const a = await adapter.createRecord(makeRecord());
|
|
786
1347
|
await new Promise((r) => setTimeout(r, 5));
|
|
787
1348
|
const b = await adapter.createRecord(makeRecord());
|
|
788
|
-
const result = await adapter.queryRecords({ sort: { field: 'createdAt', direction } });
|
|
1349
|
+
const result = await adapter.queryRecords(resolveQuery({ sort: { field: 'createdAt', direction } }));
|
|
789
1350
|
const ids = result.records.map((r) => r.id);
|
|
790
1351
|
expect(ids).toEqual(direction === 'asc' ? [a.id, b.id] : [b.id, a.id]);
|
|
791
1352
|
});
|
|
@@ -796,9 +1357,9 @@ export function runRecordAdapterConformance(options) {
|
|
|
796
1357
|
await adapter.saveType(conformanceType());
|
|
797
1358
|
await adapter.createRecord(makeRecord({ content: { title: 'x', priority: 1 } }));
|
|
798
1359
|
await adapter.createRecord(makeRecord({ content: { title: 'y', priority: 2 } }));
|
|
799
|
-
const result = await adapter.queryRecords({
|
|
1360
|
+
const result = await adapter.queryRecords(resolveQuery({
|
|
800
1361
|
sort: { contentField: 'priority', direction },
|
|
801
|
-
});
|
|
1362
|
+
}));
|
|
802
1363
|
const priorities = result.records.map((r) => r.content.priority);
|
|
803
1364
|
expect(priorities).toEqual(direction === 'asc' ? [1, 2] : [2, 1]);
|
|
804
1365
|
});
|
|
@@ -810,13 +1371,32 @@ export function runRecordAdapterConformance(options) {
|
|
|
810
1371
|
await adapter.createRecord(makeRecord({ content: { title: 'x', priority: 3 } }));
|
|
811
1372
|
await adapter.createRecord(makeRecord({ content: { title: 'y', priority: 1 } }));
|
|
812
1373
|
await adapter.createRecord(makeRecord({ content: { title: 'z', priority: 2 } }));
|
|
813
|
-
const result = await adapter.queryRecords({
|
|
1374
|
+
const result = await adapter.queryRecords(resolveQuery({
|
|
814
1375
|
sort: { contentField: 'priority', direction: 'asc' },
|
|
815
|
-
});
|
|
1376
|
+
}));
|
|
816
1377
|
expect(result.records.map((r) => r.content.priority)).toEqual([
|
|
817
1378
|
1, 2, 3,
|
|
818
1379
|
]);
|
|
819
1380
|
});
|
|
1381
|
+
test('orders an enum field by value, not by its position in values', async () => {
|
|
1382
|
+
await adapter.saveType(conformanceType({
|
|
1383
|
+
schema: {
|
|
1384
|
+
title: { kind: 'string' },
|
|
1385
|
+
status: { kind: 'enum', values: ['want', 'reading', 'finished'] },
|
|
1386
|
+
},
|
|
1387
|
+
}));
|
|
1388
|
+
for (const status of ['want', 'finished', 'reading']) {
|
|
1389
|
+
await adapter.createRecord(makeRecord({ content: { title: status, status } }));
|
|
1390
|
+
}
|
|
1391
|
+
const result = await adapter.queryRecords(resolveQuery({
|
|
1392
|
+
sort: { contentField: 'status', direction: 'asc' },
|
|
1393
|
+
}));
|
|
1394
|
+
expect(result.records.map((r) => r.content.status)).toEqual([
|
|
1395
|
+
'finished',
|
|
1396
|
+
'reading',
|
|
1397
|
+
'want',
|
|
1398
|
+
]);
|
|
1399
|
+
});
|
|
820
1400
|
});
|
|
821
1401
|
}
|
|
822
1402
|
// -----------------------------------------------------------------
|
|
@@ -829,7 +1409,7 @@ export function runRecordAdapterConformance(options) {
|
|
|
829
1409
|
const noMatch = makeRecord({ content: { title: 'something unrelated' } });
|
|
830
1410
|
await adapter.createRecord(match);
|
|
831
1411
|
await adapter.createRecord(noMatch);
|
|
832
|
-
const result = await adapter.queryRecords({ filter: { search: 'brown' } });
|
|
1412
|
+
const result = await adapter.queryRecords(resolveQuery({ filter: { search: 'brown' } }));
|
|
833
1413
|
expect(result.records.map((r) => r.id)).toContain(match.id);
|
|
834
1414
|
expect(result.records.map((r) => r.id)).not.toContain(noMatch.id);
|
|
835
1415
|
});
|
|
@@ -840,17 +1420,17 @@ export function runRecordAdapterConformance(options) {
|
|
|
840
1420
|
test('a content patch is reflected in search — the old content is no longer searchable', async () => {
|
|
841
1421
|
const record = makeRecord({ content: { title: 'original wording' } });
|
|
842
1422
|
await adapter.createRecord(record);
|
|
843
|
-
await adapter.
|
|
844
|
-
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' } }));
|
|
845
1425
|
expect(stale.records.map((r) => r.id)).not.toContain(record.id);
|
|
846
|
-
const fresh = await adapter.queryRecords({ filter: { search: 'updated' } });
|
|
1426
|
+
const fresh = await adapter.queryRecords(resolveQuery({ filter: { search: 'updated' } }));
|
|
847
1427
|
expect(fresh.records.map((r) => r.id)).toContain(record.id);
|
|
848
1428
|
});
|
|
849
1429
|
test('a purged record is no longer found by search', async () => {
|
|
850
1430
|
const record = makeRecord({ content: { title: 'ephemeral content' } });
|
|
851
1431
|
await adapter.createRecord(record);
|
|
852
1432
|
await adapter.deleteRecord(record.id, { purge: true });
|
|
853
|
-
const result = await adapter.queryRecords({ filter: { search: 'ephemeral' } });
|
|
1433
|
+
const result = await adapter.queryRecords(resolveQuery({ filter: { search: 'ephemeral' } }));
|
|
854
1434
|
expect(result.records.map((r) => r.id)).not.toContain(record.id);
|
|
855
1435
|
});
|
|
856
1436
|
});
|