@haverstack/adapter-conformance 0.11.0 → 0.12.0

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