@haverstack/adapter-conformance 0.10.0 → 0.12.0

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