@haverstack/adapter-conformance 0.11.0 → 0.13.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.js CHANGED
@@ -21,8 +21,9 @@
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
- import { CONFORMANCE_TYPE_ID, conformanceType, makeRecord, uniqueId } from './helpers.js';
26
+ import { CONFORMANCE_TYPE_ID, conformanceType, makeRecord, snapshotOf, uniqueId, } from './helpers.js';
26
27
  export function runRecordAdapterConformance(options) {
27
28
  const { name, open, close, capabilities } = options;
28
29
  describe(`record adapter conformance: ${name}`, () => {
@@ -42,37 +43,48 @@ export function runRecordAdapterConformance(options) {
42
43
  opened = false;
43
44
  await close?.(adapter);
44
45
  });
46
+ /**
47
+ * Patch `record` `count` times, each patch snapshotting the version it
48
+ * replaces, as Stack does: versions 1 through `count` end up stored.
49
+ */
50
+ const patchWithSnapshots = async (record, count) => {
51
+ let current = record;
52
+ for (let i = 0; i < count; i++) {
53
+ current = await adapter.patchContent(current.id, { title: `v${current.version + 1}` }, { snapshot: snapshotOf(current) });
54
+ }
55
+ return current;
56
+ };
45
57
  // -----------------------------------------------------------------
46
58
  // Capabilities
47
59
  // -----------------------------------------------------------------
48
60
  describe('capabilities', () => {
49
61
  test('declares a well-formed StackCapabilities object', () => {
50
62
  expect(['none', 'field', 'path']).toContain(capabilities.filter.content);
51
- expect(typeof capabilities.filter.contentPresent).toBe('boolean');
52
- expect(typeof capabilities.filter.search).toBe('boolean');
53
- expect(Array.isArray(capabilities.sort.fields)).toBe(true);
54
- expect(typeof capabilities.sort.contentField).toBe('boolean');
55
- for (const cap of [
63
+ for (const flag of [
64
+ capabilities.filter.contentPresent,
56
65
  capabilities.filter.contentByType,
66
+ capabilities.filter.search,
67
+ capabilities.sort.contentField,
57
68
  capabilities.sort.contentFieldByType,
58
69
  ]) {
59
- expect(Number.isInteger(cap) && cap >= 0).toBe(true);
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);
70
+ expect(typeof flag).toBe('boolean');
67
71
  }
72
+ expect(Array.isArray(capabilities.sort.fields)).toBe(true);
68
73
  });
69
- test('filter.contentPresent is false whenever filter.content is "none"', () => {
70
- // contentPresent asks whether a value is there at all — meaningless
71
- // to promise from an adapter that reaches no content to look at.
74
+ // Each travels only in the POST /records/query body, which a reach of
75
+ // "none" does not serve.
76
+ test('what needs the query body is false whenever filter.content is "none"', () => {
72
77
  if (capabilities.filter.content === 'none') {
73
78
  expect(capabilities.filter.contentPresent).toBe(false);
79
+ expect(capabilities.filter.contentByType).toBe(false);
80
+ expect(capabilities.sort.contentFieldByType).toBe(false);
74
81
  }
75
82
  });
83
+ // A query naming no sort reads `createdAt`, newest first, so every
84
+ // adapter already orders by it.
85
+ test('sort.fields includes createdAt', () => {
86
+ expect(capabilities.sort.fields).toContain('createdAt');
87
+ });
76
88
  });
77
89
  // -----------------------------------------------------------------
78
90
  // Types
@@ -122,44 +134,79 @@ export function runRecordAdapterConformance(options) {
122
134
  await expectStackErrorCode(adapter.createRecord(makeRecord({ id: record.id })), 'conflict');
123
135
  expect((await adapter.getRecord(record.id))?.content).toEqual(record.content);
124
136
  });
125
- test('mutateRecord merges the content patch at the top level and bumps version', async () => {
137
+ test('patchContent merges the patch at the top level and bumps version', async () => {
126
138
  const record = makeRecord({ content: { title: 'Before', priority: 1 } });
127
139
  await adapter.createRecord(record);
128
- const updated = await adapter.mutateRecord(record.id, {
129
- contentPatch: { title: 'After' },
130
- });
140
+ const updated = await adapter.patchContent(record.id, { title: 'After' });
131
141
  expect(updated.content).toEqual({ title: 'After', priority: 1 });
132
142
  expect(updated.version).toBe(record.version + 1);
133
143
  });
134
- // A change set naming only aspects the journal keeps in full arrives
135
- // with `bumpsVersion: false`. The columns still land; `version`,
136
- // `updatedAt` and the actor stamps do not move.
144
+ // The aspects the journal keeps in full never bump: the column lands,
145
+ // and `version`, `updatedAt` and the actor stamps do not move.
137
146
  // See docs/spec/versioning.md § Version history.
138
- test('a no-bump mutateRecord writes parentId and unlisted without moving version', async () => {
147
+ test('setParent and setUnlisted write their column without moving version', async () => {
139
148
  const parent = makeRecord();
140
149
  await adapter.createRecord(parent);
141
150
  const record = makeRecord({ content: { title: 'Hello' } });
142
151
  await adapter.createRecord(record);
143
- const moved = await adapter.mutateRecord(record.id, { parentId: parent.id }, { bumpsVersion: false });
152
+ const moved = await adapter.setParent(record.id, parent.id);
144
153
  expect(moved.parentId).toBe(parent.id);
145
154
  expect(moved.version).toBe(record.version);
146
155
  expect(moved.updatedAt).toEqual(record.updatedAt);
147
- const unlisted = await adapter.mutateRecord(record.id, { unlisted: true }, { bumpsVersion: false });
156
+ const unlisted = await adapter.setUnlisted(record.id, true);
148
157
  expect(unlisted.unlistedAt).toBeInstanceOf(Date);
149
158
  expect(unlisted.version).toBe(record.version);
150
159
  expect(unlisted.parentId).toBe(parent.id);
151
- const listed = await adapter.mutateRecord(record.id, { unlisted: false }, { bumpsVersion: false });
160
+ const listed = await adapter.setUnlisted(record.id, false);
152
161
  expect(listed.unlistedAt).toBeUndefined();
153
162
  expect(listed.version).toBe(record.version);
163
+ const rooted = await adapter.setParent(record.id, null);
164
+ expect(rooted.parentId).toBeUndefined();
154
165
  });
155
- test('a no-bump mutateRecord takes no snapshot', async () => {
166
+ test('a write that does not bump takes no snapshot', async () => {
156
167
  const parent = makeRecord();
157
168
  await adapter.createRecord(parent);
158
169
  const record = makeRecord();
159
170
  await adapter.createRecord(record);
160
- await adapter.mutateRecord(record.id, { parentId: parent.id }, { bumpsVersion: false });
171
+ await adapter.setParent(record.id, parent.id);
172
+ await adapter.setUnlisted(record.id, true);
173
+ await adapter.setAssociations(record.id, [{ kind: 'tag', label: 'x' }]);
174
+ await adapter.setAccess(record.id, [{ kind: 'anyone', label: 'read' }]);
161
175
  expect(await adapter.getVersions(record.id)).toEqual([]);
162
176
  });
177
+ // Every write checks its precondition inside its own write, whether
178
+ // or not it bumps. See docs/spec/versioning.md § Optimistic concurrency.
179
+ test('every write refuses a stale ifVersion and changes nothing', async () => {
180
+ const parent = makeRecord();
181
+ await adapter.createRecord(parent);
182
+ const record = makeRecord({ content: { title: 'v1' } });
183
+ await adapter.createRecord(record);
184
+ const stale = { ifVersion: record.version + 1 };
185
+ const tag = { kind: 'tag', label: 'x' };
186
+ const anyone = { kind: 'anyone', label: 'read' };
187
+ for (const write of [
188
+ () => adapter.patchContent(record.id, { title: 'v2' }, stale),
189
+ () => adapter.setParent(record.id, parent.id, stale),
190
+ () => adapter.setUnlisted(record.id, true, stale),
191
+ () => adapter.editAssociations(record.id, [{ op: 'add', association: tag }], stale),
192
+ () => adapter.setAssociations(record.id, [tag], stale),
193
+ () => adapter.editAccess(record.id, [{ op: 'add', association: anyone }], stale),
194
+ () => adapter.setAccess(record.id, [anyone], stale),
195
+ ]) {
196
+ await expectStackErrorCode(write(), 'version_conflict');
197
+ }
198
+ const after = await adapter.getRecord(record.id);
199
+ expect(after.content).toEqual({ title: 'v1' });
200
+ expect(after.parentId).toBeUndefined();
201
+ expect(after.unlistedAt).toBeUndefined();
202
+ expect(after.associations ?? []).toEqual([]);
203
+ expect(after.permissions ?? []).toEqual([]);
204
+ const current = { ifVersion: record.version };
205
+ expect((await adapter.setParent(record.id, parent.id, current)).parentId).toBe(parent.id);
206
+ expect((await adapter.setAccess(record.id, [anyone], current)).permissions).toEqual([
207
+ anyone,
208
+ ]);
209
+ });
163
210
  test('soft deleteRecord sets deletedAt; undeleteRecord clears it', async () => {
164
211
  const record = makeRecord();
165
212
  await adapter.createRecord(record);
@@ -171,27 +218,13 @@ export function runRecordAdapterConformance(options) {
171
218
  test('getVersions returns snapshots newest first', async () => {
172
219
  const record = makeRecord();
173
220
  await adapter.createRecord(record);
174
- for (const version of [1, 2, 3]) {
175
- await adapter.saveVersion(record.id, {
176
- version,
177
- typeId: record.typeId,
178
- content: record.content,
179
- updatedAt: record.updatedAt,
180
- });
181
- }
221
+ await patchWithSnapshots(record, 3);
182
222
  expect((await adapter.getVersions(record.id)).map((v) => v.version)).toEqual([3, 2, 1]);
183
223
  });
184
224
  test('getVersions pages walk every version exactly once, newest first', async () => {
185
225
  const record = makeRecord();
186
226
  await adapter.createRecord(record);
187
- for (const version of [1, 2, 3, 4, 5]) {
188
- await adapter.saveVersion(record.id, {
189
- version,
190
- typeId: record.typeId,
191
- content: record.content,
192
- updatedAt: record.updatedAt,
193
- });
194
- }
227
+ await patchWithSnapshots(record, 5);
195
228
  const seen = [];
196
229
  let beforeVersion;
197
230
  for (;;) {
@@ -207,14 +240,7 @@ export function runRecordAdapterConformance(options) {
207
240
  test('getVersions beforeVersion is exclusive and limit bounds the result', async () => {
208
241
  const record = makeRecord();
209
242
  await adapter.createRecord(record);
210
- for (const version of [1, 2, 3]) {
211
- await adapter.saveVersion(record.id, {
212
- version,
213
- typeId: record.typeId,
214
- content: record.content,
215
- updatedAt: record.updatedAt,
216
- });
217
- }
243
+ await patchWithSnapshots(record, 3);
218
244
  const versionsOf = async (query) => (await adapter.getVersions(record.id, query)).map((v) => v.version);
219
245
  expect(await versionsOf({ beforeVersion: 3 })).toEqual([2, 1]);
220
246
  expect(await versionsOf({ beforeVersion: 1 })).toEqual([]);
@@ -225,12 +251,7 @@ export function runRecordAdapterConformance(options) {
225
251
  test('purging deleteRecord removes the record and its version history', async () => {
226
252
  const record = makeRecord();
227
253
  await adapter.createRecord(record);
228
- await adapter.saveVersion(record.id, {
229
- version: record.version,
230
- typeId: record.typeId,
231
- content: record.content,
232
- updatedAt: record.updatedAt,
233
- });
254
+ await patchWithSnapshots(record, 1);
234
255
  await adapter.deleteRecord(record.id, { purge: true });
235
256
  expect(await adapter.getRecord(record.id)).toBeNull();
236
257
  expect(await adapter.getVersions(record.id)).toEqual([]);
@@ -245,7 +266,7 @@ export function runRecordAdapterConformance(options) {
245
266
  // initialization, outside this interface — nothing here writes it.
246
267
  // The invariant holds either way: if the adapter under test has one,
247
268
  // a generic query must still never surface it.
248
- const { records } = await adapter.queryRecords({});
269
+ const { records } = await adapter.queryRecords(resolveQuery({}));
249
270
  expect(records.some((r) => r.id === SYSTEM_TYPES.CONFIG)).toBe(false);
250
271
  });
251
272
  });
@@ -254,15 +275,28 @@ export function runRecordAdapterConformance(options) {
254
275
  // -----------------------------------------------------------------
255
276
  describe('associations', () => {
256
277
  const tag = (label) => ({ kind: 'tag', label });
278
+ // A tag filter is conjunctive, and its length is the caller's — far
279
+ // past any statement's parameter cap (100 on Durable Object SQLite).
280
+ test('a tag filter matches the records carrying every tag named, however many', async () => {
281
+ const labels = Array.from({ length: 120 }, (_, i) => `tag-${i}`);
282
+ const all = makeRecord({ associations: labels.map((label) => ({ kind: 'tag', label })) });
283
+ const allButOne = makeRecord({
284
+ associations: labels.slice(1).map((label) => ({ kind: 'tag', label })),
285
+ });
286
+ await adapter.createRecord(all);
287
+ await adapter.createRecord(allButOne);
288
+ const result = await adapter.queryRecords(resolveQuery({ filter: { tags: [...labels, labels[0]] } }));
289
+ expect(result.records.map((r) => r.id)).toEqual([all.id]);
290
+ });
257
291
  test('an add puts a tag on the record and a remove takes it off; neither bumps version', async () => {
258
292
  const record = makeRecord();
259
293
  await adapter.createRecord(record);
260
- const tagged = await adapter.amendAssociations(record.id, [
294
+ const tagged = await adapter.editAssociations(record.id, [
261
295
  { op: 'add', association: { kind: 'tag', label: 'important' } },
262
296
  ]);
263
297
  expect(tagged.associations).toContainEqual({ kind: 'tag', label: 'important' });
264
298
  expect(tagged.version).toBe(record.version);
265
- const untagged = await adapter.amendAssociations(record.id, [
299
+ const untagged = await adapter.editAssociations(record.id, [
266
300
  {
267
301
  op: 'remove',
268
302
  association: {
@@ -280,24 +314,23 @@ export function runRecordAdapterConformance(options) {
280
314
  test('an add is idempotent — a duplicate does not create a second entry', async () => {
281
315
  const record = makeRecord();
282
316
  await adapter.createRecord(record);
283
- await adapter.amendAssociations(record.id, [
317
+ await adapter.editAssociations(record.id, [
284
318
  { op: 'add', association: { kind: 'tag', label: 'dup' } },
285
319
  ]);
286
- const twice = await adapter.amendAssociations(record.id, [
320
+ const twice = await adapter.editAssociations(record.id, [
287
321
  { op: 'add', association: { kind: 'tag', label: 'dup' } },
288
322
  ]);
289
323
  expect(twice.associations?.filter((a) => a.kind === 'tag' && a.label === 'dup')).toHaveLength(1);
290
324
  });
291
- test('an amend on a nonexistent record reports not_found rather than creating an orphan', async () => {
292
- await expectStackErrorCode(adapter.amendAssociations(uniqueId('missing'), [
325
+ test('an edit on a nonexistent record reports not_found rather than creating an orphan', async () => {
326
+ await expectStackErrorCode(adapter.editAssociations(uniqueId('missing'), [
293
327
  { op: 'add', association: { kind: 'tag', label: 'x' } },
294
328
  ]), 'not_found');
295
329
  });
296
- // Authority and data share one stored set, projected into two
297
- // fields by kind. Every adapter partitions it for itself, so the
298
- // agreement is worth pinning here rather than once per adapter.
330
+ // Data associations and permissions are two aspects, stored apart:
331
+ // a write to one never reads or moves the other.
299
332
  // See docs/spec/access-control.md § Record-level permissions.
300
- test('authority kinds travel the same call and project onto permissions', async () => {
333
+ test('permissions are their own aspect, edited and replaced apart from associations', async () => {
301
334
  const record = makeRecord();
302
335
  await adapter.createRecord(record);
303
336
  const grant = {
@@ -305,20 +338,21 @@ export function runRecordAdapterConformance(options) {
305
338
  label: 'read',
306
339
  grantee: { kind: 'entity', entityId: 'did:key:z6MkConformance' },
307
340
  };
308
- await adapter.amendAssociations(record.id, [
309
- { op: 'add', association: { kind: 'tag', label: 'draft' } },
310
- ]);
311
- const granted = await adapter.amendAssociations(record.id, [
312
- { op: 'add', association: grant },
313
- ]);
341
+ // A grantee and a relationship target naming the same entity are
342
+ // two different facts.
343
+ const related = {
344
+ kind: 'relationship',
345
+ label: 'read',
346
+ target: { kind: 'entity', entityId: 'did:key:z6MkConformance' },
347
+ };
348
+ await adapter.editAssociations(record.id, [{ op: 'add', association: related }]);
349
+ const granted = await adapter.editAccess(record.id, [{ op: 'add', association: grant }]);
314
350
  expect(granted.permissions).toEqual([grant]);
315
- expect(granted.associations).toEqual([{ kind: 'tag', label: 'draft' }]);
351
+ expect(granted.associations).toEqual([related]);
316
352
  expect(granted.version).toBe(record.version);
317
- const revoked = await adapter.amendAssociations(record.id, [
318
- { op: 'remove', association: grant },
319
- ]);
353
+ const revoked = await adapter.editAccess(record.id, [{ op: 'remove', association: grant }]);
320
354
  expect(revoked.permissions ?? []).toEqual([]);
321
- expect(revoked.associations).toEqual([{ kind: 'tag', label: 'draft' }]);
355
+ expect(revoked.associations).toEqual([related]);
322
356
  });
323
357
  test('a group grantee keeps its role — member and admin are two entries', async () => {
324
358
  const record = makeRecord();
@@ -331,18 +365,16 @@ export function runRecordAdapterConformance(options) {
331
365
  });
332
366
  const member = forRole('member');
333
367
  const admin = forRole('admin');
334
- await adapter.amendAssociations(record.id, [{ op: 'add', association: member }]);
335
- const both = await adapter.amendAssociations(record.id, [
336
- { op: 'add', association: admin },
337
- ]);
368
+ await adapter.editAccess(record.id, [{ op: 'add', association: member }]);
369
+ const both = await adapter.editAccess(record.id, [{ op: 'add', association: admin }]);
338
370
  expect(both.permissions).toEqual(expect.arrayContaining([member, admin]));
339
371
  expect(both.permissions).toHaveLength(2);
340
372
  });
341
373
  test('a list of adds and removes lands as one write that bumps no version', async () => {
342
374
  const record = makeRecord();
343
375
  await adapter.createRecord(record, { journal: { ops: ['create'], kind: 'created' } });
344
- await adapter.amendAssociations(record.id, [{ op: 'add', association: tag('old') }]);
345
- const amended = await adapter.amendAssociations(record.id, [
376
+ await adapter.editAssociations(record.id, [{ op: 'add', association: tag('old') }]);
377
+ const amended = await adapter.editAssociations(record.id, [
346
378
  { op: 'remove', association: tag('old') },
347
379
  { op: 'add', association: tag('new') },
348
380
  { op: 'add', association: tag('extra') },
@@ -353,64 +385,144 @@ export function runRecordAdapterConformance(options) {
353
385
  expect(amended.updatedAt).toEqual(record.updatedAt);
354
386
  expect(await adapter.getJournal(record.id)).toHaveLength(2);
355
387
  });
356
- test('edits naming one identity collapse, last wins', async () => {
388
+ // The compare-and-swap Stack retries on: the set its rules were asked
389
+ // of must be the set the write lands on. Order is not part of a set.
390
+ // See docs/spec/adapters.md § Editing associations.
391
+ test('an element write refuses an expectSet the stored set no longer matches, and changes nothing', async () => {
357
392
  const record = makeRecord();
358
393
  await adapter.createRecord(record);
359
- const fileId = 'c'.repeat(64);
360
- const first = {
361
- kind: 'attachment',
362
- label: 'cover',
363
- fileId,
364
- attachmentRecordId: 'one',
365
- };
366
- const second = { ...first, attachmentRecordId: 'two' };
367
- const amended = await adapter.amendAssociations(record.id, [
368
- { op: 'add', association: first },
369
- { op: 'add', association: second },
370
- ]);
371
- expect(amended.associations).toEqual([second]);
394
+ const anyone = { kind: 'anyone', label: 'read' };
395
+ await adapter.setAssociations(record.id, [tag('a'), tag('b')]);
396
+ await adapter.setAccess(record.id, [anyone]);
397
+ const journal = { ops: ['associate'], kind: 'changed' };
398
+ const staleTags = { expectSet: [tag('a')], journal };
399
+ const stalePermissions = { expectSet: [], journal };
400
+ for (const write of [
401
+ () => adapter.editAssociations(record.id, [{ op: 'add', association: tag('c') }], staleTags),
402
+ () => adapter.setAssociations(record.id, [tag('c')], staleTags),
403
+ () => adapter.editAccess(record.id, [{ op: 'remove', association: anyone }], stalePermissions),
404
+ () => adapter.setAccess(record.id, [], stalePermissions),
405
+ ]) {
406
+ await expect(write()).rejects.toBeInstanceOf(ElementSetMovedError);
407
+ }
408
+ const after = await adapter.getRecord(record.id);
409
+ expect(after.associations).toEqual(expect.arrayContaining([tag('a'), tag('b')]));
410
+ expect(after.associations).toHaveLength(2);
411
+ expect(after.permissions).toEqual([anyone]);
412
+ expect(await adapter.getJournal(record.id)).toHaveLength(0);
413
+ const held = await adapter.editAssociations(record.id, [{ op: 'add', association: tag('c') }], { expectSet: [tag('b'), tag('a')] });
414
+ expect(held.associations).toHaveLength(3);
415
+ const cleared = await adapter.setAccess(record.id, [], { expectSet: [anyone] });
416
+ expect(cleared.permissions ?? []).toEqual([]);
372
417
  });
373
- test('a list mixing authority and data elements is refused and applies nothing', async () => {
418
+ // Asked before ifVersion: the stale version here would otherwise
419
+ // answer 412 for a record whose state is the reason for refusing.
420
+ // See docs/spec/adapters.md § Interface split.
421
+ test('an aspect write refuses a soft-deleted record, and changes nothing', async () => {
422
+ const parent = makeRecord();
374
423
  const record = makeRecord();
424
+ await adapter.createRecord(parent);
375
425
  await adapter.createRecord(record);
376
- const grant = { kind: 'anyone', label: 'read' };
377
- await expectStackErrorCode(adapter.amendAssociations(record.id, [
378
- { op: 'add', association: tag('mixed') },
379
- { op: 'add', association: grant },
380
- ]), 'bad_request');
381
- const after = await adapter.getRecord(record.id);
382
- expect(after.associations ?? []).toEqual([]);
383
- expect(after.permissions ?? []).toEqual([]);
426
+ await adapter.deleteRecord(record.id);
427
+ const before = await adapter.getRecord(record.id);
428
+ const journalBefore = await adapter.getJournal(record.id);
429
+ const anyone = { kind: 'anyone', label: 'read' };
430
+ const journal = { ops: ['associate'], kind: 'changed' };
431
+ const opts = { ifVersion: record.version, journal };
432
+ for (const write of [
433
+ () => adapter.setParent(record.id, parent.id, opts),
434
+ () => adapter.setUnlisted(record.id, true, opts),
435
+ () => adapter.editAssociations(record.id, [{ op: 'add', association: tag('a') }], opts),
436
+ () => adapter.setAssociations(record.id, [tag('a')], opts),
437
+ () => adapter.editAccess(record.id, [{ op: 'add', association: anyone }], opts),
438
+ () => adapter.setAccess(record.id, [anyone], opts),
439
+ ]) {
440
+ await expectStackErrorCode(write(), 'conflict');
441
+ }
442
+ expect(await adapter.getRecord(record.id)).toEqual(before);
443
+ expect(await adapter.getJournal(record.id)).toEqual(journalBefore);
444
+ });
445
+ // Stack's expectSet is what getRecord() answered, so a set read back
446
+ // must match itself in every shape an element can take.
447
+ test('a set read back through getRecord matches itself as expectSet', async () => {
448
+ const record = makeRecord();
449
+ await adapter.createRecord(record);
450
+ const associations = [
451
+ tag('t'),
452
+ { kind: 'attachment', label: 'cover', fileId: 'a'.repeat(64) },
453
+ {
454
+ kind: 'attachment',
455
+ label: 'cover',
456
+ fileId: 'b'.repeat(64),
457
+ attachmentRecordId: uniqueId('att'),
458
+ },
459
+ { kind: 'relationship', label: 'cites', target: { kind: 'record', recordId: 'r1' } },
460
+ {
461
+ kind: 'relationship',
462
+ label: 'cites',
463
+ target: { kind: 'record', recordId: 'r1', stackUrl: 'https://example.com' },
464
+ },
465
+ {
466
+ kind: 'relationship',
467
+ label: 'about',
468
+ target: { kind: 'entity', entityId: 'did:key:z6MkConformance' },
469
+ },
470
+ {
471
+ kind: 'relationship',
472
+ label: 'link',
473
+ target: { kind: 'external', ns: 'isbn', id: '1' },
474
+ },
475
+ ];
476
+ const permissions = [
477
+ { kind: 'anyone', label: 'read' },
478
+ {
479
+ kind: 'permission',
480
+ label: 'read',
481
+ grantee: { kind: 'entity', entityId: 'did:key:z6MkConformance' },
482
+ },
483
+ {
484
+ kind: 'permission',
485
+ label: 'write',
486
+ grantee: { kind: 'group', groupId: uniqueId('grp'), role: 'admin' },
487
+ },
488
+ ];
489
+ await adapter.setAssociations(record.id, associations);
490
+ await adapter.setAccess(record.id, permissions);
491
+ const read = (await adapter.getRecord(record.id));
492
+ const tagged = await adapter.editAssociations(record.id, [{ op: 'add', association: tag('u') }], { expectSet: read.associations ?? [] });
493
+ expect(tagged.associations).toHaveLength(associations.length + 1);
494
+ const shared = await adapter.setAccess(record.id, permissions.slice(1), {
495
+ expectSet: read.permissions ?? [],
496
+ });
497
+ expect(shared.permissions).toHaveLength(permissions.length - 1);
384
498
  });
385
- test('each change-set key replaces only its own half of the set', async () => {
499
+ test('each set verb replaces only its own aspect', async () => {
386
500
  const record = makeRecord();
387
501
  await adapter.createRecord(record);
388
502
  const anyone = { kind: 'anyone', label: 'read' };
389
- await adapter.amendAssociations(record.id, [
503
+ await adapter.editAssociations(record.id, [
390
504
  { op: 'add', association: { kind: 'tag', label: 'draft' } },
391
505
  ]);
392
- await adapter.amendAssociations(record.id, [{ op: 'add', association: anyone }]);
393
- const retagged = await adapter.mutateRecord(record.id, { associations: [{ kind: 'tag', label: 'reviewed' }] }, { bumpsVersion: false });
506
+ await adapter.editAccess(record.id, [{ op: 'add', association: anyone }]);
507
+ const retagged = await adapter.setAssociations(record.id, [
508
+ { kind: 'tag', label: 'reviewed' },
509
+ ]);
394
510
  expect(retagged.associations).toEqual([{ kind: 'tag', label: 'reviewed' }]);
395
511
  expect(retagged.permissions).toEqual([anyone]);
396
- const cleared = await adapter.mutateRecord(record.id, { permissions: [] }, { bumpsVersion: false });
512
+ const cleared = await adapter.setAccess(record.id, []);
397
513
  expect(cleared.associations).toEqual([{ kind: 'tag', label: 'reviewed' }]);
398
514
  expect(cleared.permissions ?? []).toEqual([]);
515
+ expect(cleared.version).toBe(record.version);
399
516
  });
400
517
  });
401
518
  // -----------------------------------------------------------------
402
519
  // Versions — snapshots and restore
403
520
  // -----------------------------------------------------------------
404
521
  describe('versions', () => {
405
- test('saveVersion and getVersion roundtrip', async () => {
522
+ test("a patch's snapshot reads back through getVersion", async () => {
406
523
  const record = makeRecord();
407
524
  await adapter.createRecord(record);
408
- await adapter.saveVersion(record.id, {
409
- version: record.version,
410
- typeId: record.typeId,
411
- content: record.content,
412
- updatedAt: record.updatedAt,
413
- });
525
+ await patchWithSnapshots(record, 1);
414
526
  const version = await adapter.getVersion(record.id, record.version);
415
527
  expect(version?.content).toEqual(record.content);
416
528
  });
@@ -419,15 +531,9 @@ export function runRecordAdapterConformance(options) {
419
531
  await adapter.createRecord(parent);
420
532
  const record = makeRecord({ content: { title: 'v1' } });
421
533
  await adapter.createRecord(record);
422
- await adapter.saveVersion(record.id, {
423
- version: record.version,
424
- typeId: record.typeId,
425
- content: record.content,
426
- updatedAt: record.updatedAt,
427
- });
428
- await adapter.mutateRecord(record.id, { contentPatch: { title: 'v2' } });
429
- await adapter.mutateRecord(record.id, { parentId: parent.id }, { bumpsVersion: false });
430
- await adapter.amendAssociations(record.id, [
534
+ await adapter.patchContent(record.id, { title: 'v2' }, { snapshot: snapshotOf(record) });
535
+ await adapter.setParent(record.id, parent.id);
536
+ await adapter.editAssociations(record.id, [
431
537
  { op: 'add', association: { kind: 'tag', label: 'keep-me' } },
432
538
  ]);
433
539
  const restored = await adapter.restoreVersion(record.id, record.version);
@@ -443,6 +549,134 @@ export function runRecordAdapterConformance(options) {
443
549
  });
444
550
  });
445
551
  // -----------------------------------------------------------------
552
+ // Importing a record — optional, so each test asks whether the adapter
553
+ // has importRecord() at all. See docs/spec/portability.md § Writing
554
+ // history.
555
+ // -----------------------------------------------------------------
556
+ describe('importing a record', () => {
557
+ const AUTHOR = { subjectId: 'did:key:conformance-author' };
558
+ const EDITOR = { subjectId: 'did:key:conformance-editor', principalId: 'did:key:app' };
559
+ const at = (day) => new Date(Date.UTC(2024, 0, day));
560
+ const pinned = { kind: 'tag', label: 'pinned' };
561
+ /** A record with a past: two versions, four journal entries, every optional field set. */
562
+ const withPast = (parentId) => {
563
+ const record = makeRecord({
564
+ content: { title: 'v2' },
565
+ createdAt: at(1),
566
+ updatedAt: at(2),
567
+ version: 2,
568
+ parentId,
569
+ appId: 'org.haverstack.conformance',
570
+ createdBy: AUTHOR,
571
+ updatedBy: EDITOR,
572
+ unlistedAt: at(3),
573
+ associations: [pinned],
574
+ permissions: [{ kind: 'anyone', label: 'read' }],
575
+ });
576
+ const versions = [
577
+ {
578
+ version: 1,
579
+ typeId: record.typeId,
580
+ content: { title: 'v1' },
581
+ updatedAt: at(1),
582
+ createdBy: AUTHOR,
583
+ updatedBy: AUTHOR,
584
+ },
585
+ ];
586
+ const journal = [
587
+ {
588
+ seq: 1,
589
+ at: at(1),
590
+ kind: 'created',
591
+ ops: ['create'],
592
+ version: 1,
593
+ typeId: record.typeId,
594
+ actor: AUTHOR,
595
+ },
596
+ {
597
+ seq: 2,
598
+ at: at(2),
599
+ kind: 'changed',
600
+ ops: ['patch'],
601
+ version: 2,
602
+ typeId: record.typeId,
603
+ actor: EDITOR,
604
+ },
605
+ {
606
+ seq: 3,
607
+ at: at(3),
608
+ kind: 'changed',
609
+ ops: ['reparent'],
610
+ version: 2,
611
+ typeId: record.typeId,
612
+ parentId,
613
+ previousParentId: null,
614
+ actor: EDITOR,
615
+ },
616
+ {
617
+ seq: 4,
618
+ at: at(3),
619
+ kind: 'changed',
620
+ ops: ['associate', 'unlist'],
621
+ version: 2,
622
+ typeId: record.typeId,
623
+ parentId,
624
+ associations: [{ op: 'add', association: pinned }],
625
+ },
626
+ ];
627
+ return { record, versions, journal };
628
+ };
629
+ test('writes the record, its versions and its journal exactly as recorded', async () => {
630
+ if (!adapter.importRecord)
631
+ return;
632
+ const parent = makeRecord();
633
+ await adapter.createRecord(parent);
634
+ const { record, versions, journal } = withPast(parent.id);
635
+ expect(await adapter.importRecord(record, { versions, journal })).toEqual(record);
636
+ expect(await adapter.getRecord(record.id)).toEqual(record);
637
+ expect(await adapter.getVersions(record.id)).toEqual(versions);
638
+ expect(await adapter.getJournal(record.id)).toEqual(journal);
639
+ });
640
+ test('appends no journal entry of its own', async () => {
641
+ if (!adapter.importRecord)
642
+ return;
643
+ const record = makeRecord();
644
+ await adapter.importRecord(record, { versions: [], journal: [] });
645
+ expect(await adapter.getJournal(record.id)).toEqual([]);
646
+ });
647
+ test('refuses an id that already exists', async () => {
648
+ if (!adapter.importRecord)
649
+ return;
650
+ const record = makeRecord();
651
+ await adapter.createRecord(record);
652
+ await expectStackErrorCode(adapter.importRecord(record, { versions: [], journal: [] }), 'conflict');
653
+ });
654
+ test('an imported record answers queries as a created one does', async () => {
655
+ if (!adapter.importRecord)
656
+ return;
657
+ const record = makeRecord({ content: { title: 'imported' }, associations: [pinned] });
658
+ await adapter.importRecord(record, { versions: [], journal: [] });
659
+ const byTag = await adapter.queryRecords(resolveQuery({ filter: { tags: ['pinned'] } }));
660
+ expect(byTag.records.map((r) => r.id)).toEqual([record.id]);
661
+ if (capabilities.filter.content !== 'none') {
662
+ const byContent = await adapter.queryRecords(resolveQuery({ filter: { content: { title: 'imported' } } }));
663
+ expect(byContent.records.map((r) => r.id)).toEqual([record.id]);
664
+ }
665
+ });
666
+ test('a write after an import continues its history', async () => {
667
+ if (!adapter.importRecord)
668
+ return;
669
+ const parent = makeRecord();
670
+ await adapter.createRecord(parent);
671
+ const { record, versions, journal } = withPast(parent.id);
672
+ await adapter.importRecord(record, { versions, journal });
673
+ const patched = await adapter.patchContent(record.id, { title: 'v3' }, { snapshot: snapshotOf(record), journal: { ops: ['patch'], kind: 'changed' } });
674
+ expect(patched.version).toBe(3);
675
+ expect((await adapter.getVersions(record.id)).map((v) => v.version)).toEqual([2, 1]);
676
+ expect((await adapter.getJournal(record.id)).map((e) => e.seq)).toEqual([1, 2, 3, 4, 5]);
677
+ });
678
+ });
679
+ // -----------------------------------------------------------------
446
680
  // Change journal — the second durable tier
447
681
  // -----------------------------------------------------------------
448
682
  describe('change journal', () => {
@@ -465,7 +699,7 @@ export function runRecordAdapterConformance(options) {
465
699
  await adapter.createRecord(record, {
466
700
  journal: { ops: ['create'], kind: 'created' },
467
701
  });
468
- await adapter.mutateRecord(record.id, { contentPatch: { title: 'v2' } }, { bumpsVersion: true, journal: { ops: ['patch'], kind: 'changed' } });
702
+ await adapter.patchContent(record.id, { title: 'v2' }, { journal: { ops: ['patch'], kind: 'changed' } });
469
703
  const log = await adapter.getJournal(record.id);
470
704
  expect(log).toHaveLength(2);
471
705
  // version, typeId and parentId are the adapter's to stamp: Stack
@@ -480,8 +714,8 @@ export function runRecordAdapterConformance(options) {
480
714
  const second = makeRecord();
481
715
  await adapter.createRecord(first, { journal: { ops: ['create'], kind: 'created' } });
482
716
  await adapter.createRecord(second, { journal: { ops: ['create'], kind: 'created' } });
483
- await adapter.amendAssociations(first.id, [{ op: 'add', association: { kind: 'tag', label: 'a' } }], { journal: { ops: ['associate'], kind: 'changed' } });
484
- await adapter.amendAssociations(first.id, [{ op: 'add', association: { kind: 'tag', label: 'b' } }], { journal: { ops: ['associate'], kind: 'changed' } });
717
+ await adapter.editAssociations(first.id, [{ op: 'add', association: { kind: 'tag', label: 'a' } }], { journal: { ops: ['associate'], kind: 'changed' } });
718
+ await adapter.editAssociations(first.id, [{ op: 'add', association: { kind: 'tag', label: 'b' } }], { journal: { ops: ['associate'], kind: 'changed' } });
485
719
  expect((await adapter.getJournal(first.id)).map((e) => e.seq)).toEqual([1, 2, 3]);
486
720
  // Per record, so a busy neighbour never advances this one's log.
487
721
  expect((await adapter.getJournal(second.id)).map((e) => e.seq)).toEqual([1]);
@@ -489,14 +723,14 @@ export function runRecordAdapterConformance(options) {
489
723
  test('an association change journals a version that stands still', async () => {
490
724
  const record = makeRecord();
491
725
  await adapter.createRecord(record);
492
- await adapter.amendAssociations(record.id, [{ op: 'add', association: { kind: 'tag', label: 'starred' } }], {
726
+ await adapter.editAssociations(record.id, [{ op: 'add', association: { kind: 'tag', label: 'starred' } }], {
493
727
  journal: {
494
728
  ops: ['associate'],
495
729
  kind: 'changed',
496
730
  associations: [{ op: 'add', association: { kind: 'tag', label: 'starred' } }],
497
731
  },
498
732
  });
499
- await adapter.amendAssociations(record.id, [{ op: 'remove', association: { kind: 'tag', label: 'starred' } }], {
733
+ await adapter.editAssociations(record.id, [{ op: 'remove', association: { kind: 'tag', label: 'starred' } }], {
500
734
  journal: {
501
735
  ops: ['dissociate'],
502
736
  kind: 'changed',
@@ -530,7 +764,7 @@ export function runRecordAdapterConformance(options) {
530
764
  fileId: 'a'.repeat(64),
531
765
  attachmentRecordId: uniqueId('new'),
532
766
  };
533
- await adapter.amendAssociations(record.id, [{ op: 'add', association: association }], {
767
+ await adapter.editAssociations(record.id, [{ op: 'add', association: association }], {
534
768
  journal: {
535
769
  ops: ['associate'],
536
770
  kind: 'changed',
@@ -552,8 +786,8 @@ export function runRecordAdapterConformance(options) {
552
786
  fileId: 'b'.repeat(64),
553
787
  attachmentRecordId: uniqueId('upload'),
554
788
  };
555
- await adapter.amendAssociations(record.id, [{ op: 'add', association: previous }]);
556
- await adapter.amendAssociations(record.id, [
789
+ await adapter.editAssociations(record.id, [{ op: 'add', association: previous }]);
790
+ await adapter.editAssociations(record.id, [
557
791
  {
558
792
  op: 'remove',
559
793
  association: { kind: 'attachment', label: 'cover', fileId: 'b'.repeat(64) },
@@ -568,25 +802,6 @@ export function runRecordAdapterConformance(options) {
568
802
  const [entry] = await adapter.getJournal(record.id);
569
803
  expect(entry.associations).toEqual([{ op: 'remove', association: previous }]);
570
804
  });
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
805
  test('previousParentId tells "did not move" apart from "moved off the root"', async () => {
591
806
  // The one field on any entry where null is a value rather than an
592
807
  // input spelling. Collapsing both to absent loses which happened —
@@ -596,8 +811,7 @@ export function runRecordAdapterConformance(options) {
596
811
  await adapter.createRecord(parent);
597
812
  const record = makeRecord();
598
813
  await adapter.createRecord(record, { journal: { ops: ['create'], kind: 'created' } });
599
- await adapter.mutateRecord(record.id, { parentId: parent.id }, {
600
- bumpsVersion: false,
814
+ await adapter.setParent(record.id, parent.id, {
601
815
  journal: { ops: ['reparent'], kind: 'changed', previousParentId: null },
602
816
  });
603
817
  const log = await adapter.getJournal(record.id);
@@ -609,7 +823,7 @@ export function runRecordAdapterConformance(options) {
609
823
  const record = makeRecord();
610
824
  await adapter.createRecord(record, { journal: { ops: ['create'], kind: 'created' } });
611
825
  for (const label of ['a', 'b', 'c']) {
612
- await adapter.amendAssociations(record.id, [{ op: 'add', association: { kind: 'tag', label } }], { journal: { ops: ['associate'], kind: 'changed' } });
826
+ await adapter.editAssociations(record.id, [{ op: 'add', association: { kind: 'tag', label } }], { journal: { ops: ['associate'], kind: 'changed' } });
613
827
  }
614
828
  expect(await adapter.getJournal(record.id)).toHaveLength(4);
615
829
  expect((await adapter.getJournal(record.id, { afterSeq: 2 })).map((e) => e.seq)).toEqual([
@@ -623,7 +837,7 @@ export function runRecordAdapterConformance(options) {
623
837
  });
624
838
  test('a failed mutation leaves no entry behind', async () => {
625
839
  const missing = uniqueId('missing');
626
- await expectStackErrorCode(adapter.mutateRecord(missing, { contentPatch: { title: 'x' } }, { bumpsVersion: true, journal: { ops: ['patch'], kind: 'changed' } }), 'not_found');
840
+ await expectStackErrorCode(adapter.patchContent(missing, { title: 'x' }, { journal: { ops: ['patch'], kind: 'changed' } }), 'not_found');
627
841
  // The record was never created, so its log is refused rather than
628
842
  // read — what matters is that the entry did not land somewhere.
629
843
  await expectStackErrorCode(adapter.getJournal(missing), 'not_found');
@@ -676,7 +890,7 @@ export function runRecordAdapterConformance(options) {
676
890
  const seen = new Set();
677
891
  let cursor;
678
892
  do {
679
- const page = await adapter.queryRecords({ limit: 2, cursor: cursor ?? undefined });
893
+ const page = await adapter.queryRecords(resolveQuery({ limit: 2, cursor: cursor ?? undefined }));
680
894
  expect(page.records.length).toBeLessThanOrEqual(2);
681
895
  for (const r of page.records)
682
896
  seen.add(r.id);
@@ -685,22 +899,275 @@ export function runRecordAdapterConformance(options) {
685
899
  for (const id of ids)
686
900
  expect(seen.has(id)).toBe(true);
687
901
  });
902
+ // A cursor names the last record its page held, so the next page
903
+ // starts after that record wherever it now falls in the order.
904
+ test('a page resumes after its cursor whatever was inserted ahead of it', async () => {
905
+ for (let i = 0; i < 5; i++) {
906
+ await adapter.createRecord(makeRecord({ createdAt: new Date(1_000 + i) }));
907
+ }
908
+ const sort = { field: 'createdAt', direction: 'asc' };
909
+ const first = await adapter.queryRecords(resolveQuery({ limit: 2, sort }));
910
+ await adapter.createRecord(makeRecord({ createdAt: new Date(1) }));
911
+ const second = await adapter.queryRecords(resolveQuery({ limit: 2, cursor: first.cursor, sort }));
912
+ const all = (await adapter.queryRecords(resolveQuery({ limit: 100, sort }))).records.map((r) => r.id);
913
+ const after = all.indexOf(first.records[1].id);
914
+ expect(second.records.map((r) => r.id)).toEqual(all.slice(after + 1, after + 3));
915
+ });
688
916
  test('a malformed cursor is refused rather than misread', async () => {
689
- await expectStackErrorCode(adapter.queryRecords({ cursor: 'not-a-real-cursor' }), 'bad_request');
917
+ await expectStackErrorCode(adapter.queryRecords(resolveQuery({ cursor: 'not-a-real-cursor' })), 'bad_request');
690
918
  });
691
919
  test('a cursor minted under one sort field is refused when replayed under a different one', async () => {
692
920
  await adapter.createRecord(makeRecord());
693
921
  await adapter.createRecord(makeRecord());
694
- const minted = await adapter.queryRecords({
922
+ const minted = await adapter.queryRecords(resolveQuery({
695
923
  limit: 1,
696
924
  sort: { field: 'createdAt', direction: 'asc' },
697
- });
925
+ }));
698
926
  expect(minted.cursor).not.toBeNull();
699
- await expectStackErrorCode(adapter.queryRecords({
927
+ await expectStackErrorCode(adapter.queryRecords(resolveQuery({
700
928
  limit: 1,
701
929
  cursor: minted.cursor,
702
930
  sort: { field: 'updatedAt', direction: 'asc' },
703
- }), 'bad_request');
931
+ })), 'bad_request');
932
+ });
933
+ });
934
+ // -----------------------------------------------------------------
935
+ // Querying within a reach — optional, so each test asks whether the
936
+ // adapter has queryReadable() at all. The reference reading is core's
937
+ // inReach(); see docs/spec/access-control.md § Reach.
938
+ // -----------------------------------------------------------------
939
+ describe('querying within a reach', () => {
940
+ const NOTE = 'org.haverstack.conformance/note';
941
+ const OTHER = 'org.haverstack.conformance/other';
942
+ const OTHER_TYPE_ID = `${OTHER}@1`;
943
+ const ALICE = 'did:key:conformance-alice';
944
+ const BOB = 'did:key:conformance-bob';
945
+ const anyone = { kind: 'anyone', label: 'read' };
946
+ const toAlice = {
947
+ kind: 'permission',
948
+ label: 'read',
949
+ grantee: { kind: 'entity', entityId: ALICE },
950
+ };
951
+ const toMembers = {
952
+ kind: 'permission',
953
+ label: 'read',
954
+ grantee: { kind: 'group', groupId: 'conformance-group', role: 'member' },
955
+ };
956
+ const toAdmins = {
957
+ kind: 'permission',
958
+ label: 'read',
959
+ grantee: { kind: 'group', groupId: 'conformance-group', role: 'admin' },
960
+ };
961
+ /** One record per route into a reach, and one beside each that misses it. */
962
+ const populate = async () => {
963
+ const records = [
964
+ makeRecord(),
965
+ makeRecord({ typeId: OTHER_TYPE_ID }),
966
+ makeRecord({ createdBy: { subjectId: ALICE } }),
967
+ makeRecord({ typeId: OTHER_TYPE_ID, createdBy: { subjectId: ALICE } }),
968
+ makeRecord({ createdBy: { subjectId: BOB, principalId: ALICE } }),
969
+ makeRecord({ permissions: [anyone] }),
970
+ makeRecord({ typeId: OTHER_TYPE_ID, permissions: [anyone] }),
971
+ // A family's other versions are in reach with it; one sharing its
972
+ // prefix is not.
973
+ makeRecord({ typeId: `${NOTE}@2` }),
974
+ makeRecord({ typeId: `${NOTE}@2`, createdBy: { subjectId: ALICE } }),
975
+ makeRecord({ typeId: `${NOTE}s@1`, createdBy: { subjectId: ALICE } }),
976
+ makeRecord({ permissions: [toAlice] }),
977
+ makeRecord({ permissions: [{ ...toAlice, label: 'write' }] }),
978
+ makeRecord({ permissions: [toMembers] }),
979
+ makeRecord({ permissions: [toAdmins] }),
980
+ makeRecord({ permissions: [toAlice, toMembers] }),
981
+ makeRecord({ permissions: [toAlice, { ...toAlice, label: 'write' }] }),
982
+ makeRecord({ permissions: [anyone, { ...toAlice, label: 'write' }] }),
983
+ makeRecord({ permissions: [toMembers, toAdmins] }),
984
+ ];
985
+ for (const record of records)
986
+ await adapter.createRecord(record);
987
+ return records;
988
+ };
989
+ // Far past any statement's parameter cap — 100 on Durable Object SQLite.
990
+ const manyFamilies = Array.from({ length: 150 }, (_, i) => `org.haverstack.conformance/t${i}`);
991
+ const manyGroups = Array.from({ length: 60 }, (_, i) => `conformance-group-${i}`);
992
+ const reaches = [
993
+ ['nothing constrained', {}],
994
+ ['every Record of a family', { within: { baseIds: [OTHER], permissions: [] } }],
995
+ [
996
+ 'the Records one subject authored in a family',
997
+ {
998
+ within: {
999
+ baseIds: [],
1000
+ authored: { subjectId: ALICE, baseIds: [NOTE] },
1001
+ permissions: [],
1002
+ },
1003
+ },
1004
+ ],
1005
+ ['Records carrying `anyone`', { within: { baseIds: [], permissions: [[anyone]] } }],
1006
+ [
1007
+ 'Records carrying a read naming an entity',
1008
+ { within: { baseIds: [], permissions: [[toAlice]] } },
1009
+ ],
1010
+ [
1011
+ 'Records carrying a read naming a group at one role',
1012
+ { within: { baseIds: [], permissions: [[toAdmins]] } },
1013
+ ],
1014
+ [
1015
+ 'every arm at once',
1016
+ {
1017
+ within: {
1018
+ baseIds: [OTHER],
1019
+ authored: { subjectId: ALICE, baseIds: [NOTE] },
1020
+ permissions: [[anyone], [toMembers]],
1021
+ },
1022
+ },
1023
+ ],
1024
+ ['a fence alone', { fence: [OTHER] }],
1025
+ [
1026
+ 'arms inside a fence',
1027
+ {
1028
+ fence: [NOTE],
1029
+ within: {
1030
+ baseIds: [OTHER],
1031
+ authored: { subjectId: ALICE, baseIds: [NOTE, OTHER] },
1032
+ permissions: [[anyone]],
1033
+ },
1034
+ },
1035
+ ],
1036
+ ['an empty fence', { fence: [], within: { baseIds: [NOTE], permissions: [[anyone]] } }],
1037
+ ['no arm at all', { within: { baseIds: [], permissions: [] } }],
1038
+ [
1039
+ 'Records carrying every element of a set',
1040
+ { within: { baseIds: [], permissions: [[toAlice, { ...toAlice, label: 'write' }]] } },
1041
+ ],
1042
+ [
1043
+ 'Records carrying one whole set of several',
1044
+ {
1045
+ within: {
1046
+ baseIds: [],
1047
+ permissions: [
1048
+ [{ ...toAlice, label: 'write' }, anyone],
1049
+ [toMembers, toAdmins],
1050
+ ],
1051
+ },
1052
+ },
1053
+ ],
1054
+ ['an empty set, which no Record carries', { within: { baseIds: [], permissions: [[]] } }],
1055
+ [
1056
+ 'a reach of any size',
1057
+ {
1058
+ fence: [NOTE, ...manyFamilies],
1059
+ within: {
1060
+ baseIds: manyFamilies,
1061
+ authored: { subjectId: ALICE, baseIds: [NOTE, ...manyFamilies] },
1062
+ permissions: [
1063
+ ...manyGroups.flatMap((groupId) => [
1064
+ [{ ...toMembers, grantee: { ...toMembers.grantee, groupId } }],
1065
+ [
1066
+ {
1067
+ ...toAdmins,
1068
+ label: 'write',
1069
+ grantee: { ...toAdmins.grantee, groupId },
1070
+ },
1071
+ anyone,
1072
+ ],
1073
+ ]),
1074
+ [toAlice],
1075
+ ],
1076
+ },
1077
+ },
1078
+ ],
1079
+ ];
1080
+ test.each(reaches)('returns exactly the Records in reach: %s', async (_name, reach) => {
1081
+ if (!adapter.queryReadable)
1082
+ return;
1083
+ const records = await populate();
1084
+ const result = await adapter.queryReadable(resolveQuery({ limit: 100 }), reach);
1085
+ const expected = records.filter((r) => inReach(r, reach)).map((r) => r.id);
1086
+ expect(result.records.map((r) => r.id).sort()).toEqual(expected.sort());
1087
+ expect(result.cursor).toBeNull();
1088
+ });
1089
+ test("a reach narrows the query's own filter rather than widening it", async () => {
1090
+ if (!adapter.queryReadable)
1091
+ return;
1092
+ const pinned = { kind: 'tag', label: 'pinned' };
1093
+ const tagged = makeRecord({ permissions: [anyone], associations: [pinned] });
1094
+ await adapter.createRecord(tagged);
1095
+ await adapter.createRecord(makeRecord({ permissions: [anyone] }));
1096
+ await adapter.createRecord(makeRecord({ associations: [pinned] }));
1097
+ const result = await adapter.queryReadable(resolveQuery({ filter: { tags: ['pinned'] } }), {
1098
+ within: { baseIds: [], permissions: [[anyone]] },
1099
+ });
1100
+ expect(result.records.map((r) => r.id)).toEqual([tagged.id]);
1101
+ });
1102
+ // Paging is decided among the Records in reach alone: every page is
1103
+ // full, and no cursor comes back once they are exhausted, however
1104
+ // many Records outside the reach remain.
1105
+ const pagesWithin = async (sort) => {
1106
+ if (!adapter.queryReadable)
1107
+ return;
1108
+ const reach = { within: { baseIds: [], permissions: [[anyone]] } };
1109
+ const inside = [];
1110
+ for (let i = 0; i < 9; i++) {
1111
+ const visible = i % 3 === 1;
1112
+ const record = makeRecord({
1113
+ content: { title: `t${i}`, priority: i },
1114
+ ...(visible && { permissions: [anyone] }),
1115
+ });
1116
+ await adapter.createRecord(record);
1117
+ if (visible)
1118
+ inside.push(record.id);
1119
+ }
1120
+ const seen = [];
1121
+ let cursor;
1122
+ do {
1123
+ const page = await adapter.queryReadable(resolveQuery({ limit: 1, cursor, sort }), reach);
1124
+ expect(page.records).toHaveLength(1);
1125
+ seen.push(page.records[0].id);
1126
+ cursor = page.cursor ?? undefined;
1127
+ } while (cursor);
1128
+ expect(seen.sort()).toEqual(inside.sort());
1129
+ };
1130
+ test('pages through the Records in reach alone, by a native field', () => pagesWithin({ field: 'createdAt', direction: 'asc' }));
1131
+ if (capabilities.sort.contentField) {
1132
+ test('pages through the Records in reach alone, by a content field', async () => {
1133
+ await adapter.saveType(conformanceType());
1134
+ await pagesWithin({ contentField: 'priority', direction: 'desc' });
1135
+ });
1136
+ }
1137
+ });
1138
+ // -----------------------------------------------------------------
1139
+ // Type families
1140
+ // -----------------------------------------------------------------
1141
+ describe('filter.baseId', () => {
1142
+ const NOTE = 'org.haverstack.conformance/note';
1143
+ const ids = async (filter) => (await adapter.queryRecords(resolveQuery({ filter }))).records.map((r) => r.id).sort();
1144
+ /** One record per TypeId, keyed by it. */
1145
+ const seed = async (typeIds) => {
1146
+ const seeded = {};
1147
+ for (const typeId of typeIds) {
1148
+ const record = makeRecord({ typeId });
1149
+ await adapter.createRecord(record);
1150
+ seeded[typeId] = record.id;
1151
+ }
1152
+ return seeded;
1153
+ };
1154
+ test('matches every version of the family and nothing that only shares its prefix', async () => {
1155
+ const r = await seed([`${NOTE}@1`, `${NOTE}@2`, `${NOTE}s@1`, `${NOTE}-x@1`, `${NOTE}@10`]);
1156
+ expect(await ids({ baseId: NOTE })).toEqual([r[`${NOTE}@1`], r[`${NOTE}@2`], r[`${NOTE}@10`]].sort());
1157
+ });
1158
+ test('matches any of several families', async () => {
1159
+ const r = await seed([`${NOTE}@1`, `${NOTE}s@1`, 'org.haverstack.conformance/other@1']);
1160
+ expect(await ids({ baseId: [NOTE, `${NOTE}s`] })).toEqual([r[`${NOTE}@1`], r[`${NOTE}s@1`]].sort());
1161
+ });
1162
+ test('intersects with filter.typeId', async () => {
1163
+ const r = await seed([`${NOTE}@1`, `${NOTE}@2`, `${NOTE}s@1`]);
1164
+ expect(await ids({ baseId: NOTE, typeId: [`${NOTE}@2`, `${NOTE}s@1`] })).toEqual([
1165
+ r[`${NOTE}@2`],
1166
+ ]);
1167
+ });
1168
+ test('an unknown family matches nothing', async () => {
1169
+ await seed([`${NOTE}@1`]);
1170
+ expect(await ids({ baseId: 'org.haverstack.conformance/none' })).toEqual([]);
704
1171
  });
705
1172
  });
706
1173
  // -----------------------------------------------------------------
@@ -713,7 +1180,7 @@ export function runRecordAdapterConformance(options) {
713
1180
  const decoy = makeRecord({ content: { title: 'b', priority: 2 } });
714
1181
  await adapter.createRecord(match);
715
1182
  await adapter.createRecord(decoy);
716
- const result = await adapter.queryRecords({ filter: { content: { priority: 1 } } });
1183
+ const result = await adapter.queryRecords(resolveQuery({ filter: { content: { priority: 1 } } }));
717
1184
  // The positive assertion first: `not.toContain` and `every` are
718
1185
  // both vacuously true on an empty result, so without this an
719
1186
  // adapter that declares filter.content and returns nothing at
@@ -729,7 +1196,7 @@ export function runRecordAdapterConformance(options) {
729
1196
  await adapter.createRecord(absent);
730
1197
  await adapter.createRecord(explicitNull);
731
1198
  await adapter.createRecord(present);
732
- const result = await adapter.queryRecords({ filter: { content: { priority: null } } });
1199
+ const result = await adapter.queryRecords(resolveQuery({ filter: { content: { priority: null } } }));
733
1200
  const ids = result.records.map((r) => r.id).sort();
734
1201
  expect(ids).toEqual([absent.id, explicitNull.id].sort());
735
1202
  expect(ids).not.toContain(present.id);
@@ -739,7 +1206,7 @@ export function runRecordAdapterConformance(options) {
739
1206
  const nested = makeRecord({ content: { title: 'y', priority: { high: true } } });
740
1207
  await adapter.createRecord(scalarMatch);
741
1208
  await adapter.createRecord(nested);
742
- const result = await adapter.queryRecords({ filter: { content: { priority: 1 } } });
1209
+ const result = await adapter.queryRecords(resolveQuery({ filter: { content: { priority: 1 } } }));
743
1210
  expect(result.records.map((r) => r.id)).toContain(scalarMatch.id);
744
1211
  expect(result.records.map((r) => r.id)).not.toContain(nested.id);
745
1212
  });
@@ -751,10 +1218,43 @@ export function runRecordAdapterConformance(options) {
751
1218
  await adapter.createRecord(present);
752
1219
  await adapter.createRecord(absent);
753
1220
  await adapter.createRecord(nullValue);
754
- const result = await adapter.queryRecords({ filter: { contentPresent: ['priority'] } });
1221
+ const result = await adapter.queryRecords(resolveQuery({ filter: { contentPresent: ['priority'] } }));
755
1222
  expect(result.records.map((r) => r.id)).toEqual([present.id]);
756
1223
  });
757
1224
  }
1225
+ // A value matches as `===` compares it: its JSON type as well as
1226
+ // its value. See docs/spec/data-model.md § Filter.
1227
+ test('a content filter value matches its own JSON type only', async () => {
1228
+ const bool = makeRecord({ content: { title: 'b', flag: true } });
1229
+ const num = makeRecord({ content: { title: 'n', flag: 1 } });
1230
+ const text = makeRecord({ content: { title: 't', flag: '1' } });
1231
+ for (const record of [bool, num, text])
1232
+ await adapter.createRecord(record);
1233
+ for (const [value, record] of [
1234
+ [true, bool],
1235
+ [1, num],
1236
+ ['1', text],
1237
+ ]) {
1238
+ const result = await adapter.queryRecords(resolveQuery({ filter: { content: { flag: value } } }));
1239
+ expect(result.records.map((r) => r.id)).toEqual([record.id]);
1240
+ }
1241
+ });
1242
+ if (capabilities.filter.contentPresent && capabilities.filter.content === 'path') {
1243
+ // Both are element-wise, so a path reaching a null and a value
1244
+ // satisfies each. See docs/spec/data-model.md § Filter.
1245
+ test('a multi-valued path satisfies both contentPresent and a null filter', async () => {
1246
+ const mixed = makeRecord({
1247
+ content: { title: 'x', tags: [null, 'a'], emails: [{ value: null }, { value: 1 }] },
1248
+ });
1249
+ await adapter.createRecord(mixed);
1250
+ for (const path of ['tags', 'emails.value']) {
1251
+ const present = await adapter.queryRecords(resolveQuery({ filter: { contentPresent: [path] } }));
1252
+ expect(present.records.map((r) => r.id)).toEqual([mixed.id]);
1253
+ const nulls = await adapter.queryRecords(resolveQuery({ filter: { content: { [path]: null } } }));
1254
+ expect(nulls.records.map((r) => r.id)).toEqual([mixed.id]);
1255
+ }
1256
+ });
1257
+ }
758
1258
  if (capabilities.filter.content === 'path') {
759
1259
  test('an array is spread element-wise when filtering a nested path', async () => {
760
1260
  const match = makeRecord({
@@ -765,9 +1265,9 @@ export function runRecordAdapterConformance(options) {
765
1265
  });
766
1266
  await adapter.createRecord(match);
767
1267
  await adapter.createRecord(noMatch);
768
- const result = await adapter.queryRecords({
1268
+ const result = await adapter.queryRecords(resolveQuery({
769
1269
  filter: { content: { 'emails.value': 'a@b.c' } },
770
- });
1270
+ }));
771
1271
  expect(result.records.map((r) => r.id)).toEqual([match.id]);
772
1272
  });
773
1273
  test('a nested path filter reaches a property inside a plain (non-array) object', async () => {
@@ -775,16 +1275,16 @@ export function runRecordAdapterConformance(options) {
775
1275
  const noMatch = makeRecord({ content: { title: 'y', address: { city: 'Lyon' } } });
776
1276
  await adapter.createRecord(match);
777
1277
  await adapter.createRecord(noMatch);
778
- const result = await adapter.queryRecords({
1278
+ const result = await adapter.queryRecords(resolveQuery({
779
1279
  filter: { content: { 'address.city': 'Berlin' } },
780
- });
1280
+ }));
781
1281
  expect(result.records.map((r) => r.id)).toEqual([match.id]);
782
1282
  });
783
1283
  test('a filter path descending through a scalar matches nothing, rather than throwing', async () => {
784
1284
  await adapter.createRecord(makeRecord({ content: { title: 'x', priority: 1 } }));
785
- const result = await adapter.queryRecords({
1285
+ const result = await adapter.queryRecords(resolveQuery({
786
1286
  filter: { content: { 'priority.nested': 'anything' } },
787
- });
1287
+ }));
788
1288
  expect(result.records).toEqual([]);
789
1289
  });
790
1290
  }
@@ -793,7 +1293,7 @@ export function runRecordAdapterConformance(options) {
793
1293
  // -----------------------------------------------------------------
794
1294
  // Content by type — capability-gated on filter.contentByType
795
1295
  // -----------------------------------------------------------------
796
- if (capabilities.filter.contentByType >= 2) {
1296
+ if (capabilities.filter.contentByType) {
797
1297
  describe('content by type', () => {
798
1298
  const OTHER = 'org.haverstack.conformance/other@1';
799
1299
  const THIRD = 'org.haverstack.conformance/third@1';
@@ -807,14 +1307,14 @@ export function runRecordAdapterConformance(options) {
807
1307
  ];
808
1308
  for (const record of [note, other, ...decoys])
809
1309
  await adapter.createRecord(record);
810
- const result = await adapter.queryRecords({
1310
+ const result = await adapter.queryRecords(resolveQuery({
811
1311
  filter: {
812
1312
  contentByType: {
813
1313
  [CONFORMANCE_TYPE_ID]: { content: { title: 'a' } },
814
1314
  [OTHER]: { content: { heading: 'a' } },
815
1315
  },
816
1316
  },
817
- });
1317
+ }));
818
1318
  expect(result.records.map((r) => r.id).sort()).toEqual([note.id, other.id].sort());
819
1319
  });
820
1320
  test('an arm naming nothing matches every record of its type', async () => {
@@ -822,11 +1322,11 @@ export function runRecordAdapterConformance(options) {
822
1322
  const decoy = makeRecord({ typeId: THIRD });
823
1323
  for (const record of [...notes, decoy])
824
1324
  await adapter.createRecord(record);
825
- const result = await adapter.queryRecords({
1325
+ const result = await adapter.queryRecords(resolveQuery({
826
1326
  filter: {
827
1327
  contentByType: { [CONFORMANCE_TYPE_ID]: {}, [OTHER]: { content: { a: 1 } } },
828
1328
  },
829
- });
1329
+ }));
830
1330
  expect(result.records.map((r) => r.id).sort()).toEqual(notes.map((r) => r.id).sort());
831
1331
  });
832
1332
  test('intersects with every other key of the filter', async () => {
@@ -835,12 +1335,12 @@ export function runRecordAdapterConformance(options) {
835
1335
  const child = makeRecord({ content: { title: 'a' }, parentId: parent.id });
836
1336
  await adapter.createRecord(kept);
837
1337
  await adapter.createRecord(child);
838
- const result = await adapter.queryRecords({
1338
+ const result = await adapter.queryRecords(resolveQuery({
839
1339
  filter: {
840
1340
  parentId: parent.id,
841
1341
  contentByType: { [CONFORMANCE_TYPE_ID]: { content: { title: 'a' } }, [OTHER]: {} },
842
1342
  },
843
- });
1343
+ }));
844
1344
  expect(result.records.map((r) => r.id)).toEqual([child.id]);
845
1345
  });
846
1346
  if (capabilities.filter.contentPresent) {
@@ -850,14 +1350,14 @@ export function runRecordAdapterConformance(options) {
850
1350
  const other = makeRecord({ typeId: OTHER, content: { priority: 1 } });
851
1351
  for (const record of [present, absent, other])
852
1352
  await adapter.createRecord(record);
853
- const result = await adapter.queryRecords({
1353
+ const result = await adapter.queryRecords(resolveQuery({
854
1354
  filter: {
855
1355
  contentByType: {
856
1356
  [CONFORMANCE_TYPE_ID]: { contentPresent: ['priority'] },
857
1357
  [OTHER]: { contentPresent: ['missing'] },
858
1358
  },
859
1359
  },
860
- });
1360
+ }));
861
1361
  expect(result.records.map((r) => r.id)).toEqual([present.id]);
862
1362
  });
863
1363
  }
@@ -866,7 +1366,7 @@ export function runRecordAdapterConformance(options) {
866
1366
  // -----------------------------------------------------------------
867
1367
  // Sort by content field per type — gated on sort.contentFieldByType
868
1368
  // -----------------------------------------------------------------
869
- if (capabilities.sort.contentFieldByType >= 3) {
1369
+ if (capabilities.sort.contentFieldByType) {
870
1370
  describe('sort by content field per type', () => {
871
1371
  const OTHER = 'org.haverstack.conformance/other@1';
872
1372
  const THIRD = 'org.haverstack.conformance/third@1';
@@ -899,9 +1399,9 @@ export function runRecordAdapterConformance(options) {
899
1399
  const titles = (records) => records.map((r) => r.content.title);
900
1400
  test.each(['asc', 'desc'])('orders each type by its own field or constant, %s, holding no value last', async (direction) => {
901
1401
  await seed();
902
- const result = await adapter.queryRecords({
1402
+ const result = await adapter.queryRecords(resolveQuery({
903
1403
  sort: { contentFieldByType: BY_TYPE, direction },
904
- });
1404
+ }));
905
1405
  const ordered = ['a', 'b', 'c', 'd', 'e'];
906
1406
  expect(titles(result.records)).toEqual([
907
1407
  ...(direction === 'asc' ? ordered : ordered.reverse()),
@@ -914,11 +1414,11 @@ export function runRecordAdapterConformance(options) {
914
1414
  const seen = [];
915
1415
  let cursor;
916
1416
  do {
917
- const page = await adapter.queryRecords({
1417
+ const page = await adapter.queryRecords(resolveQuery({
918
1418
  limit: 1,
919
1419
  cursor: cursor ?? undefined,
920
1420
  sort: { contentFieldByType: BY_TYPE, direction: 'asc' },
921
- });
1421
+ }));
922
1422
  seen.push(...titles(page.records));
923
1423
  cursor = page.cursor;
924
1424
  } while (cursor);
@@ -927,15 +1427,15 @@ export function runRecordAdapterConformance(options) {
927
1427
  if (capabilities.sort.contentField) {
928
1428
  test('a cursor minted under one field is refused under a map', async () => {
929
1429
  await seed();
930
- const minted = await adapter.queryRecords({
1430
+ const minted = await adapter.queryRecords(resolveQuery({
931
1431
  limit: 1,
932
1432
  sort: { contentField: 'priority', direction: 'asc' },
933
- });
934
- await expectStackErrorCode(adapter.queryRecords({
1433
+ }));
1434
+ await expectStackErrorCode(adapter.queryRecords(resolveQuery({
935
1435
  limit: 1,
936
1436
  cursor: minted.cursor,
937
1437
  sort: { contentFieldByType: BY_TYPE, direction: 'asc' },
938
- }), 'bad_request');
1438
+ })), 'bad_request');
939
1439
  });
940
1440
  }
941
1441
  });
@@ -948,7 +1448,7 @@ export function runRecordAdapterConformance(options) {
948
1448
  const a = await adapter.createRecord(makeRecord());
949
1449
  await new Promise((r) => setTimeout(r, 5));
950
1450
  const b = await adapter.createRecord(makeRecord());
951
- const result = await adapter.queryRecords({ sort: { field: 'createdAt', direction } });
1451
+ const result = await adapter.queryRecords(resolveQuery({ sort: { field: 'createdAt', direction } }));
952
1452
  const ids = result.records.map((r) => r.id);
953
1453
  expect(ids).toEqual(direction === 'asc' ? [a.id, b.id] : [b.id, a.id]);
954
1454
  });
@@ -959,9 +1459,9 @@ export function runRecordAdapterConformance(options) {
959
1459
  await adapter.saveType(conformanceType());
960
1460
  await adapter.createRecord(makeRecord({ content: { title: 'x', priority: 1 } }));
961
1461
  await adapter.createRecord(makeRecord({ content: { title: 'y', priority: 2 } }));
962
- const result = await adapter.queryRecords({
1462
+ const result = await adapter.queryRecords(resolveQuery({
963
1463
  sort: { contentField: 'priority', direction },
964
- });
1464
+ }));
965
1465
  const priorities = result.records.map((r) => r.content.priority);
966
1466
  expect(priorities).toEqual(direction === 'asc' ? [1, 2] : [2, 1]);
967
1467
  });
@@ -973,9 +1473,9 @@ export function runRecordAdapterConformance(options) {
973
1473
  await adapter.createRecord(makeRecord({ content: { title: 'x', priority: 3 } }));
974
1474
  await adapter.createRecord(makeRecord({ content: { title: 'y', priority: 1 } }));
975
1475
  await adapter.createRecord(makeRecord({ content: { title: 'z', priority: 2 } }));
976
- const result = await adapter.queryRecords({
1476
+ const result = await adapter.queryRecords(resolveQuery({
977
1477
  sort: { contentField: 'priority', direction: 'asc' },
978
- });
1478
+ }));
979
1479
  expect(result.records.map((r) => r.content.priority)).toEqual([
980
1480
  1, 2, 3,
981
1481
  ]);
@@ -990,9 +1490,9 @@ export function runRecordAdapterConformance(options) {
990
1490
  for (const status of ['want', 'finished', 'reading']) {
991
1491
  await adapter.createRecord(makeRecord({ content: { title: status, status } }));
992
1492
  }
993
- const result = await adapter.queryRecords({
1493
+ const result = await adapter.queryRecords(resolveQuery({
994
1494
  sort: { contentField: 'status', direction: 'asc' },
995
- });
1495
+ }));
996
1496
  expect(result.records.map((r) => r.content.status)).toEqual([
997
1497
  'finished',
998
1498
  'reading',
@@ -1011,7 +1511,7 @@ export function runRecordAdapterConformance(options) {
1011
1511
  const noMatch = makeRecord({ content: { title: 'something unrelated' } });
1012
1512
  await adapter.createRecord(match);
1013
1513
  await adapter.createRecord(noMatch);
1014
- const result = await adapter.queryRecords({ filter: { search: 'brown' } });
1514
+ const result = await adapter.queryRecords(resolveQuery({ filter: { search: 'brown' } }));
1015
1515
  expect(result.records.map((r) => r.id)).toContain(match.id);
1016
1516
  expect(result.records.map((r) => r.id)).not.toContain(noMatch.id);
1017
1517
  });
@@ -1022,17 +1522,17 @@ export function runRecordAdapterConformance(options) {
1022
1522
  test('a content patch is reflected in search — the old content is no longer searchable', async () => {
1023
1523
  const record = makeRecord({ content: { title: 'original wording' } });
1024
1524
  await adapter.createRecord(record);
1025
- await adapter.mutateRecord(record.id, { contentPatch: { title: 'updated wording' } });
1026
- const stale = await adapter.queryRecords({ filter: { search: 'original' } });
1525
+ await adapter.patchContent(record.id, { title: 'updated wording' });
1526
+ const stale = await adapter.queryRecords(resolveQuery({ filter: { search: 'original' } }));
1027
1527
  expect(stale.records.map((r) => r.id)).not.toContain(record.id);
1028
- const fresh = await adapter.queryRecords({ filter: { search: 'updated' } });
1528
+ const fresh = await adapter.queryRecords(resolveQuery({ filter: { search: 'updated' } }));
1029
1529
  expect(fresh.records.map((r) => r.id)).toContain(record.id);
1030
1530
  });
1031
1531
  test('a purged record is no longer found by search', async () => {
1032
1532
  const record = makeRecord({ content: { title: 'ephemeral content' } });
1033
1533
  await adapter.createRecord(record);
1034
1534
  await adapter.deleteRecord(record.id, { purge: true });
1035
- const result = await adapter.queryRecords({ filter: { search: 'ephemeral' } });
1535
+ const result = await adapter.queryRecords(resolveQuery({ filter: { search: 'ephemeral' } }));
1036
1536
  expect(result.records.map((r) => r.id)).not.toContain(record.id);
1037
1537
  });
1038
1538
  });