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