@warp-drive-mirror/json-api 5.8.0-alpha.30 → 5.8.0-alpha.34

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.
Files changed (37) hide show
  1. package/dist/unpkg/dev/declarations/-private/cache.d.ts +445 -0
  2. package/dist/unpkg/dev/declarations/-private/validate-document-fields.d.ts +3 -0
  3. package/dist/unpkg/dev/declarations/-private/validator/1.1/7.1_top-level-document-members.d.ts +1 -0
  4. package/dist/unpkg/dev/declarations/-private/validator/1.1/7.2_resource-objects.d.ts +1 -0
  5. package/dist/unpkg/dev/declarations/-private/validator/1.1/links.d.ts +1 -0
  6. package/dist/unpkg/dev/declarations/-private/validator/index.d.ts +4 -0
  7. package/dist/unpkg/dev/declarations/-private/validator/utils.d.ts +75 -0
  8. package/dist/unpkg/dev/declarations/index.d.ts +5 -0
  9. package/dist/unpkg/dev/index.js +3225 -0
  10. package/dist/unpkg/dev-deprecated/declarations/-private/cache.d.ts +445 -0
  11. package/dist/unpkg/dev-deprecated/declarations/-private/validate-document-fields.d.ts +3 -0
  12. package/dist/unpkg/dev-deprecated/declarations/-private/validator/1.1/7.1_top-level-document-members.d.ts +1 -0
  13. package/dist/unpkg/dev-deprecated/declarations/-private/validator/1.1/7.2_resource-objects.d.ts +1 -0
  14. package/dist/unpkg/dev-deprecated/declarations/-private/validator/1.1/links.d.ts +1 -0
  15. package/dist/unpkg/dev-deprecated/declarations/-private/validator/index.d.ts +4 -0
  16. package/dist/unpkg/dev-deprecated/declarations/-private/validator/utils.d.ts +75 -0
  17. package/dist/unpkg/dev-deprecated/declarations/index.d.ts +5 -0
  18. package/dist/unpkg/dev-deprecated/index.js +3225 -0
  19. package/dist/unpkg/prod/declarations/-private/cache.d.ts +445 -0
  20. package/dist/unpkg/prod/declarations/-private/validate-document-fields.d.ts +3 -0
  21. package/dist/unpkg/prod/declarations/-private/validator/1.1/7.1_top-level-document-members.d.ts +1 -0
  22. package/dist/unpkg/prod/declarations/-private/validator/1.1/7.2_resource-objects.d.ts +1 -0
  23. package/dist/unpkg/prod/declarations/-private/validator/1.1/links.d.ts +1 -0
  24. package/dist/unpkg/prod/declarations/-private/validator/index.d.ts +4 -0
  25. package/dist/unpkg/prod/declarations/-private/validator/utils.d.ts +75 -0
  26. package/dist/unpkg/prod/declarations/index.d.ts +5 -0
  27. package/dist/unpkg/prod/index.js +3225 -0
  28. package/dist/unpkg/prod-deprecated/declarations/-private/cache.d.ts +445 -0
  29. package/dist/unpkg/prod-deprecated/declarations/-private/validate-document-fields.d.ts +3 -0
  30. package/dist/unpkg/prod-deprecated/declarations/-private/validator/1.1/7.1_top-level-document-members.d.ts +1 -0
  31. package/dist/unpkg/prod-deprecated/declarations/-private/validator/1.1/7.2_resource-objects.d.ts +1 -0
  32. package/dist/unpkg/prod-deprecated/declarations/-private/validator/1.1/links.d.ts +1 -0
  33. package/dist/unpkg/prod-deprecated/declarations/-private/validator/index.d.ts +4 -0
  34. package/dist/unpkg/prod-deprecated/declarations/-private/validator/utils.d.ts +75 -0
  35. package/dist/unpkg/prod-deprecated/declarations/index.d.ts +5 -0
  36. package/dist/unpkg/prod-deprecated/index.js +3225 -0
  37. package/package.json +28 -4
@@ -0,0 +1,3225 @@
1
+ import { graphFor, peekGraph, isBelongsTo } from '@warp-drive-mirror/core/graph/-private';
2
+ import { logGroup, isResourceKey, assertPrivateCapabilities, isRequestKey } from '@warp-drive-mirror/core/store/-private';
3
+ import Fuse from 'fuse.js';
4
+ import jsonToAst from 'json-to-ast';
5
+ import { macroCondition, getGlobalConfig } from '@embroider/macros';
6
+
7
+ function validateDocumentFields(schema, jsonApiDoc) {
8
+ const {
9
+ data,
10
+ included
11
+ } = jsonApiDoc;
12
+ if (data === null) {
13
+ return;
14
+ }
15
+ if (typeof jsonApiDoc.data !== 'object') {
16
+ throw new Error(`Expected a resource object in the 'data' property in the document provided to the cache, but was ${typeof jsonApiDoc.data}`);
17
+ }
18
+ if (Array.isArray(data)) {
19
+ for (const resource of data) {
20
+ validateResourceFields(schema, resource, {
21
+ verifyIncluded: true,
22
+ included
23
+ });
24
+ }
25
+ } else {
26
+ validateResourceFields(schema, data, {
27
+ verifyIncluded: true,
28
+ included
29
+ });
30
+ }
31
+ if (included) {
32
+ for (const resource of included) {
33
+ validateResourceFields(schema, resource, {
34
+ verifyIncluded: false
35
+ });
36
+ }
37
+ }
38
+ }
39
+ function validateResourceFields(schema, resource, options) {
40
+ if (!resource.relationships) {
41
+ return;
42
+ }
43
+ const resourceType = resource.type;
44
+ const fields = schema.fields({
45
+ type: resource.type
46
+ });
47
+ for (const [type, relationshipDoc] of Object.entries(resource.relationships)) {
48
+ const field = fields.get(type);
49
+ if (!field) {
50
+ return;
51
+ }
52
+ switch (field.kind) {
53
+ case 'belongsTo':
54
+ {
55
+ if (field.options.linksMode) {
56
+ validateBelongsToLinksMode(resourceType, field, relationshipDoc, options);
57
+ }
58
+ break;
59
+ }
60
+ case 'hasMany':
61
+ {
62
+ if (field.options.linksMode) {
63
+ validateHasManyToLinksMode(resourceType, field);
64
+ }
65
+ break;
66
+ }
67
+ }
68
+ }
69
+ }
70
+ function validateBelongsToLinksMode(resourceType, field, relationshipDoc, options) {
71
+ if (field.options.async) {
72
+ throw new Error(`Cannot fetch ${resourceType}.${field.name} because the field is in linksMode but async is not yet supported`);
73
+ }
74
+ if (!relationshipDoc.links?.related) {
75
+ throw new Error(`Cannot fetch ${resourceType}.${field.name} because the field is in linksMode but the related link is missing`);
76
+ }
77
+ const relationshipData = relationshipDoc.data;
78
+ if (Array.isArray(relationshipData)) {
79
+ throw new Error(`Cannot fetch ${resourceType}.${field.name} because the relationship data for a belongsTo relationship is unexpectedly an array`);
80
+ }
81
+ // Explicitly allow `null`! Missing key or `undefined` are always invalid.
82
+ if (relationshipData === undefined) {
83
+ throw new Error(`Cannot fetch ${resourceType}.${field.name} because the field is in linksMode but the relationship data is undefined`);
84
+ }
85
+ if (relationshipData === null) {
86
+ return;
87
+ }
88
+ if (!options.verifyIncluded) {
89
+ return;
90
+ }
91
+ const includedDoc = options.included?.find(doc => doc.type === relationshipData.type && doc.id === relationshipData.id);
92
+ if (!includedDoc) {
93
+ throw new Error(`Cannot fetch ${resourceType}.${field.name} because the field is in linksMode but the related data is not included`);
94
+ }
95
+ }
96
+ function validateHasManyToLinksMode(resourceType, field, _relationshipDoc, _options) {
97
+ if (field.options.async) {
98
+ throw new Error(`Cannot fetch ${resourceType}.${field.name} because the field is in linksMode but async hasMany is not yet supported`);
99
+ }
100
+ }
101
+
102
+ function inspectType(obj) {
103
+ if (obj === null) {
104
+ return 'null';
105
+ }
106
+ if (Array.isArray(obj)) {
107
+ return 'array';
108
+ }
109
+ if (typeof obj === 'object') {
110
+ const proto = Object.getPrototypeOf(obj);
111
+ if (proto === null) {
112
+ return 'object';
113
+ }
114
+ if (proto === Object.prototype) {
115
+ return 'object';
116
+ }
117
+ return `object (${proto.constructor?.name})`;
118
+ }
119
+ if (typeof obj === 'function') {
120
+ return 'function';
121
+ }
122
+ if (typeof obj === 'string') {
123
+ return 'string';
124
+ }
125
+ if (typeof obj === 'number') {
126
+ return 'number';
127
+ }
128
+ if (typeof obj === 'boolean') {
129
+ return 'boolean';
130
+ }
131
+ if (typeof obj === 'symbol') {
132
+ return 'symbol';
133
+ }
134
+ if (typeof obj === 'bigint') {
135
+ return 'bigint';
136
+ }
137
+ if (typeof obj === 'undefined') {
138
+ return 'undefined';
139
+ }
140
+ return 'unknown';
141
+ }
142
+ function isSimpleObject(obj) {
143
+ if (obj === null) {
144
+ return false;
145
+ }
146
+ if (Array.isArray(obj)) {
147
+ return false;
148
+ }
149
+ if (typeof obj !== 'object') {
150
+ return false;
151
+ }
152
+ const proto = Object.getPrototypeOf(obj);
153
+ if (proto === null) {
154
+ return true;
155
+ }
156
+ if (proto === Object.prototype) {
157
+ return true;
158
+ }
159
+ return false;
160
+ }
161
+ const RELATIONSHIP_FIELD_KINDS = ['belongsTo', 'hasMany', 'resource', 'collection'];
162
+ class Reporter {
163
+ capabilities;
164
+ contextDocument;
165
+ errors = [];
166
+ ast;
167
+ jsonStr;
168
+
169
+ // TODO @runspired make this configurable to consuming apps before
170
+ // activating by default
171
+ strict = {
172
+ linkage: true,
173
+ unknownType: true,
174
+ unknownAttribute: true,
175
+ unknownRelationship: true
176
+ };
177
+ constructor(capabilities, doc) {
178
+ this.capabilities = capabilities;
179
+ this.contextDocument = doc;
180
+ this.jsonStr = JSON.stringify(doc.content, null, 2);
181
+ this.ast = jsonToAst(this.jsonStr, {
182
+ loc: true
183
+ });
184
+ }
185
+ searchTypes(type) {
186
+ if (!this._typeFilter) {
187
+ const allTypes = this.schema.resourceTypes();
188
+ this._typeFilter = new Fuse(allTypes);
189
+ }
190
+ const result = this._typeFilter.search(type);
191
+ return result;
192
+ }
193
+ _fieldFilters = new Map();
194
+ searchFields(type, field) {
195
+ if (!this._fieldFilters.has(type)) {
196
+ const allFields = this.schema.fields({
197
+ type
198
+ });
199
+ const allCacheFields = this.schema.cacheFields?.({
200
+ type
201
+ }) ?? allFields;
202
+ const attrs = Array.from(allCacheFields.values()).filter(isRemoteField).map(v => v.name);
203
+ this._fieldFilters.set(type, new Fuse(attrs));
204
+ }
205
+ const result = this._fieldFilters.get(type).search(field);
206
+ return result;
207
+ }
208
+ get schema() {
209
+ return this.capabilities.schema;
210
+ }
211
+ getLocation(path, kind) {
212
+ if (path.length === 0) {
213
+ return this.ast.loc;
214
+ }
215
+ let priorNode = this.ast;
216
+ let node = this.ast;
217
+ for (const segment of path) {
218
+ //
219
+ // handle array paths
220
+ //
221
+ if (typeof segment === 'number') {
222
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
223
+ if (!test) {
224
+ throw new Error(`Because the segment is a number, expected a node of type Array`);
225
+ }
226
+ })(node.type === 'Array') : {};
227
+ if (node.children && node.children[segment]) {
228
+ priorNode = node;
229
+ const childNode = node.children[segment];
230
+ if (childNode.type === 'Object' || childNode.type === 'Array') {
231
+ node = childNode;
232
+ } else {
233
+ // set to the closest node we can find
234
+ return node.loc;
235
+ }
236
+ } else {
237
+ // set to the closest node we can find
238
+ // as we had no children
239
+ return priorNode.loc;
240
+ }
241
+
242
+ //
243
+ // handle object paths
244
+ //
245
+ } else {
246
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
247
+ if (!test) {
248
+ throw new Error(`Because the segment is a string, expected a node of type Object`);
249
+ }
250
+ })(node.type === 'Object') : {};
251
+ const child = node.children.find(childCandidate => {
252
+ if (childCandidate.type === 'Property') {
253
+ return childCandidate.key.type === 'Identifier' && childCandidate.key.value === segment;
254
+ }
255
+ return false;
256
+ });
257
+ if (child) {
258
+ if (child.value.type === 'Object' || child.value.type === 'Array') {
259
+ priorNode = node;
260
+ node = child.value;
261
+ } else {
262
+ // set to the closest node we can find
263
+ return kind === 'key' ? child.key.loc : child.value.loc;
264
+ }
265
+ } else {
266
+ // set to the closest node we can find
267
+ return priorNode.loc;
268
+ }
269
+ }
270
+ }
271
+ return node.loc;
272
+ }
273
+ error(path, message, kind = 'key') {
274
+ const loc = this.getLocation(path, kind);
275
+ this.errors.push({
276
+ path,
277
+ message,
278
+ loc,
279
+ type: 'error',
280
+ kind
281
+ });
282
+ }
283
+ warn(path, message, kind = 'key') {
284
+ const loc = this.getLocation(path, kind);
285
+ this.errors.push({
286
+ path,
287
+ message,
288
+ loc,
289
+ type: 'warning',
290
+ kind
291
+ });
292
+ }
293
+ info(path, message, kind = 'key') {
294
+ const loc = this.getLocation(path, kind);
295
+ this.errors.push({
296
+ path,
297
+ message,
298
+ loc,
299
+ type: 'info',
300
+ kind
301
+ });
302
+ }
303
+ hasExtension(extensionName) {
304
+ return REGISTERED_EXTENSIONS.has(extensionName);
305
+ }
306
+ getExtension(extensionName) {
307
+ return REGISTERED_EXTENSIONS.get(extensionName);
308
+ }
309
+ report(colorize = true) {
310
+ const lines = this.jsonStr.split('\n');
311
+
312
+ // sort the errors by line, then by column, then by type
313
+ const {
314
+ errors
315
+ } = this;
316
+ if (!errors.length) {
317
+ return;
318
+ }
319
+ errors.sort((a, b) => {
320
+ return a.loc.end.line < b.loc.end.line ? -1 : a.loc.end.column < b.loc.end.column ? -1 : compareType(a.type, b.type);
321
+ });
322
+
323
+ // store the errors in a map by line
324
+ const errorMap = new Map();
325
+ for (const error of errors) {
326
+ const line = error.loc.end.line;
327
+ if (!errorMap.has(line)) {
328
+ errorMap.set(line, []);
329
+ }
330
+ errorMap.get(line).push(error);
331
+ }
332
+
333
+ // splice the errors into the lines
334
+ const errorLines = [];
335
+ const colors = [];
336
+ const counts = {
337
+ error: 0,
338
+ warning: 0,
339
+ info: 0
340
+ };
341
+ const LINE_SIZE = String(lines.length).length;
342
+ for (let i = 0; i < lines.length; i++) {
343
+ const line = lines[i];
344
+ errorLines.push(colorize ? `${String(i + 1).padEnd(LINE_SIZE, ' ')} \t%c${line}%c` : `${String(i + 1).padEnd(LINE_SIZE, ' ')} \t${line}`);
345
+ colors.push(`color: grey; background-color: transparent;`,
346
+ // first color sets color
347
+ `color: inherit; background-color: transparent;` // second color resets the color profile
348
+ );
349
+ if (errorMap.has(i + 1)) {
350
+ const errorsForLine = errorMap.get(i + 1);
351
+ for (const error of errorsForLine) {
352
+ counts[error.type]++;
353
+ const {
354
+ loc,
355
+ message
356
+ } = error;
357
+ const start = loc.end.line === loc.start.line ? loc.start.column - 1 : loc.end.column - 1;
358
+ const end = loc.end.column - 1;
359
+ const symbol = error.type === 'error' ? '❌' : error.type === 'warning' ? '⚠️' : 'ℹ️';
360
+ const errorLine = colorize ? `${''.padStart(LINE_SIZE, ' ') + symbol}\t${' '.repeat(start)}%c^${'~'.repeat(end - start)} %c//%c ${message}%c` : `${''.padStart(LINE_SIZE, ' ') + symbol}\t${' '.repeat(start)}^${'~'.repeat(end - start)} // ${message}`;
361
+ errorLines.push(errorLine);
362
+ colors.push(error.type === 'error' ? 'color: red;' : error.type === 'warning' ? 'color: orange;' : 'color: blue;', 'color: grey;', error.type === 'error' ? 'color: red;' : error.type === 'warning' ? 'color: orange;' : 'color: blue;', 'color: inherit; background-color: transparent;' // reset color
363
+ );
364
+ }
365
+ }
366
+ }
367
+ const contextStr = `${counts.error} errors and ${counts.warning} warnings found in the {json:api} document returned by ${this.contextDocument.request?.method} ${this.contextDocument.request?.url}`;
368
+ const errorString = contextStr + `\n\n` + errorLines.join('\n');
369
+
370
+ // eslint-disable-next-line no-console, @typescript-eslint/no-unused-expressions
371
+ colorize ? console.log(errorString, ...colors) : console.log(errorString);
372
+ if (macroCondition(getGlobalConfig().WarpDriveMirror.features.JSON_API_CACHE_VALIDATION_ERRORS)) {
373
+ if (counts.error > 0) {
374
+ throw new Error(contextStr);
375
+ }
376
+ }
377
+ }
378
+ }
379
+
380
+ // we always want to sort errors first, then warnings, then info
381
+ function compareType(a, b) {
382
+ if (a === b) {
383
+ return 0;
384
+ }
385
+ if (a === 'error') {
386
+ return -1;
387
+ }
388
+ if (b === 'error') {
389
+ return 1;
390
+ }
391
+ if (a === 'warning') {
392
+ return -1;
393
+ }
394
+ if (b === 'warning') {
395
+ return 1;
396
+ }
397
+ return 0;
398
+ }
399
+ const REGISTERED_EXTENSIONS = new Map();
400
+ function isMetaDocument(doc) {
401
+ return !(doc instanceof Error) && doc.content && !('data' in doc.content) && !('included' in doc.content) && 'meta' in doc.content;
402
+ }
403
+ function isErrorDocument(doc) {
404
+ return doc instanceof Error;
405
+ }
406
+ function isPushedDocument(doc) {
407
+ return !!doc && typeof doc === 'object' && 'content' in doc && !('request' in doc) && !('response' in doc);
408
+ }
409
+ function logPotentialMatches(matches, kind) {
410
+ if (matches.length === 0) {
411
+ return '';
412
+ }
413
+ if (matches.length === 1) {
414
+ return ` Did you mean this available ${kind} "${matches[0].item}"?`;
415
+ }
416
+ const potentialMatches = matches.map(match => match.item).join('", "');
417
+ return ` Did you mean one of these available ${kind}s: "${potentialMatches}"?`;
418
+ }
419
+ function isRemoteField(v) {
420
+ return !(v.kind === '@local' || v.kind === 'alias' || v.kind === 'derived');
421
+ }
422
+ function getRemoteField(fields, key) {
423
+ const field = fields.get(key);
424
+ if (!field) {
425
+ return undefined;
426
+ }
427
+ if (!isRemoteField(field)) {
428
+ return undefined;
429
+ }
430
+ return field;
431
+ }
432
+
433
+ const VALID_TOP_LEVEL_MEMBERS = ['data', 'included', 'meta', 'jsonapi', 'links'];
434
+
435
+ /**
436
+ * Reports issues which violate the JSON:API spec for top-level members.
437
+ *
438
+ * Version: 1.1
439
+ * Section: 7.1
440
+ * Link: https://jsonapi.org/format/#document-top-level
441
+ *
442
+ * @internal
443
+ */
444
+ function validateTopLevelDocumentMembers(reporter, doc) {
445
+ const keys = Object.keys(doc);
446
+ for (const key of keys) {
447
+ if (!VALID_TOP_LEVEL_MEMBERS.includes(key)) {
448
+ if (key.includes(':')) {
449
+ // TODO @runspired expose the API to enable folks to add validation for their own extensions
450
+ const extensionName = key.split(':')[0];
451
+ if (reporter.hasExtension(extensionName)) {
452
+ const extension = reporter.getExtension(extensionName);
453
+ extension(reporter, [key]);
454
+ } else {
455
+ reporter.warn([key], `Unrecognized extension ${extensionName}. The data provided by "${key}" will be ignored as it is not a valid {json:api} member`);
456
+ }
457
+ } else {
458
+ reporter.error([key], `Unrecognized top-level member. The data it provides is ignored as it is not a valid {json:api} member`);
459
+ }
460
+ }
461
+ }
462
+
463
+ // additional rules for top-level members
464
+ // ======================================
465
+
466
+ // 1. MUST have either `data`, `errors`, or `meta`
467
+ if (!('data' in doc) && !('errors' in doc) && !('meta' in doc)) {
468
+ reporter.error([], 'A {json:api} Document must contain one-of `data` `errors` or `meta`');
469
+ }
470
+
471
+ // 2. MUST NOT have both `data` and `errors`
472
+ if ('data' in doc && 'errors' in doc) {
473
+ reporter.error(['data'], 'A {json:api} Document MUST NOT contain both `data` and `errors` members');
474
+ }
475
+
476
+ // 3. MUST NOT have both `included` and `errors`
477
+ // while not explicitly stated in the spec, this is a logical extension of the above rule
478
+ // since `included` is only valid when `data` is present.
479
+ if ('included' in doc && 'errors' in doc) {
480
+ reporter.error(['included'], 'A {json:api} Document MUST NOT contain both `included` and `errors` members');
481
+ }
482
+
483
+ // 4. MUST NOT have `included` if `data` is not present
484
+ if ('included' in doc && !('data' in doc)) {
485
+ reporter.error(['included'], 'A {json:api} Document MUST NOT contain `included` if `data` is not present');
486
+ }
487
+
488
+ // 5. MUST NOT have `included` if `data` is null
489
+ // when strictly enforcing full linkage, we need to ensure that `included` is not present if `data` is null
490
+ // however, most APIs will ignore this rule for DELETE requests, so unless strict linkage is enabled, we will only warn
491
+ // about this issue.
492
+ if ('included' in doc && doc.data === null) {
493
+ const isMaybeDelete = reporter.contextDocument.request?.method?.toUpperCase() === 'DELETE' || reporter.contextDocument.request?.op === 'deleteRecord';
494
+ const method = !reporter.strict.linkage && isMaybeDelete ? 'warn' : 'error';
495
+ reporter[method](['included'], 'A {json:api} Document MUST NOT contain `included` if `data` is null');
496
+ }
497
+
498
+ // Simple Validation of Top-Level Members
499
+ // ==========================================
500
+ // 1. `data` MUST be a single resource object or an array of resource objects or `null`
501
+ if ('data' in doc) {
502
+ const dataMemberHasAppropriateForm = doc.data === null || Array.isArray(doc.data) || isSimpleObject(doc.data);
503
+ if (!dataMemberHasAppropriateForm) {
504
+ reporter.error(['data'], `The 'data' member MUST be a single resource object or an array of resource objects or null. Received data of type "${inspectType(doc.data)}"`);
505
+ }
506
+ }
507
+
508
+ // 2. `included` MUST be an array of resource objects
509
+ if ('included' in doc) {
510
+ if (!Array.isArray(doc.included)) {
511
+ reporter.error(['included'], `The 'included' member MUST be an array of resource objects. Received data of type "${inspectType(doc.included)}"`);
512
+ }
513
+ }
514
+
515
+ // 3. `meta` MUST be a simple object
516
+ if ('meta' in doc) {
517
+ if (!isSimpleObject(doc.meta)) {
518
+ reporter.error(['meta'], `The 'meta' member MUST be a simple object. Received data of type "${inspectType(doc.meta)}"`);
519
+ }
520
+ }
521
+
522
+ // 4. `jsonapi` MUST be a simple object
523
+ if ('jsonapi' in doc) {
524
+ if (!isSimpleObject(doc.jsonapi)) {
525
+ reporter.error(['jsonapi'], `The 'jsonapi' member MUST be a simple object. Received data of type "${inspectType(doc.jsonapi)}"`);
526
+ }
527
+ }
528
+
529
+ // 5. `links` MUST be a simple object
530
+ if ('links' in doc) {
531
+ if (!isSimpleObject(doc.links)) {
532
+ reporter.error(['links'], `The 'links' member MUST be a simple object. Received data of type "${inspectType(doc.links)}"`);
533
+ }
534
+ }
535
+
536
+ // 6. `errors` MUST be an array of error objects
537
+ if ('errors' in doc) {
538
+ if (!Array.isArray(doc.errors)) {
539
+ reporter.error(['errors'], `The 'errors' member MUST be an array of error objects. Received data of type "${inspectType(doc.errors)}"`);
540
+ }
541
+ }
542
+ }
543
+
544
+ const VALID_COLLECTION_LINKS = ['self', 'related', 'first', 'last', 'prev', 'next'];
545
+ const VALID_RESOURCE_RELATIONSHIP_LINKS = ['self', 'related'];
546
+ const VALID_RESOURCE_LINKS = ['self'];
547
+
548
+ /**
549
+ * Validates the links object in a top-level JSON API document or resource object
550
+ *
551
+ * Version: 1.1
552
+ *
553
+ * Section: 7.1 Top Level
554
+ * Link: https://jsonapi.org/format/#document-top-level
555
+ *
556
+ * Section: 7.2.3 Resource Objects
557
+ * Link: https://jsonapi.org/format/#document-resource-object-links
558
+ *
559
+ * Section: 7.2.2.2 Resource Relationships
560
+ * Link: https://jsonapi.org/format/#document-resource-object-relationships
561
+ *
562
+ * Section: 7.6 Document Links
563
+ * Link: https://jsonapi.org/format/#document-links
564
+ *
565
+ * @internal
566
+ */
567
+ function validateLinks(reporter, doc, type, path = ['links']) {
568
+ if (!('links' in doc)) {
569
+ return;
570
+ }
571
+ if (!isSimpleObject(doc.links)) {
572
+ // this is a violation but we report it when validating section 7.1
573
+ return;
574
+ }
575
+
576
+ // prettier-ignore
577
+ const VALID_TOP_LEVEL_LINKS = type === 'collection-document' || type === 'collection-relationship' ? VALID_COLLECTION_LINKS : type === 'resource-document' || type === 'resource-relationship' ? VALID_RESOURCE_RELATIONSHIP_LINKS : type === 'resource' ? VALID_RESOURCE_LINKS : [];
578
+ const links = doc.links;
579
+ const keys = Object.keys(links);
580
+ for (const key of keys) {
581
+ if (!VALID_TOP_LEVEL_LINKS.includes(key)) {
582
+ reporter.warn([...path, key], `Unrecognized top-level link. The data it provides may be ignored as it is not a valid {json:api} link for a ${type}`);
583
+ }
584
+ // links may be either a string or an object with an href property or null
585
+ if (links[key] === null) ; else if (typeof links[key] === 'string') {
586
+ if (links[key].length === 0) {
587
+ reporter.warn([...path, key], `Expected a non-empty string, but received an empty string`);
588
+ }
589
+ // valid, though we should potentially validate the URL here
590
+ } else if (isSimpleObject(links[key])) {
591
+ if ('href' in links[key]) {
592
+ const linksKeys = Object.keys(links[key]);
593
+ if (linksKeys.length > 1) {
594
+ reporter.warn([...path, key], `Expected the links object to only have an href property, but received unknown keys ${linksKeys.filter(k => k !== 'href').join(', ')}`);
595
+ }
596
+ if (typeof links[key].href !== 'string') {
597
+ reporter.error([...path, key, 'href'], `Expected a string value, but received ${inspectType(links[key].href)}`);
598
+ } else {
599
+ if (links[key].href.length === 0) {
600
+ reporter.warn([...path, key, 'href'], `Expected a non-empty string, but received an empty string`);
601
+ }
602
+ // valid, though we should potentially validate the URL here
603
+ }
604
+ } else {
605
+ const linksKeys = Object.keys(links[key]);
606
+ if (linksKeys.length > 0) {
607
+ reporter.error([...path, key], `Expected the links object to have an href property, but received only the unknown keys ${linksKeys.join(', ')}`);
608
+ } else {
609
+ reporter.error([...path, key], `Expected the links object to have an href property`);
610
+ }
611
+ }
612
+ } else {
613
+ // invalid
614
+ reporter.error([...path, key], `Expected a string, null, or an object with an href property for the link "${key}", but received ${inspectType(links[key])}`);
615
+ }
616
+ }
617
+ }
618
+
619
+ const SINGULAR_OPS = ['createRecord', 'updateRecord', 'deleteRecord', 'findRecord', 'queryRecord'];
620
+
621
+ /**
622
+ * Validates the resource objects in either the `data` or `included` members of
623
+ * JSON:API document.
624
+ *
625
+ * Version: 1.1
626
+ * Section: 7.2
627
+ * Link: https://jsonapi.org/format/#document-resource-objects
628
+ *
629
+ * @internal
630
+ */
631
+ function validateDocumentResources(reporter, doc) {
632
+ if ('data' in doc) {
633
+ // scan for common mistakes of single vs multiple resource objects
634
+ const op = reporter.contextDocument.request?.op;
635
+ if (op && SINGULAR_OPS.includes(op)) {
636
+ if (Array.isArray(doc.data)) {
637
+ reporter.error(['data'], `"${op}" requests expect a single resource object in the returned data, but received an array`);
638
+ }
639
+ }
640
+
641
+ // guard for a common mistake around deleteRecord
642
+ if (op === 'deleteRecord') {
643
+ if (doc.data !== null) {
644
+ reporter.warn(['data'], `"deleteRecord" requests expect the data member to be null, but received ${inspectType(doc.data)}. This can sometimes cause unexpected resurrection of the deleted record.`);
645
+ }
646
+ }
647
+ if (Array.isArray(doc.data)) {
648
+ doc.data.forEach((resource, index) => {
649
+ if (!isSimpleObject(resource)) {
650
+ reporter.error(['data', index], `Expected a resource object, but received ${inspectType(resource)}`);
651
+ } else {
652
+ validateResourceObject(reporter, resource, ['data', index]);
653
+ }
654
+ });
655
+ } else if (doc.data !== null) {
656
+ if (!isSimpleObject(doc.data)) {
657
+ reporter.error(['data'], `Expected a resource object, but received ${inspectType(doc.data)}`);
658
+ } else {
659
+ validateResourceObject(reporter, doc.data, ['data']);
660
+ }
661
+ }
662
+ }
663
+ if ('included' in doc && Array.isArray(doc.included)) {
664
+ doc.included.forEach((resource, index) => {
665
+ if (!isSimpleObject(resource)) {
666
+ reporter.error(['included', index], `Expected a resource object, but received ${inspectType(resource)}`);
667
+ } else {
668
+ validateResourceObject(reporter, resource, ['included', index]);
669
+ }
670
+ });
671
+ }
672
+ }
673
+ function validateResourceObject(reporter, resource, path) {
674
+ validateTopLevelResourceShape(reporter, resource, path);
675
+ }
676
+ const VALID_TOP_LEVEL_RESOURCE_KEYS = ['lid', 'id', 'type', 'attributes', 'relationships', 'meta', 'links'];
677
+ function validateTopLevelResourceShape(reporter, resource, path) {
678
+ // a resource MUST have a string type
679
+ if (!('type' in resource)) {
680
+ reporter.error([...path, 'type'], `Expected a ResourceObject to have a type property`);
681
+ } else if (typeof resource.type !== 'string') {
682
+ reporter.error([...path, 'type'], `Expected a string value for the type property, but received ${inspectType(resource.type)}`, 'value');
683
+ } else if (resource.type.length === 0) {
684
+ reporter.error([...path, 'type'], `Expected a non-empty string value for the type property, but received an empty string`, 'value');
685
+ } else if (!reporter.schema.hasResource({
686
+ type: resource.type
687
+ })) {
688
+ const method = reporter.strict.unknownType ? 'error' : 'warn';
689
+ const potentialTypes = reporter.searchTypes(resource.type);
690
+ reporter[method]([...path, 'type'], `Expected a schema to be available for the ResourceType "${resource.type}" but none was found.${logPotentialMatches(potentialTypes, 'ResourceType')}`, 'value');
691
+ }
692
+
693
+ // a resource MUST have a string ID
694
+ if (!('id' in resource)) {
695
+ reporter.error([...path, 'id'], `Expected a ResourceObject to have an id property`);
696
+ } else if (typeof resource.id !== 'string') {
697
+ reporter.error([...path, 'id'], `Expected a string value for the id property, but received ${inspectType(resource.id)}`, 'value');
698
+ } else if (resource.id.length === 0) {
699
+ reporter.error([...path, 'id'], `Expected a non-empty string value for the id property, but received an empty string`, 'value');
700
+ }
701
+
702
+ // a resource MAY have a lid property
703
+ if ('lid' in resource && typeof resource.lid !== 'string') {
704
+ reporter.error([...path, 'lid'], `Expected a string value for the lid property, but received ${inspectType(resource.lid)}`, 'value');
705
+ }
706
+
707
+ // a resource MAY have a meta property
708
+ if ('meta' in resource && !isSimpleObject(resource.meta)) {
709
+ reporter.error([...path, 'meta'], `Expected a simple object for the meta property, but received ${inspectType(resource.meta)}`, 'value');
710
+ }
711
+
712
+ // a resource MAY have a links property
713
+ if ('links' in resource && !isSimpleObject(resource.links)) {
714
+ reporter.error([...path, 'links'], `Expected a simple object for the links property, but received ${inspectType(resource.links)}`, 'value');
715
+ } else if ('links' in resource) {
716
+ validateLinks(reporter, resource, 'resource', [...path, 'links']);
717
+ }
718
+ const hasAttributes = 'attributes' in resource && isSimpleObject(resource.attributes);
719
+ const hasRelationships = 'relationships' in resource && isSimpleObject(resource.relationships);
720
+
721
+ // We expect at least one of attributes or relationships to be present
722
+ if (!hasAttributes && !hasRelationships) {
723
+ reporter.warn(path, `Expected a ResourceObject to have either attributes or relationships`);
724
+ }
725
+
726
+ // we expect at least one of attributes or relationships to be non-empty
727
+ const attributesLength = hasAttributes ? Object.keys(resource.attributes).length : 0;
728
+ const relationshipsLength = hasRelationships ? Object.keys(resource.relationships).length : 0;
729
+ if ((hasAttributes || hasRelationships) && attributesLength === 0 && relationshipsLength === 0) {
730
+ reporter.warn([...path, hasAttributes ? 'attributes' : hasRelationships ? 'relationships' : 'attributes'], `Expected a ResourceObject to have either non-empty attributes or non-empty relationships`);
731
+ }
732
+
733
+ // check for unknown keys on the resource object
734
+ const keys = Object.keys(resource);
735
+ for (const key of keys) {
736
+ if (!VALID_TOP_LEVEL_RESOURCE_KEYS.includes(key)) {
737
+ // check for extension keys
738
+ if (key.includes(':')) {
739
+ const extensionName = key.split(':')[0];
740
+ if (reporter.hasExtension(extensionName)) {
741
+ const extension = reporter.getExtension(extensionName);
742
+ extension(reporter, [...path, key]);
743
+ } else {
744
+ reporter.warn([...path, key], `Unrecognized extension ${extensionName}. The data provided by "${key}" will be ignored as it is not a valid {json:api} ResourceObject member`);
745
+ }
746
+ } else {
747
+ // check if this is an attribute or relationship
748
+ let didYouMean = ' Likely this field should have been inside of either "attributes" or "relationships"';
749
+ const type = 'type' in resource ? resource.type : undefined;
750
+ if (type && reporter.schema.hasResource({
751
+ type
752
+ })) {
753
+ const fields = reporter.schema.fields({
754
+ type
755
+ });
756
+ const field = getRemoteField(fields, key);
757
+ if (field) {
758
+ const isRelationship = RELATIONSHIP_FIELD_KINDS.includes(field.kind);
759
+ didYouMean = ` Based on the ResourceSchema for "${type}" this field is likely a ${field.kind} and belongs inside of ${isRelationship ? 'relationships' : 'attributes'}, e.g. "${isRelationship ? 'relationships' : 'attributes'}": { "${key}": { ... } }`;
760
+ } else {
761
+ const fieldMatches = reporter.searchFields(type, key);
762
+ if (fieldMatches.length === 1) {
763
+ const matchedField = fields.get(fieldMatches[0].item);
764
+ const isRelationship = RELATIONSHIP_FIELD_KINDS.includes(matchedField.kind);
765
+ didYouMean = ` Based on the ResourceSchema for "${type}" this field is likely a ${matchedField.kind} and belongs inside of ${isRelationship ? 'relationships' : 'attributes'}, e.g. "${isRelationship ? 'relationships' : 'attributes'}": { "${matchedField.name}": { ... } }`;
766
+ } else if (fieldMatches.length > 1) {
767
+ const matchedField = fields.get(fieldMatches[0].item);
768
+ const isRelationship = RELATIONSHIP_FIELD_KINDS.includes(matchedField.kind);
769
+ didYouMean = ` Based on the ResourceSchema for "${type}" this field is likely one of "${fieldMatches.map(v => v.item).join('", "')}" and belongs inside of either "attributes" or "relationships", e.g. "${isRelationship ? 'relationships' : 'attributes'}": { "${matchedField.name}": { ... } }`;
770
+ }
771
+ }
772
+ }
773
+ reporter.error([...path, key], `Unrecognized ResourceObject member. The data it provides is ignored as it is not a valid {json:api} ResourceObject member.${didYouMean}`);
774
+ }
775
+ }
776
+ }
777
+
778
+ // if we have a schema, validate the individual attributes and relationships
779
+ const type = 'type' in resource ? resource.type : undefined;
780
+ if (type && reporter.schema.hasResource({
781
+ type
782
+ })) {
783
+ if ('attributes' in resource) {
784
+ validateResourceAttributes(reporter, type, resource.attributes, [...path, 'attributes']);
785
+ }
786
+ if ('relationships' in resource) {
787
+ validateResourceRelationships(reporter, type, resource.relationships, [...path, 'relationships']);
788
+ }
789
+ }
790
+ }
791
+ function validateResourceAttributes(reporter, type, resource, path) {
792
+ const fields = reporter.schema.fields({
793
+ type
794
+ });
795
+ const cacheFields = reporter.schema.cacheFields?.({
796
+ type
797
+ }) ?? fields;
798
+ for (const [key] of Object.entries(resource)) {
799
+ const field = getRemoteField(cacheFields, key);
800
+ const actualField = cacheFields.get(key);
801
+ if (!field && actualField) {
802
+ reporter.warn([...path, key], `Expected the ${actualField.kind} field "${key}" to not have its own data in the ResourceObject's attributes. Likely this field should either not be returned in this payload or the field definition should be updated in the schema.`);
803
+ } else if (!field) {
804
+ if (key.includes(':')) {
805
+ const extensionName = key.split(':')[0];
806
+ if (reporter.hasExtension(extensionName)) {
807
+ const extension = reporter.getExtension(extensionName);
808
+ extension(reporter, [...path, key]);
809
+ } else {
810
+ reporter.warn([...path, key], `Unrecognized extension ${extensionName}. The data provided by "${key}" will be ignored as it is not a valid {json:api} ResourceObject member`);
811
+ }
812
+ } else {
813
+ const method = reporter.strict.unknownAttribute ? 'error' : 'warn';
814
+
815
+ // TODO @runspired when we check for fuzzy matches we can adjust the message to say
816
+ // whether the expected field is an attribute or a relationship
817
+ const potentialFields = reporter.searchFields(type, key);
818
+ reporter[method]([...path, key], `Unrecognized attribute. The data it provides is ignored as it is not part of the ResourceSchema for "${type}".${logPotentialMatches(potentialFields, 'field')}`);
819
+ }
820
+ } else if (field && RELATIONSHIP_FIELD_KINDS.includes(field.kind)) {
821
+ reporter.error([...path, key], `Expected the "${key}" field to be in "relationships" as it has kind "${field.kind}", but received data for it in "attributes".`);
822
+ }
823
+ }
824
+
825
+ // TODO @runspired we should also deep-validate the field value
826
+ // TODO @runspired we should validate that field values are valid JSON and not instances
827
+ }
828
+ function validateResourceRelationships(reporter, type, resource, path) {
829
+ const schema = reporter.schema.fields({
830
+ type
831
+ });
832
+ for (const [key] of Object.entries(resource)) {
833
+ const field = getRemoteField(schema, key);
834
+ const actualField = schema.get(key);
835
+ if (!field && actualField) {
836
+ reporter.warn([...path, key], `Expected the ${actualField.kind} field "${key}" to not have its own data in the ResourceObject's relationships. Likely this field should either not be returned in this payload or the field definition should be updated in the schema.`);
837
+ } else if (!field) {
838
+ if (key.includes(':')) {
839
+ const extensionName = key.split(':')[0];
840
+ if (reporter.hasExtension(extensionName)) {
841
+ const extension = reporter.getExtension(extensionName);
842
+ extension(reporter, [...path, key]);
843
+ } else {
844
+ reporter.warn([...path, key], `Unrecognized extension ${extensionName}. The data provided by "${key}" will be ignored as it is not a valid {json:api} ResourceObject member`);
845
+ }
846
+ } else {
847
+ const method = reporter.strict.unknownRelationship ? 'error' : 'warn';
848
+
849
+ // TODO @runspired when we check for fuzzy matches we can adjust the message to say
850
+ // whether the expected field is an attribute or a relationship
851
+ const potentialFields = reporter.searchFields(type, key);
852
+ reporter[method]([...path, key], `Unrecognized relationship. The data it provides is ignored as it is not part of the ResourceSchema for "${type}".${logPotentialMatches(potentialFields, 'field')}`);
853
+ }
854
+ } else if (field && !RELATIONSHIP_FIELD_KINDS.includes(field.kind)) {
855
+ reporter.error([...path, key], `Expected the "${key}" field to be in "attributes" as it has kind "${field.kind}", but received data for it in "relationships".`);
856
+ }
857
+ }
858
+
859
+ // TODO @runspired we should also deep-validate the relationship payload
860
+ // TODO @runspired we should validate linksMode requirements for both Polaris and Legacy modes
861
+ // TODO @runspired we should warn if the discovered resource-type in a relationship is the abstract
862
+ // type instead of the concrete type.
863
+ }
864
+
865
+ function validateDocument(capabilities, doc) {
866
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
867
+ if (!test) {
868
+ throw new Error(`Expected a JSON:API Document as the content provided to the cache, received ${typeof doc.content}`);
869
+ }
870
+ })(doc instanceof Error || typeof doc.content === 'object' && doc.content !== null) : {};
871
+
872
+ // if the feature is not active and the payloads are not being logged
873
+ // we don't need to validate the payloads
874
+ if (macroCondition(!getGlobalConfig().WarpDriveMirror.features.JSON_API_CACHE_VALIDATION_ERRORS)) {
875
+ if (macroCondition(!getGlobalConfig().WarpDriveMirror.activeLogging.LOG_CACHE)) {
876
+ if (!(getGlobalConfig().WarpDriveMirror.debug.LOG_CACHE || globalThis.getWarpDriveRuntimeConfig().debug.LOG_CACHE)) {
877
+ return;
878
+ }
879
+ }
880
+ }
881
+ if (macroCondition(!getGlobalConfig().WarpDriveMirror.activeLogging.LOG_CACHE)) {
882
+ if (!(getGlobalConfig().WarpDriveMirror.debug.LOG_CACHE || globalThis.getWarpDriveRuntimeConfig().debug.LOG_CACHE)) {
883
+ if (macroCondition(!getGlobalConfig().WarpDriveMirror.features.JSON_API_CACHE_VALIDATION_ERRORS)) {
884
+ return;
885
+ }
886
+ }
887
+ }
888
+ if (isErrorDocument(doc)) {
889
+ return; // return validateErrorDocument(reporter, doc);
890
+ } else if (isMetaDocument(doc)) {
891
+ return; // return validateMetaDocument(reporter, doc);
892
+ } else if (isPushedDocument(doc)) {
893
+ return; // return validatePushedDocument(reporter, doc);
894
+ }
895
+ const reporter = new Reporter(capabilities, doc);
896
+ return validateResourceDocument(reporter, doc);
897
+ }
898
+
899
+ // function validateErrorDocument(reporter: Reporter, doc: StructuredErrorDocument) {}
900
+
901
+ // function validateMetaDocument(reporter: Reporter, doc: StructuredDataDocument<ResourceMetaDocument>) {}
902
+
903
+ // function validatePushedDocument(reporter: Reporter, doc: StructuredDataDocument<ResourceDocument>) {}
904
+
905
+ function validateResourceDocument(reporter, doc) {
906
+ validateTopLevelDocumentMembers(reporter, doc.content);
907
+ validateLinks(reporter, doc.content, 'data' in doc.content && Array.isArray(doc.content?.data) ? 'collection-document' : 'resource-document');
908
+ validateDocumentResources(reporter, doc.content);
909
+
910
+ // TODO @runspired - validateMeta on document
911
+ // TODO @runspired - validateMeta on resource
912
+ // TODO @runspired - validateMeta on resource relationships
913
+ // TODO @runspired - validate no-meta on resource identifiers
914
+ //
915
+ // ---------------------------------
916
+ // super-strict-mode
917
+ //
918
+ // TODO @runspired - validate that all referenced resource identifiers are present in the document (full linkage)
919
+ // TODO @runspired - validate that all included resources have a path back to `data` (full linkage)
920
+ //
921
+ // ---------------------------------
922
+ // nice-to-haves
923
+ //
924
+ // TODO @runspired - validate links objects more thoroughly for spec props we don't use
925
+ // TODO @runspired - validate request includes are in fact included
926
+ // TODO @runspired - validate request fields are in fact present
927
+ // TODO @runspired - MAYBE validate request sort is in fact sorted? (useful for catching Mocking bugs)
928
+ // TODO @runspired - MAYBE validate request pagination is in fact paginated? (useful for catching Mocking bugs)
929
+
930
+ reporter.report();
931
+ }
932
+
933
+ function isImplicit(relationship) {
934
+ return relationship.definition.isImplicit;
935
+ }
936
+ const EMPTY_ITERATOR = {
937
+ iterator() {
938
+ return {
939
+ next() {
940
+ return {
941
+ done: true,
942
+ value: undefined
943
+ };
944
+ }
945
+ };
946
+ }
947
+ };
948
+ function makeCache() {
949
+ return {
950
+ id: null,
951
+ remoteAttrs: null,
952
+ localAttrs: null,
953
+ defaultAttrs: null,
954
+ inflightAttrs: null,
955
+ changes: null,
956
+ errors: null,
957
+ isNew: false,
958
+ isDeleted: false,
959
+ isDeletionCommitted: false
960
+ };
961
+ }
962
+
963
+ /**
964
+ * ```ts
965
+ * import { JSONAPICache } from '@warp-drive-mirror/json-api';
966
+ * ```
967
+ *
968
+ * A {@link Cache} implementation tuned for [{json:api}](https://jsonapi.org/)
969
+ *
970
+ * @categoryDescription Cache Management
971
+ * APIs for primary cache management functionality
972
+ * @categoryDescription Cache Forking
973
+ * APIs that support Cache Forking
974
+ * @categoryDescription SSR Support
975
+ * APIs that support SSR functionality
976
+ * @categoryDescription Resource Lifecycle
977
+ * APIs that support management of resource data
978
+ * @categoryDescription Resource Data
979
+ * APIs that support granular field level management of resource data
980
+ * @categoryDescription Resource State
981
+ * APIs that support managing Resource states
982
+ *
983
+ * @public
984
+ */
985
+ class JSONAPICache {
986
+ /**
987
+ * The Cache Version that this implementation implements.
988
+ *
989
+ * @public
990
+ */
991
+
992
+ /** @internal */
993
+
994
+ /** @internal */
995
+
996
+ /** @internal */
997
+
998
+ /** @internal */
999
+
1000
+ /** @internal */
1001
+
1002
+ constructor(capabilities) {
1003
+ this.version = '2';
1004
+ this._capabilities = capabilities;
1005
+ this.__cache = new Map();
1006
+ this.__graph = graphFor(capabilities);
1007
+ this.__destroyedCache = new Map();
1008
+ this.__documents = new Map();
1009
+ }
1010
+
1011
+ ////////// ================ //////////
1012
+ ////////// Cache Management //////////
1013
+ ////////// ================ //////////
1014
+
1015
+ /**
1016
+ * Cache the response to a request
1017
+ *
1018
+ * Implements `Cache.put`.
1019
+ *
1020
+ * Expects a StructuredDocument whose `content` member is a JsonApiDocument.
1021
+ *
1022
+ * ```js
1023
+ * cache.put({
1024
+ * request: { url: 'https://api.example.com/v1/user/1' },
1025
+ * content: {
1026
+ * data: {
1027
+ * type: 'user',
1028
+ * id: '1',
1029
+ * attributes: {
1030
+ * name: 'Chris'
1031
+ * }
1032
+ * }
1033
+ * }
1034
+ * })
1035
+ * ```
1036
+ *
1037
+ * > **Note**
1038
+ * > The nested `content` and `data` members are not a mistake. This is because
1039
+ * > there are two separate concepts involved here, the `StructuredDocument` which contains
1040
+ * > the context of a given Request that has been issued with the returned contents as its
1041
+ * > `content` property, and a `JSON:API Document` which is the json contents returned by
1042
+ * > this endpoint and which uses its `data` property to signify which resources are the
1043
+ * > primary resources associated with the request.
1044
+ *
1045
+ * StructuredDocument's with urls will be cached as full documents with
1046
+ * associated resource membership order and contents preserved but linked
1047
+ * into the cache.
1048
+ *
1049
+ * @category Cache Management
1050
+ * @public
1051
+ */
1052
+
1053
+ put(doc) {
1054
+ if (macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG)) {
1055
+ validateDocument(this._capabilities, doc);
1056
+ }
1057
+ if (isErrorDocument(doc)) {
1058
+ return this._putDocument(doc, undefined, undefined);
1059
+ } else if (isMetaDocument(doc)) {
1060
+ return this._putDocument(doc, undefined, undefined);
1061
+ }
1062
+ const jsonApiDoc = doc.content;
1063
+ const included = jsonApiDoc.included;
1064
+ let i, length;
1065
+ const {
1066
+ cacheKeyManager
1067
+ } = this._capabilities;
1068
+ if (macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG)) {
1069
+ validateDocumentFields(this._capabilities.schema, jsonApiDoc);
1070
+ }
1071
+ if (macroCondition(getGlobalConfig().WarpDriveMirror.activeLogging.LOG_CACHE)) {
1072
+ if (getGlobalConfig().WarpDriveMirror.debug.LOG_CACHE || globalThis.getWarpDriveRuntimeConfig().debug.LOG_CACHE) {
1073
+ const Counts = new Map();
1074
+ let totalCount = 0;
1075
+ if (included) {
1076
+ for (i = 0, length = included.length; i < length; i++) {
1077
+ const type = included[i].type;
1078
+ Counts.set(type, (Counts.get(type) || 0) + 1);
1079
+ totalCount++;
1080
+ }
1081
+ }
1082
+ if (Array.isArray(jsonApiDoc.data)) {
1083
+ for (i = 0, length = jsonApiDoc.data.length; i < length; i++) {
1084
+ const type = jsonApiDoc.data[i].type;
1085
+ Counts.set(type, (Counts.get(type) || 0) + 1);
1086
+ totalCount++;
1087
+ }
1088
+ } else if (jsonApiDoc.data) {
1089
+ const type = jsonApiDoc.data.type;
1090
+ Counts.set(type, (Counts.get(type) || 0) + 1);
1091
+ totalCount++;
1092
+ }
1093
+ logGroup('cache', 'put', '<@document>', doc.content?.lid || doc.request?.url || 'unknown-request', `(${totalCount}) records`, '');
1094
+ let str = `\tContent Counts:`;
1095
+ Counts.forEach((count, type) => {
1096
+ str += `\n\t\t${type}: ${count} record${count > 1 ? 's' : ''}`;
1097
+ });
1098
+ if (Counts.size === 0) {
1099
+ str += `\t(empty)`;
1100
+ }
1101
+ // eslint-disable-next-line no-console
1102
+ console.log(str);
1103
+ // eslint-disable-next-line no-console
1104
+ console.log({
1105
+ lid: doc.content?.lid,
1106
+ content: structuredClone(doc.content),
1107
+ // we may need a specialized copy here
1108
+ request: doc.request,
1109
+ // structuredClone(doc.request),
1110
+ response: doc.response // structuredClone(doc.response),
1111
+ });
1112
+ // eslint-disable-next-line no-console
1113
+ console.groupEnd();
1114
+ }
1115
+ }
1116
+ if (included) {
1117
+ for (i = 0, length = included.length; i < length; i++) {
1118
+ included[i] = putOne(this, cacheKeyManager, included[i]);
1119
+ }
1120
+ }
1121
+ if (Array.isArray(jsonApiDoc.data)) {
1122
+ length = jsonApiDoc.data.length;
1123
+ const identifiers = [];
1124
+ for (i = 0; i < length; i++) {
1125
+ identifiers.push(putOne(this, cacheKeyManager, jsonApiDoc.data[i]));
1126
+ }
1127
+ return this._putDocument(doc, identifiers, included);
1128
+ }
1129
+ if (jsonApiDoc.data === null) {
1130
+ return this._putDocument(doc, null, included);
1131
+ }
1132
+ const identifier = putOne(this, cacheKeyManager, jsonApiDoc.data);
1133
+ return this._putDocument(doc, identifier, included);
1134
+ }
1135
+
1136
+ /** @internal */
1137
+
1138
+ /** @internal */
1139
+
1140
+ /** @internal */
1141
+
1142
+ /** @internal */
1143
+
1144
+ /** @internal */
1145
+ _putDocument(doc, data, included) {
1146
+ // @ts-expect-error narrowing within is just horrible in TS :/
1147
+ const resourceDocument = isErrorDocument(doc) ? fromStructuredError(doc) : fromBaseDocument(doc);
1148
+ if (data !== undefined) {
1149
+ resourceDocument.data = data;
1150
+ }
1151
+ if (included !== undefined) {
1152
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
1153
+ if (!test) {
1154
+ throw new Error(`There should not be included data on an Error document`);
1155
+ }
1156
+ })(!isErrorDocument(doc)) : {};
1157
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
1158
+ if (!test) {
1159
+ throw new Error(`There should not be included data on a Meta document`);
1160
+ }
1161
+ })(!isMetaDocument(doc)) : {};
1162
+ resourceDocument.included = included;
1163
+ }
1164
+ const request = doc.request;
1165
+ const identifier = request ? this._capabilities.cacheKeyManager.getOrCreateDocumentIdentifier(request) : null;
1166
+ if (identifier) {
1167
+ resourceDocument.lid = identifier.lid;
1168
+
1169
+ // @ts-expect-error
1170
+ doc.content = resourceDocument;
1171
+ const hasExisting = this.__documents.has(identifier.lid);
1172
+ this.__documents.set(identifier.lid, doc);
1173
+ this._capabilities.notifyChange(identifier, hasExisting ? 'updated' : 'added', null);
1174
+ }
1175
+ if (doc.request?.op === 'findHasMany') {
1176
+ const parentIdentifier = doc.request.options?.identifier;
1177
+ const parentField = doc.request.options?.field;
1178
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
1179
+ if (!test) {
1180
+ throw new Error(`Expected a hasMany field`);
1181
+ }
1182
+ })(parentField?.kind === 'hasMany') : {};
1183
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
1184
+ if (!test) {
1185
+ throw new Error(`Expected a parent identifier for a findHasMany request`);
1186
+ }
1187
+ })(parentIdentifier && isResourceKey(parentIdentifier)) : {};
1188
+ if (parentField && parentIdentifier) {
1189
+ this.__graph.push({
1190
+ op: 'updateRelationship',
1191
+ record: parentIdentifier,
1192
+ field: parentField.name,
1193
+ value: resourceDocument
1194
+ });
1195
+ }
1196
+ }
1197
+ return resourceDocument;
1198
+ }
1199
+
1200
+ /**
1201
+ * Update the "remote" or "canonical" (persisted) state of the Cache
1202
+ * by merging new information into the existing state.
1203
+ *
1204
+ * @category Cache Management
1205
+ * @public
1206
+ * @param op the operation or list of operations to perform
1207
+ */
1208
+ patch(op) {
1209
+ if (Array.isArray(op)) {
1210
+ if (macroCondition(getGlobalConfig().WarpDriveMirror.activeLogging.LOG_CACHE)) {
1211
+ if (getGlobalConfig().WarpDriveMirror.debug.LOG_CACHE || globalThis.getWarpDriveRuntimeConfig().debug.LOG_CACHE) {
1212
+ logGroup('cache', 'patch', '<BATCH>', String(op.length) + ' operations', '', '');
1213
+ }
1214
+ }
1215
+ assertPrivateCapabilities(this._capabilities);
1216
+ this._capabilities._store._join(() => {
1217
+ for (const operation of op) {
1218
+ patchCache(this, operation);
1219
+ }
1220
+ });
1221
+ if (macroCondition(getGlobalConfig().WarpDriveMirror.activeLogging.LOG_CACHE)) {
1222
+ if (getGlobalConfig().WarpDriveMirror.debug.LOG_CACHE || globalThis.getWarpDriveRuntimeConfig().debug.LOG_CACHE) {
1223
+ // eslint-disable-next-line no-console
1224
+ console.groupEnd();
1225
+ }
1226
+ }
1227
+ } else {
1228
+ patchCache(this, op);
1229
+ }
1230
+ }
1231
+
1232
+ /**
1233
+ * Update the "local" or "current" (unpersisted) state of the Cache
1234
+ *
1235
+ * @category Cache Management
1236
+ * @public
1237
+ */
1238
+ mutate(mutation) {
1239
+ if (macroCondition(getGlobalConfig().WarpDriveMirror.activeLogging.LOG_CACHE)) {
1240
+ if (getGlobalConfig().WarpDriveMirror.debug.LOG_CACHE || globalThis.getWarpDriveRuntimeConfig().debug.LOG_CACHE) {
1241
+ logGroup('cache', 'mutate', mutation.record.type, mutation.record.lid, mutation.field, mutation.op);
1242
+ try {
1243
+ const _data = JSON.parse(JSON.stringify(mutation));
1244
+ // eslint-disable-next-line no-console
1245
+ console.log(_data);
1246
+ } catch {
1247
+ // eslint-disable-next-line no-console
1248
+ console.log(mutation);
1249
+ }
1250
+ }
1251
+ }
1252
+ this.__graph.update(mutation, false);
1253
+ if (macroCondition(getGlobalConfig().WarpDriveMirror.activeLogging.LOG_CACHE)) {
1254
+ if (getGlobalConfig().WarpDriveMirror.debug.LOG_CACHE || globalThis.getWarpDriveRuntimeConfig().debug.LOG_CACHE) {
1255
+ // eslint-disable-next-line no-console
1256
+ console.groupEnd();
1257
+ }
1258
+ }
1259
+ }
1260
+
1261
+ /**
1262
+ * Peek resource data from the Cache.
1263
+ *
1264
+ * In development, if the return value
1265
+ * is JSON the return value
1266
+ * will be deep-cloned and deep-frozen
1267
+ * to prevent mutation thereby enforcing cache
1268
+ * Immutability.
1269
+ *
1270
+ * This form of peek is useful for implementations
1271
+ * that want to feed raw-data from cache to the UI
1272
+ * or which want to interact with a blob of data
1273
+ * directly from the presentation cache.
1274
+ *
1275
+ * An implementation might want to do this because
1276
+ * de-referencing records which read from their own
1277
+ * blob is generally safer because the record does
1278
+ * not require retainining connections to the Store
1279
+ * and Cache to present data on a per-field basis.
1280
+ *
1281
+ * This generally takes the place of `getAttr` as
1282
+ * an API and may even take the place of `getRelationship`
1283
+ * depending on implementation specifics, though this
1284
+ * latter usage is less recommended due to the advantages
1285
+ * of the Graph handling necessary entanglements and
1286
+ * notifications for relational data.
1287
+ *
1288
+ * @category Cache Management
1289
+ * @public
1290
+ */
1291
+
1292
+ peek(identifier) {
1293
+ if (isResourceKey(identifier)) {
1294
+ const peeked = this.__safePeek(identifier, false);
1295
+ if (!peeked) {
1296
+ return null;
1297
+ }
1298
+ const {
1299
+ type,
1300
+ id,
1301
+ lid
1302
+ } = identifier;
1303
+ const attributes = structuredClone(Object.assign({}, peeked.remoteAttrs, peeked.inflightAttrs, peeked.localAttrs));
1304
+ const relationships = {};
1305
+ const rels = this.__graph.identifiers.get(identifier);
1306
+ if (rels) {
1307
+ Object.keys(rels).forEach(key => {
1308
+ const rel = rels[key];
1309
+ if (rel.definition.isImplicit) {
1310
+ return;
1311
+ } else {
1312
+ relationships[key] = structuredClone(this.__graph.getData(identifier, key));
1313
+ }
1314
+ });
1315
+ }
1316
+ assertPrivateCapabilities(this._capabilities);
1317
+ const store = this._capabilities._store;
1318
+ const attrs = getCacheFields(this, identifier);
1319
+ attrs.forEach((attr, key) => {
1320
+ if (key in attributes && attributes[key] !== undefined) {
1321
+ return;
1322
+ }
1323
+ const defaultValue = getDefaultValue(attr, identifier, store);
1324
+ if (defaultValue !== undefined) {
1325
+ attributes[key] = defaultValue;
1326
+ }
1327
+ });
1328
+ return {
1329
+ type,
1330
+ id,
1331
+ lid,
1332
+ attributes,
1333
+ relationships
1334
+ };
1335
+ }
1336
+ const document = this.peekRequest(identifier);
1337
+ if (document) {
1338
+ if ('content' in document) return document.content;
1339
+ }
1340
+ return null;
1341
+ }
1342
+
1343
+ /**
1344
+ * Peek the remote resource data from the Cache.
1345
+ *
1346
+ * @category Cache Management
1347
+ * @public
1348
+ */
1349
+
1350
+ peekRemoteState(identifier) {
1351
+ if (isResourceKey(identifier)) {
1352
+ const peeked = this.__safePeek(identifier, false);
1353
+ if (!peeked) {
1354
+ return null;
1355
+ }
1356
+ const {
1357
+ type,
1358
+ id,
1359
+ lid
1360
+ } = identifier;
1361
+ const attributes = structuredClone(peeked.remoteAttrs);
1362
+ const relationships = {};
1363
+ const rels = this.__graph.identifiers.get(identifier);
1364
+ if (rels) {
1365
+ Object.keys(rels).forEach(key => {
1366
+ const rel = rels[key];
1367
+ if (rel.definition.isImplicit) {
1368
+ return;
1369
+ } else {
1370
+ relationships[key] = structuredClone(this.__graph.getData(identifier, key));
1371
+ }
1372
+ });
1373
+ }
1374
+ assertPrivateCapabilities(this._capabilities);
1375
+ const store = this._capabilities._store;
1376
+ const attrs = getCacheFields(this, identifier);
1377
+ attrs.forEach((attr, key) => {
1378
+ if (key in attributes && attributes[key] !== undefined) {
1379
+ return;
1380
+ }
1381
+ const defaultValue = getDefaultValue(attr, identifier, store);
1382
+ if (defaultValue !== undefined) {
1383
+ attributes[key] = defaultValue;
1384
+ }
1385
+ });
1386
+ return {
1387
+ type,
1388
+ id,
1389
+ lid,
1390
+ attributes,
1391
+ relationships
1392
+ };
1393
+ }
1394
+ const document = this.peekRequest(identifier);
1395
+ if (document) {
1396
+ if ('content' in document) return document.content;
1397
+ }
1398
+ return null;
1399
+ }
1400
+
1401
+ /**
1402
+ * Peek the Cache for the existing request data associated with
1403
+ * a cacheable request.
1404
+ *
1405
+ * This is effectively the reverse of `put` for a request in
1406
+ * that it will return the the request, response, and content
1407
+ * whereas `peek` will return just the `content`.
1408
+ *
1409
+ * @category Cache Management
1410
+ * @public
1411
+ */
1412
+ peekRequest(identifier) {
1413
+ return this.__documents.get(identifier.lid) || null;
1414
+ }
1415
+
1416
+ /**
1417
+ * Push resource data from a remote source into the cache for this identifier
1418
+ *
1419
+ * @category Cache Management
1420
+ * @public
1421
+ * @return if `calculateChanges` is true then calculated key changes should be returned
1422
+ */
1423
+ upsert(identifier, data, calculateChanges) {
1424
+ assertPrivateCapabilities(this._capabilities);
1425
+ const store = this._capabilities._store;
1426
+ if (!store._cbs) {
1427
+ let result = undefined;
1428
+ store._run(() => {
1429
+ result = cacheUpsert(this, identifier, data, calculateChanges);
1430
+ });
1431
+ return result;
1432
+ }
1433
+ return cacheUpsert(this, identifier, data, calculateChanges);
1434
+ }
1435
+
1436
+ ////////// ============= //////////
1437
+ ////////// Cache Forking //////////
1438
+ ////////// ============= //////////
1439
+
1440
+ /**
1441
+ * Create a fork of the cache from the current state.
1442
+ *
1443
+ * Applications should typically not call this method themselves,
1444
+ * preferring instead to fork at the Store level, which will
1445
+ * utilize this method to fork the cache.
1446
+ *
1447
+ * @category Cache Forking
1448
+ * @private
1449
+ */
1450
+ fork() {
1451
+ throw new Error(`Not Implemented`);
1452
+ }
1453
+
1454
+ /**
1455
+ * Merge a fork back into a parent Cache.
1456
+ *
1457
+ * Applications should typically not call this method themselves,
1458
+ * preferring instead to merge at the Store level, which will
1459
+ * utilize this method to merge the caches.
1460
+ *
1461
+ * @category Cache Forking
1462
+ * @private
1463
+ */
1464
+ merge(_cache) {
1465
+ throw new Error(`Not Implemented`);
1466
+ }
1467
+
1468
+ /**
1469
+ * Generate the list of changes applied to all
1470
+ * record in the store.
1471
+ *
1472
+ * Each individual resource or document that has
1473
+ * been mutated should be described as an individual
1474
+ * `Change` entry in the returned array.
1475
+ *
1476
+ * A `Change` is described by an object containing up to
1477
+ * three properties: (1) the `identifier` of the entity that
1478
+ * changed; (2) the `op` code of that change being one of
1479
+ * `upsert` or `remove`, and if the op is `upsert` a `patch`
1480
+ * containing the data to merge into the cache for the given
1481
+ * entity.
1482
+ *
1483
+ * This `patch` is opaque to the Store but should be understood
1484
+ * by the Cache and may expect to be utilized by an Adapter
1485
+ * when generating data during a `save` operation.
1486
+ *
1487
+ * It is generally recommended that the `patch` contain only
1488
+ * the updated state, ignoring fields that are unchanged
1489
+ *
1490
+ * ```ts
1491
+ * interface Change {
1492
+ * identifier: ResourceKey | RequestKey;
1493
+ * op: 'upsert' | 'remove';
1494
+ * patch?: unknown;
1495
+ * }
1496
+ * ```
1497
+ *
1498
+ * @category Cache Forking
1499
+ * @private
1500
+ */
1501
+ diff() {
1502
+ throw new Error(`Not Implemented`);
1503
+ }
1504
+
1505
+ ////////// =========== //////////
1506
+ ////////// SSR Support //////////
1507
+ ////////// =========== //////////
1508
+
1509
+ /**
1510
+ * Serialize the entire contents of the Cache into a Stream
1511
+ * which may be fed back into a new instance of the same Cache
1512
+ * via `cache.hydrate`.
1513
+ *
1514
+ * @category SSR Support
1515
+ * @private
1516
+ */
1517
+ dump() {
1518
+ throw new Error(`Not Implemented`);
1519
+ }
1520
+
1521
+ /**
1522
+ * hydrate a Cache from a Stream with content previously serialized
1523
+ * from another instance of the same Cache, resolving when hydration
1524
+ * is complete.
1525
+ *
1526
+ * This method should expect to be called both in the context of restoring
1527
+ * the Cache during application rehydration after SSR **AND** at unknown
1528
+ * times during the lifetime of an already booted application when it is
1529
+ * desired to bulk-load additional information into the cache. This latter
1530
+ * behavior supports optimizing pre/fetching of data for route transitions
1531
+ * via data-only SSR modes.
1532
+ *
1533
+ * @category SSR Support
1534
+ * @private
1535
+ */
1536
+ hydrate(stream) {
1537
+ throw new Error('Not Implemented');
1538
+ }
1539
+
1540
+ ////////// ================== //////////
1541
+ ////////// Resource Lifecycle //////////
1542
+ ////////// ================== //////////
1543
+
1544
+ /**
1545
+ * [LIFECYCLE] Signal to the cache that a new record has been instantiated on the client
1546
+ *
1547
+ * It returns properties from options that should be set on the record during the create
1548
+ * process. This return value behavior is deprecated.
1549
+ *
1550
+ * @category Resource Lifecycle
1551
+ * @public
1552
+ */
1553
+ clientDidCreate(identifier, options) {
1554
+ if (macroCondition(getGlobalConfig().WarpDriveMirror.activeLogging.LOG_CACHE)) {
1555
+ if (getGlobalConfig().WarpDriveMirror.debug.LOG_CACHE || globalThis.getWarpDriveRuntimeConfig().debug.LOG_CACHE) {
1556
+ try {
1557
+ const _data = options ? JSON.parse(JSON.stringify(options)) : options;
1558
+ // eslint-disable-next-line no-console
1559
+ console.log(`WarpDrive | Mutation - clientDidCreate ${identifier.lid}`, _data);
1560
+ } catch {
1561
+ // eslint-disable-next-line no-console
1562
+ console.log(`WarpDrive | Mutation - clientDidCreate ${identifier.lid}`, options);
1563
+ }
1564
+ }
1565
+ }
1566
+ const cached = this._createCache(identifier);
1567
+ cached.isNew = true;
1568
+ const createOptions = {};
1569
+ if (options !== undefined) {
1570
+ const fields = getCacheFields(this, identifier);
1571
+ const graph = this.__graph;
1572
+ const propertyNames = Object.keys(options);
1573
+ for (let i = 0; i < propertyNames.length; i++) {
1574
+ const name = propertyNames[i];
1575
+ const propertyValue = options[name];
1576
+ if (name === 'id') {
1577
+ continue;
1578
+ }
1579
+ const fieldType = fields.get(name);
1580
+ const kind = fieldType !== undefined ? 'kind' in fieldType ? fieldType.kind : 'attribute' : null;
1581
+ let relationship;
1582
+ switch (kind) {
1583
+ case 'attribute':
1584
+ this.setAttr(identifier, name, propertyValue);
1585
+ createOptions[name] = propertyValue;
1586
+ break;
1587
+ case 'belongsTo':
1588
+ this.mutate({
1589
+ op: 'replaceRelatedRecord',
1590
+ field: name,
1591
+ record: identifier,
1592
+ value: propertyValue
1593
+ });
1594
+ relationship = graph.get(identifier, name);
1595
+ relationship.state.hasReceivedData = true;
1596
+ relationship.state.isEmpty = false;
1597
+ break;
1598
+ case 'hasMany':
1599
+ this.mutate({
1600
+ op: 'replaceRelatedRecords',
1601
+ field: name,
1602
+ record: identifier,
1603
+ value: propertyValue
1604
+ });
1605
+ relationship = graph.get(identifier, name);
1606
+ relationship.state.hasReceivedData = true;
1607
+ relationship.state.isEmpty = false;
1608
+ break;
1609
+ default:
1610
+ // reflect back (pass-thru) unknown properties
1611
+ createOptions[name] = propertyValue;
1612
+ }
1613
+ }
1614
+ }
1615
+ this._capabilities.notifyChange(identifier, 'added', null);
1616
+ return createOptions;
1617
+ }
1618
+
1619
+ /**
1620
+ * [LIFECYCLE] Signals to the cache that a resource
1621
+ * will be part of a save transaction.
1622
+ *
1623
+ * @category Resource Lifecycle
1624
+ * @public
1625
+ */
1626
+ willCommit(identifier, _context) {
1627
+ if (Array.isArray(identifier)) {
1628
+ for (const key of identifier) {
1629
+ willCommit(this, key);
1630
+ }
1631
+ } else {
1632
+ willCommit(this, identifier);
1633
+ }
1634
+ }
1635
+
1636
+ /**
1637
+ * [LIFECYCLE] Signals to the cache that a resource
1638
+ * was successfully updated as part of a save transaction.
1639
+ *
1640
+ * @category Resource Lifecycle
1641
+ * @public
1642
+ */
1643
+
1644
+ didCommit(committedIdentifier, result) {
1645
+ const payload = result ? result.content : null;
1646
+ const operation = result?.request?.op ?? null;
1647
+ const data = payload && payload.data;
1648
+ if (macroCondition(getGlobalConfig().WarpDriveMirror.activeLogging.LOG_CACHE)) {
1649
+ if (getGlobalConfig().WarpDriveMirror.debug.LOG_CACHE || globalThis.getWarpDriveRuntimeConfig().debug.LOG_CACHE) {
1650
+ try {
1651
+ const payloadCopy = payload ? JSON.parse(JSON.stringify(payload)) : payload;
1652
+ // eslint-disable-next-line no-console
1653
+ console.log(`WarpDrive | Payload - ${operation}`, payloadCopy);
1654
+ } catch {
1655
+ // eslint-disable-next-line no-console
1656
+ console.log(`WarpDrive | Payload - ${operation}`, payload);
1657
+ }
1658
+ }
1659
+ }
1660
+ const responseIsCollection = Array.isArray(data);
1661
+ const hasMultipleIdentifiers = Array.isArray(committedIdentifier) && committedIdentifier.length > 1;
1662
+ if (Array.isArray(committedIdentifier)) {
1663
+ // if we get back an array of primary data, we treat each
1664
+ // entry as a separate commit for each identifier
1665
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
1666
+ if (!test) {
1667
+ throw new Error(`Expected the array of primary data to match the array of committed identifiers`);
1668
+ }
1669
+ })(!hasMultipleIdentifiers || !responseIsCollection || data.length === committedIdentifier.length) : {};
1670
+ if (responseIsCollection) {
1671
+ for (let i = 0; i < committedIdentifier.length; i++) {
1672
+ const identifier = committedIdentifier[i];
1673
+ didCommit(this, identifier, data[i] ?? null, operation);
1674
+ }
1675
+ // but if we get back no data or a single entry, we apply
1676
+ // the change back to the original identifier
1677
+ } else {
1678
+ for (let i = 0; i < committedIdentifier.length; i++) {
1679
+ const identifier = committedIdentifier[i];
1680
+ didCommit(this, identifier, i === 0 ? data : null, operation);
1681
+ }
1682
+ }
1683
+ } else {
1684
+ didCommit(this, committedIdentifier, data, operation);
1685
+ }
1686
+ const included = payload && payload.included;
1687
+ const {
1688
+ cacheKeyManager
1689
+ } = this._capabilities;
1690
+ if (included) {
1691
+ for (let i = 0, length = included.length; i < length; i++) {
1692
+ putOne(this, cacheKeyManager, included[i]);
1693
+ }
1694
+ }
1695
+ return hasMultipleIdentifiers && responseIsCollection ? {
1696
+ data: committedIdentifier
1697
+ } : {
1698
+ data: Array.isArray(committedIdentifier) ? committedIdentifier[0] : committedIdentifier
1699
+ };
1700
+ }
1701
+
1702
+ /**
1703
+ * [LIFECYCLE] Signals to the cache that a resource
1704
+ * was update via a save transaction failed.
1705
+ *
1706
+ * @category Resource Lifecycle
1707
+ * @public
1708
+ */
1709
+ commitWasRejected(identifier, errors) {
1710
+ if (Array.isArray(identifier)) {
1711
+ for (let i = 0; i < identifier.length; i++) {
1712
+ commitDidError(this, identifier[i], errors && i === 0 ? errors : null);
1713
+ }
1714
+ return;
1715
+ }
1716
+ return commitDidError(this, identifier, errors || null);
1717
+ }
1718
+
1719
+ /**
1720
+ * [LIFECYCLE] Signals to the cache that all data for a resource
1721
+ * should be cleared.
1722
+ *
1723
+ * This method is a candidate to become a mutation
1724
+ *
1725
+ * @category Resource Lifecycle
1726
+ * @public
1727
+ */
1728
+ unloadRecord(identifier) {
1729
+ const storeWrapper = this._capabilities;
1730
+ // TODO this is necessary because
1731
+ // we maintain memebership inside InstanceCache
1732
+ // for peekAll, so even though we haven't created
1733
+ // any data we think this exists.
1734
+ // TODO can we eliminate that membership now?
1735
+ if (!this.__cache.has(identifier)) {
1736
+ // the graph may still need to unload identity
1737
+ peekGraph(storeWrapper)?.unload(identifier);
1738
+ return;
1739
+ }
1740
+ const removeFromRecordArray = !this.isDeletionCommitted(identifier);
1741
+ let removed = false;
1742
+ const cached = this.__peek(identifier, false);
1743
+ if (cached.isNew || cached.isDeletionCommitted) {
1744
+ peekGraph(storeWrapper)?.push({
1745
+ op: 'deleteRecord',
1746
+ record: identifier,
1747
+ isNew: cached.isNew
1748
+ });
1749
+ } else {
1750
+ peekGraph(storeWrapper)?.unload(identifier);
1751
+ }
1752
+
1753
+ // effectively clearing these is ensuring that
1754
+ // we report as `isEmpty` during teardown.
1755
+ cached.localAttrs = null;
1756
+ cached.remoteAttrs = null;
1757
+ cached.defaultAttrs = null;
1758
+ cached.inflightAttrs = null;
1759
+ const relatedIdentifiers = _allRelatedIdentifiers(storeWrapper, identifier);
1760
+ if (areAllModelsUnloaded(storeWrapper, relatedIdentifiers)) {
1761
+ for (let i = 0; i < relatedIdentifiers.length; ++i) {
1762
+ const relatedIdentifier = relatedIdentifiers[i];
1763
+ storeWrapper.notifyChange(relatedIdentifier, 'removed', null);
1764
+ removed = true;
1765
+ storeWrapper.disconnectRecord(relatedIdentifier);
1766
+ }
1767
+ }
1768
+ this.__cache.delete(identifier);
1769
+ this.__destroyedCache.set(identifier, cached);
1770
+
1771
+ /*
1772
+ * The destroy cache is a hack to prevent applications
1773
+ * from blowing up during teardown. Accessing state
1774
+ * on a destroyed record is not safe, but historically
1775
+ * was possible due to a combination of teardown timing
1776
+ * and retention of cached state directly on the
1777
+ * record itself.
1778
+ *
1779
+ * Once we have deprecated accessing state on a destroyed
1780
+ * instance we may remove this. The timing isn't a huge deal
1781
+ * as momentarily retaining the objects outside the bounds
1782
+ * of a test won't cause issues.
1783
+ */
1784
+ if (this.__destroyedCache.size === 1) {
1785
+ // TODO do we still need this?
1786
+ setTimeout(() => {
1787
+ this.__destroyedCache.clear();
1788
+ }, 100);
1789
+ }
1790
+ if (!removed && removeFromRecordArray) {
1791
+ storeWrapper.notifyChange(identifier, 'removed', null);
1792
+ }
1793
+ }
1794
+
1795
+ ////////// ============= //////////
1796
+ ////////// Resource Data //////////
1797
+ ////////// ============= //////////
1798
+
1799
+ /**
1800
+ * Retrieve the data for an attribute from the cache
1801
+ * with local mutations applied.
1802
+ *
1803
+ * @category Resource Data
1804
+ * @public
1805
+ */
1806
+ getAttr(identifier, attr) {
1807
+ const isSimplePath = !Array.isArray(attr) || attr.length === 1;
1808
+ if (Array.isArray(attr) && attr.length === 1) {
1809
+ attr = attr[0];
1810
+ }
1811
+ if (isSimplePath) {
1812
+ const attribute = attr;
1813
+ const cached = this.__peek(identifier, true);
1814
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
1815
+ if (!test) {
1816
+ throw new Error(`Cannot retrieve attributes for identifier ${String(identifier)} as it is not present in the cache`);
1817
+ }
1818
+ })(cached) : {};
1819
+
1820
+ // in Prod we try to recover when accessing something that
1821
+ // doesn't exist
1822
+ if (!cached) {
1823
+ return undefined;
1824
+ }
1825
+ if (cached.localAttrs && attribute in cached.localAttrs) {
1826
+ return cached.localAttrs[attribute];
1827
+ } else if (cached.inflightAttrs && attribute in cached.inflightAttrs) {
1828
+ return cached.inflightAttrs[attribute];
1829
+ } else if (cached.remoteAttrs && attribute in cached.remoteAttrs) {
1830
+ return cached.remoteAttrs[attribute];
1831
+ } else if (cached.defaultAttrs && attribute in cached.defaultAttrs) {
1832
+ return cached.defaultAttrs[attribute];
1833
+ } else {
1834
+ const attrSchema = getCacheFields(this, identifier).get(attribute);
1835
+ assertPrivateCapabilities(this._capabilities);
1836
+ const defaultValue = getDefaultValue(attrSchema, identifier, this._capabilities._store);
1837
+ if (schemaHasLegacyDefaultValueFn(attrSchema)) {
1838
+ cached.defaultAttrs = cached.defaultAttrs || Object.create(null);
1839
+ cached.defaultAttrs[attribute] = defaultValue;
1840
+ }
1841
+ return defaultValue;
1842
+ }
1843
+ }
1844
+
1845
+ // TODO @runspired consider whether we need a defaultValue cache in ReactiveResource
1846
+ // like we do for the simple case above.
1847
+ const path = attr;
1848
+ const cached = this.__peek(identifier, true);
1849
+ const basePath = path[0];
1850
+ let current = cached.localAttrs && basePath in cached.localAttrs ? cached.localAttrs[basePath] : undefined;
1851
+ if (current === undefined) {
1852
+ current = cached.inflightAttrs && basePath in cached.inflightAttrs ? cached.inflightAttrs[basePath] : undefined;
1853
+ }
1854
+ if (current === undefined) {
1855
+ current = cached.remoteAttrs && basePath in cached.remoteAttrs ? cached.remoteAttrs[basePath] : undefined;
1856
+ }
1857
+ if (current === undefined) {
1858
+ return undefined;
1859
+ }
1860
+ for (let i = 1; i < path.length; i++) {
1861
+ current = current[path[i]];
1862
+ if (current === undefined) {
1863
+ return undefined;
1864
+ }
1865
+ }
1866
+ return current;
1867
+ }
1868
+
1869
+ /**
1870
+ * Retrieve the remote data for an attribute from the cache
1871
+ *
1872
+ * @category Resource Data
1873
+ * @public
1874
+ */
1875
+ getRemoteAttr(identifier, attr) {
1876
+ const isSimplePath = !Array.isArray(attr) || attr.length === 1;
1877
+ if (Array.isArray(attr) && attr.length === 1) {
1878
+ attr = attr[0];
1879
+ }
1880
+ if (isSimplePath) {
1881
+ const attribute = attr;
1882
+ const cached = this.__peek(identifier, true);
1883
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
1884
+ if (!test) {
1885
+ throw new Error(`Cannot retrieve remote attributes for identifier ${String(identifier)} as it is not present in the cache`);
1886
+ }
1887
+ })(cached) : {};
1888
+
1889
+ // in Prod we try to recover when accessing something that
1890
+ // doesn't exist
1891
+ if (!cached) {
1892
+ return undefined;
1893
+ }
1894
+ if (cached.remoteAttrs && attribute in cached.remoteAttrs) {
1895
+ return cached.remoteAttrs[attribute];
1896
+
1897
+ // we still show defaultValues in the case of a remoteAttr access
1898
+ } else if (cached.defaultAttrs && attribute in cached.defaultAttrs) {
1899
+ return cached.defaultAttrs[attribute];
1900
+ } else {
1901
+ const attrSchema = getCacheFields(this, identifier).get(attribute);
1902
+ assertPrivateCapabilities(this._capabilities);
1903
+ const defaultValue = getDefaultValue(attrSchema, identifier, this._capabilities._store);
1904
+ if (schemaHasLegacyDefaultValueFn(attrSchema)) {
1905
+ cached.defaultAttrs = cached.defaultAttrs || Object.create(null);
1906
+ cached.defaultAttrs[attribute] = defaultValue;
1907
+ }
1908
+ return defaultValue;
1909
+ }
1910
+ }
1911
+
1912
+ // TODO @runspired consider whether we need a defaultValue cache in ReactiveResource
1913
+ // like we do for the simple case above.
1914
+ const path = attr;
1915
+ const cached = this.__peek(identifier, true);
1916
+ const basePath = path[0];
1917
+ let current = cached.remoteAttrs && basePath in cached.remoteAttrs ? cached.remoteAttrs[basePath] : undefined;
1918
+ if (current === undefined) {
1919
+ return undefined;
1920
+ }
1921
+ for (let i = 1; i < path.length; i++) {
1922
+ current = current[path[i]];
1923
+ if (current === undefined) {
1924
+ return undefined;
1925
+ }
1926
+ }
1927
+ return current;
1928
+ }
1929
+
1930
+ /**
1931
+ * Mutate the data for an attribute in the cache
1932
+ *
1933
+ * This method is a candidate to become a mutation
1934
+ *
1935
+ * @category Resource Data
1936
+ * @public
1937
+ */
1938
+ setAttr(identifier, attr, value) {
1939
+ // this assert works to ensure we have a non-empty string and/or a non-empty array
1940
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
1941
+ if (!test) {
1942
+ throw new Error('setAttr must receive at least one attribute path');
1943
+ }
1944
+ })(attr.length > 0) : {};
1945
+ const isSimplePath = !Array.isArray(attr) || attr.length === 1;
1946
+ if (Array.isArray(attr) && attr.length === 1) {
1947
+ attr = attr[0];
1948
+ }
1949
+ if (isSimplePath) {
1950
+ const cached = this.__peek(identifier, false);
1951
+ const currentAttr = attr;
1952
+ const existing = cached.inflightAttrs && currentAttr in cached.inflightAttrs ? cached.inflightAttrs[currentAttr] : cached.remoteAttrs && currentAttr in cached.remoteAttrs ? cached.remoteAttrs[currentAttr] : undefined;
1953
+ if (existing !== value) {
1954
+ cached.localAttrs = cached.localAttrs || Object.create(null);
1955
+ cached.localAttrs[currentAttr] = value;
1956
+ cached.changes = cached.changes || Object.create(null);
1957
+ cached.changes[currentAttr] = [existing, value];
1958
+ } else if (cached.localAttrs) {
1959
+ delete cached.localAttrs[currentAttr];
1960
+ delete cached.changes[currentAttr];
1961
+ }
1962
+ if (cached.defaultAttrs && currentAttr in cached.defaultAttrs) {
1963
+ delete cached.defaultAttrs[currentAttr];
1964
+ }
1965
+ this._capabilities.notifyChange(identifier, 'attributes', currentAttr);
1966
+ return;
1967
+ }
1968
+
1969
+ // get current value from local else inflight else remote
1970
+ // structuredClone current if not local (or always?)
1971
+ // traverse path, update value at path
1972
+ // notify change at first link in path.
1973
+ // second pass optimization is change notifyChange signature to take an array path
1974
+
1975
+ // guaranteed that we have path of at least 2 in length
1976
+ const path = attr;
1977
+ const cached = this.__peek(identifier, false);
1978
+
1979
+ // get existing cache record for base path
1980
+ const basePath = path[0];
1981
+ const existing = cached.inflightAttrs && basePath in cached.inflightAttrs ? cached.inflightAttrs[basePath] : cached.remoteAttrs && basePath in cached.remoteAttrs ? cached.remoteAttrs[basePath] : undefined;
1982
+ let existingAttr;
1983
+ if (existing) {
1984
+ existingAttr = existing[path[1]];
1985
+ for (let i = 2; i < path.length; i++) {
1986
+ // the specific change we're making is at path[length - 1]
1987
+ existingAttr = existingAttr[path[i]];
1988
+ }
1989
+ }
1990
+ if (existingAttr !== value) {
1991
+ cached.localAttrs = cached.localAttrs || Object.create(null);
1992
+ cached.localAttrs[basePath] = cached.localAttrs[basePath] || structuredClone(existing);
1993
+ cached.changes = cached.changes || Object.create(null);
1994
+ let currentLocal = cached.localAttrs[basePath];
1995
+ let nextLink = 1;
1996
+ while (nextLink < path.length - 1) {
1997
+ currentLocal = currentLocal[path[nextLink++]];
1998
+ }
1999
+ currentLocal[path[nextLink]] = value;
2000
+ cached.changes[basePath] = [existing, cached.localAttrs[basePath]];
2001
+
2002
+ // since we initiaize the value as basePath as a clone of the value at the remote basePath
2003
+ // then in theory we can use JSON.stringify to compare the two values as key insertion order
2004
+ // ought to be consistent.
2005
+ // we try/catch this because users have a habit of doing "Bad Things"TM wherein the cache contains
2006
+ // stateful values that are not JSON serializable correctly such as Dates.
2007
+ // in the case that we error, we fallback to not removing the local value
2008
+ // so that any changes we don't understand are preserved. Thse objects would then sometimes
2009
+ // appear to be dirty unnecessarily, and for folks that open an issue we can guide them
2010
+ // to make their cache data less stateful.
2011
+ } else if (cached.localAttrs) {
2012
+ try {
2013
+ if (!existing) {
2014
+ return;
2015
+ }
2016
+ const existingStr = JSON.stringify(existing);
2017
+ const newStr = JSON.stringify(cached.localAttrs[basePath]);
2018
+ if (existingStr !== newStr) {
2019
+ delete cached.localAttrs[basePath];
2020
+ delete cached.changes[basePath];
2021
+ }
2022
+ } catch {
2023
+ // noop
2024
+ }
2025
+ }
2026
+ this._capabilities.notifyChange(identifier, 'attributes', basePath);
2027
+ }
2028
+
2029
+ /**
2030
+ * Query the cache for the changed attributes of a resource.
2031
+ *
2032
+ * @category Resource Data
2033
+ * @public
2034
+ * @return `{ '<field>': ['<old>', '<new>'] }`
2035
+ */
2036
+ changedAttrs(identifier) {
2037
+ const cached = this.__peek(identifier, false);
2038
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
2039
+ if (!test) {
2040
+ throw new Error(`Cannot retrieve changed attributes for identifier ${String(identifier)} as it is not present in the cache`);
2041
+ }
2042
+ })(cached) : {};
2043
+
2044
+ // in Prod we try to recover when accessing something that
2045
+ // doesn't exist
2046
+ if (!cached) {
2047
+ return Object.create(null);
2048
+ }
2049
+
2050
+ // TODO freeze in dev
2051
+ return cached.changes || Object.create(null);
2052
+ }
2053
+
2054
+ /**
2055
+ * Query the cache for whether any mutated attributes exist
2056
+ *
2057
+ * @category Resource Data
2058
+ * @public
2059
+ */
2060
+ hasChangedAttrs(identifier) {
2061
+ const cached = this.__peek(identifier, true);
2062
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
2063
+ if (!test) {
2064
+ throw new Error(`Cannot retrieve changed attributes for identifier ${String(identifier)} as it is not present in the cache`);
2065
+ }
2066
+ })(cached) : {};
2067
+
2068
+ // in Prod we try to recover when accessing something that
2069
+ // doesn't exist
2070
+ if (!cached) {
2071
+ return false;
2072
+ }
2073
+ return cached.inflightAttrs !== null && Object.keys(cached.inflightAttrs).length > 0 || cached.localAttrs !== null && Object.keys(cached.localAttrs).length > 0;
2074
+ }
2075
+
2076
+ /**
2077
+ * Tell the cache to discard any uncommitted mutations to attributes
2078
+ *
2079
+ * This method is a candidate to become a mutation
2080
+ *
2081
+ * @category Resource Data
2082
+ * @public
2083
+ * @return the names of fields that were restored
2084
+ */
2085
+ rollbackAttrs(identifier) {
2086
+ const cached = this.__peek(identifier, false);
2087
+ let dirtyKeys;
2088
+ cached.isDeleted = false;
2089
+ if (cached.localAttrs !== null) {
2090
+ dirtyKeys = Object.keys(cached.localAttrs);
2091
+ cached.localAttrs = null;
2092
+ cached.changes = null;
2093
+ }
2094
+ if (cached.isNew) {
2095
+ // > Note: Graph removal handled by unloadRecord
2096
+ cached.isDeletionCommitted = true;
2097
+ cached.isDeleted = true;
2098
+ cached.isNew = false;
2099
+ }
2100
+ cached.inflightAttrs = null;
2101
+ cached.defaultAttrs = null;
2102
+ if (cached.errors) {
2103
+ cached.errors = null;
2104
+ this._capabilities.notifyChange(identifier, 'errors', null);
2105
+ }
2106
+ this._capabilities.notifyChange(identifier, 'state', null);
2107
+ if (dirtyKeys && dirtyKeys.length) {
2108
+ notifyAttributes(this._capabilities, identifier, new Set(dirtyKeys));
2109
+ }
2110
+ return dirtyKeys || [];
2111
+ }
2112
+
2113
+ /**
2114
+ * Query the cache for the changes to relationships of a resource.
2115
+ *
2116
+ * Returns a map of relationship names to RelationshipDiff objects.
2117
+ *
2118
+ * ```ts
2119
+ * type RelationshipDiff =
2120
+ | {
2121
+ kind: 'collection';
2122
+ remoteState: ResourceKey[];
2123
+ additions: Set<ResourceKey>;
2124
+ removals: Set<ResourceKey>;
2125
+ localState: ResourceKey[];
2126
+ reordered: boolean;
2127
+ }
2128
+ | {
2129
+ kind: 'resource';
2130
+ remoteState: ResourceKey | null;
2131
+ localState: ResourceKey | null;
2132
+ };
2133
+ ```
2134
+ *
2135
+ * @category Resource Data
2136
+ * @public
2137
+ */
2138
+ changedRelationships(identifier) {
2139
+ return this.__graph.getChanged(identifier);
2140
+ }
2141
+
2142
+ /**
2143
+ * Query the cache for whether any mutated relationships exist
2144
+ *
2145
+ * @category Resource Data
2146
+ * @public
2147
+ */
2148
+ hasChangedRelationships(identifier) {
2149
+ return this.__graph.hasChanged(identifier);
2150
+ }
2151
+
2152
+ /**
2153
+ * Tell the cache to discard any uncommitted mutations to relationships.
2154
+ *
2155
+ * This will also discard the change on any appropriate inverses.
2156
+ *
2157
+ * This method is a candidate to become a mutation
2158
+ *
2159
+ * @category Resource Data
2160
+ * @public
2161
+ * @return the names of relationships that were restored
2162
+ */
2163
+ rollbackRelationships(identifier) {
2164
+ assertPrivateCapabilities(this._capabilities);
2165
+ let result;
2166
+ this._capabilities._store._join(() => {
2167
+ result = this.__graph.rollback(identifier);
2168
+ });
2169
+ return result;
2170
+ }
2171
+
2172
+ /**
2173
+ * Query the cache for the current state of a relationship property
2174
+ *
2175
+ * @category Resource Data
2176
+ * @public
2177
+ * @return resource relationship object
2178
+ */
2179
+ getRelationship(identifier, field) {
2180
+ return this.__graph.getData(identifier, field);
2181
+ }
2182
+
2183
+ /**
2184
+ * Query the cache for the remote state of a relationship property
2185
+ *
2186
+ * @category Resource Data
2187
+ * @public
2188
+ * @return resource relationship object
2189
+ */
2190
+ getRemoteRelationship(identifier, field) {
2191
+ return this.__graph.getRemoteData(identifier, field);
2192
+ }
2193
+
2194
+ ////////// ============== //////////
2195
+ ////////// Resource State //////////
2196
+ ////////// ============== //////////
2197
+
2198
+ /**
2199
+ * Update the cache state for the given resource to be marked
2200
+ * as locally deleted, or remove such a mark.
2201
+ *
2202
+ * This method is a candidate to become a mutation
2203
+ *
2204
+ * @category Resource State
2205
+ * @public
2206
+ */
2207
+ setIsDeleted(identifier, isDeleted) {
2208
+ const cached = this.__peek(identifier, false);
2209
+ cached.isDeleted = isDeleted;
2210
+ // > Note: Graph removal for isNew handled by unloadRecord
2211
+ this._capabilities.notifyChange(identifier, 'state', null);
2212
+ }
2213
+
2214
+ /**
2215
+ * Query the cache for any validation errors applicable to the given resource.
2216
+ *
2217
+ * @category Resource State
2218
+ * @public
2219
+ */
2220
+ getErrors(identifier) {
2221
+ return this.__peek(identifier, true).errors || [];
2222
+ }
2223
+
2224
+ /**
2225
+ * Query the cache for whether a given resource has any available data
2226
+ *
2227
+ * @category Resource State
2228
+ * @public
2229
+ */
2230
+ isEmpty(identifier) {
2231
+ const cached = this.__safePeek(identifier, true);
2232
+ return cached ? cached.remoteAttrs === null && cached.inflightAttrs === null && cached.localAttrs === null : true;
2233
+ }
2234
+
2235
+ /**
2236
+ * Query the cache for whether a given resource was created locally and not
2237
+ * yet persisted.
2238
+ *
2239
+ * @category Resource State
2240
+ * @public
2241
+ */
2242
+ isNew(identifier) {
2243
+ // TODO can we assert here?
2244
+ return this.__safePeek(identifier, true)?.isNew || false;
2245
+ }
2246
+
2247
+ /**
2248
+ * Query the cache for whether a given resource is marked as deleted (but not
2249
+ * necessarily persisted yet).
2250
+ *
2251
+ * @category Resource State
2252
+ * @public
2253
+ */
2254
+ isDeleted(identifier) {
2255
+ // TODO can we assert here?
2256
+ return this.__safePeek(identifier, true)?.isDeleted || false;
2257
+ }
2258
+
2259
+ /**
2260
+ * Query the cache for whether a given resource has been deleted and that deletion
2261
+ * has also been persisted.
2262
+ *
2263
+ * @category Resource State
2264
+ * @public
2265
+ */
2266
+ isDeletionCommitted(identifier) {
2267
+ // TODO can we assert here?
2268
+ return this.__safePeek(identifier, true)?.isDeletionCommitted || false;
2269
+ }
2270
+
2271
+ /**
2272
+ * Private method used to populate an entry for the identifier
2273
+ *
2274
+ * @internal
2275
+ */
2276
+ _createCache(identifier) {
2277
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
2278
+ if (!test) {
2279
+ throw new Error(`Expected no resource data to yet exist in the cache`);
2280
+ }
2281
+ })(!this.__cache.has(identifier)) : {};
2282
+ const cache = makeCache();
2283
+ this.__cache.set(identifier, cache);
2284
+ return cache;
2285
+ }
2286
+
2287
+ /**
2288
+ * Peek whether we have cached resource data matching the identifier
2289
+ * without asserting if the resource data is missing.
2290
+ *
2291
+ * @internal
2292
+ */
2293
+ __safePeek(identifier, allowDestroyed) {
2294
+ let resource = this.__cache.get(identifier);
2295
+ if (!resource && allowDestroyed) {
2296
+ resource = this.__destroyedCache.get(identifier);
2297
+ }
2298
+ return resource;
2299
+ }
2300
+
2301
+ /**
2302
+ * Peek whether we have cached resource data matching the identifier
2303
+ * Asserts if the resource data is missing.
2304
+ *
2305
+ * @internal
2306
+ */
2307
+ __peek(identifier, allowDestroyed) {
2308
+ const resource = this.__safePeek(identifier, allowDestroyed);
2309
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
2310
+ if (!test) {
2311
+ throw new Error(`Expected Cache to have a resource entry for the identifier ${String(identifier)} but none was found`);
2312
+ }
2313
+ })(resource) : {};
2314
+ return resource;
2315
+ }
2316
+ }
2317
+ function addResourceToDocument(cache, op) {
2318
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
2319
+ if (!test) {
2320
+ throw new Error(`Expected field to be either 'data' or 'included'`);
2321
+ }
2322
+ })(op.field === 'data' || op.field === 'included') : {};
2323
+ const doc = cache.__documents.get(op.record.lid);
2324
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
2325
+ if (!test) {
2326
+ throw new Error(`Expected to have a cached document on which to perform the add operation`);
2327
+ }
2328
+ })(doc) : {};
2329
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
2330
+ if (!test) {
2331
+ throw new Error(`Expected to have content on the document`);
2332
+ }
2333
+ })(doc.content) : {};
2334
+ const {
2335
+ content
2336
+ } = doc;
2337
+ if (op.field === 'data') {
2338
+ let shouldNotify = false;
2339
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
2340
+ if (!test) {
2341
+ throw new Error(`Expected to have a data property on the document`);
2342
+ }
2343
+ })('data' in content) : {};
2344
+
2345
+ // if data is not an array, we set the data property directly
2346
+ if (!Array.isArray(content.data)) {
2347
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
2348
+ if (!test) {
2349
+ throw new Error(`Expected to have a single record as the operation value`);
2350
+ }
2351
+ })(op.value && !Array.isArray(op.value)) : {};
2352
+ shouldNotify = content.data !== op.value;
2353
+ if (shouldNotify) content.data = op.value;
2354
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
2355
+ if (!test) {
2356
+ throw new Error(`The value '${op.value.lid}' cannot be added from the data of document '${op.record.lid}' as it is already the current value '${content.data ? content.data.lid : '<null>'}'`);
2357
+ }
2358
+ })(shouldNotify) : {};
2359
+ } else {
2360
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
2361
+ if (!test) {
2362
+ throw new Error(`Expected to have a non-null operation value`);
2363
+ }
2364
+ })(op.value) : {};
2365
+ if (Array.isArray(op.value)) {
2366
+ if (op.index !== undefined) {
2367
+ // for collections, because we allow duplicates we are always changed.
2368
+ shouldNotify = true;
2369
+ content.data.splice(op.index, 0, ...op.value);
2370
+ } else {
2371
+ // for collections, because we allow duplicates we are always changed.
2372
+ shouldNotify = true;
2373
+ content.data.push(...op.value);
2374
+ }
2375
+ } else {
2376
+ if (op.index !== undefined) {
2377
+ // for collections, because we allow duplicates we are always changed.
2378
+ shouldNotify = true;
2379
+ content.data.splice(op.index, 0, op.value);
2380
+ } else {
2381
+ // for collections, because we allow duplicates we are always changed.
2382
+ shouldNotify = true;
2383
+ content.data.push(op.value);
2384
+ }
2385
+ }
2386
+ }
2387
+
2388
+ // notify
2389
+ if (shouldNotify) cache._capabilities.notifyChange(op.record, 'updated', null);
2390
+ return;
2391
+ }
2392
+ content.included = content.included || [];
2393
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
2394
+ if (!test) {
2395
+ throw new Error(`Expected to have a non-null operation value`);
2396
+ }
2397
+ })(op.value) : {};
2398
+ if (Array.isArray(op.value)) {
2399
+ // included is not allowed to have duplicates, so we do a dirty check here
2400
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
2401
+ if (!test) {
2402
+ throw new Error(`included should not contain duplicate members`);
2403
+ }
2404
+ })(new Set([...content.included, ...op.value]).size === content.included.length + op.value.length) : {};
2405
+ content.included = content.included.concat(op.value);
2406
+ } else {
2407
+ // included is not allowed to have duplicates, so we do a dirty check here
2408
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
2409
+ if (!test) {
2410
+ throw new Error(`included should not contain duplicate members`);
2411
+ }
2412
+ })(content.included.includes(op.value) === false) : {};
2413
+ content.included.push(op.value);
2414
+ }
2415
+
2416
+ // we don't notify in the included case because this is not reactively
2417
+ // exposed. We should possibly consider doing so though for subscribers
2418
+ }
2419
+ function removeResourceFromDocument(cache, op) {
2420
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
2421
+ if (!test) {
2422
+ throw new Error(`Expected field to be either 'data' or 'included'`);
2423
+ }
2424
+ })(op.field === 'data' || op.field === 'included') : {};
2425
+ const doc = cache.__documents.get(op.record.lid);
2426
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
2427
+ if (!test) {
2428
+ throw new Error(`Expected to have a cached document on which to perform the remove operation`);
2429
+ }
2430
+ })(doc) : {};
2431
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
2432
+ if (!test) {
2433
+ throw new Error(`Expected to have content on the document`);
2434
+ }
2435
+ })(doc.content) : {};
2436
+ const {
2437
+ content
2438
+ } = doc;
2439
+ if (op.field === 'data') {
2440
+ let shouldNotify = false;
2441
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
2442
+ if (!test) {
2443
+ throw new Error(`Expected to have a data property on the document`);
2444
+ }
2445
+ })('data' in content) : {};
2446
+
2447
+ // if data is not an array, we set the data property directly
2448
+ if (!Array.isArray(content.data)) {
2449
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
2450
+ if (!test) {
2451
+ throw new Error(`Expected to have a single record as the operation value`);
2452
+ }
2453
+ })(op.value && !Array.isArray(op.value)) : {};
2454
+ shouldNotify = content.data === op.value;
2455
+ // we only remove the value if it was our existing value
2456
+ if (shouldNotify) content.data = null;
2457
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
2458
+ if (!test) {
2459
+ throw new Error(`The value '${op.value.lid}' cannot be removed from the data of document '${op.record.lid}' as it is not the current value '${content.data ? content.data.lid : '<null>'}'`);
2460
+ }
2461
+ })(shouldNotify) : {};
2462
+ } else {
2463
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
2464
+ if (!test) {
2465
+ throw new Error(`Expected to have a non-null operation value`);
2466
+ }
2467
+ })(op.value) : {};
2468
+ const toRemove = Array.isArray(op.value) ? op.value : [op.value];
2469
+ for (let i = 0; i < toRemove.length; i++) {
2470
+ const value = toRemove[i];
2471
+ if (op.index !== undefined) {
2472
+ // in production we want to recover gracefully
2473
+ // so we fallback to first-index-of
2474
+ const index = op.index < content.data.length && content.data[op.index] === value ? op.index : content.data.indexOf(value);
2475
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
2476
+ if (!test) {
2477
+ throw new Error(`Mismatched Index: Expected index '${op.index}' to contain the value '${value.lid}' but that value is at index '${index}'`);
2478
+ }
2479
+ })(op.index < content.data.length && content.data[op.index] === value) : {};
2480
+ if (index !== -1) {
2481
+ // we remove the first occurrence of the value
2482
+ shouldNotify = true;
2483
+ content.data.splice(index, 1);
2484
+ }
2485
+ } else {
2486
+ // we remove the first occurrence of the value
2487
+ const index = content.data.indexOf(value);
2488
+ if (index !== -1) {
2489
+ shouldNotify = true;
2490
+ content.data.splice(index, 1);
2491
+ }
2492
+ }
2493
+ }
2494
+ }
2495
+
2496
+ // notify
2497
+ if (shouldNotify) cache._capabilities.notifyChange(op.record, 'updated', null);
2498
+ } else {
2499
+ content.included = content.included || [];
2500
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
2501
+ if (!test) {
2502
+ throw new Error(`Expected to have a non-null operation value`);
2503
+ }
2504
+ })(op.value) : {};
2505
+ const toRemove = Array.isArray(op.value) ? op.value : [op.value];
2506
+ for (const identifier of toRemove) {
2507
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
2508
+ if (!test) {
2509
+ throw new Error(`attempted to remove a value from included that was not present in the included array`);
2510
+ }
2511
+ })(content.included.includes(identifier)) : {};
2512
+ const index = content.included.indexOf(identifier);
2513
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
2514
+ if (!test) {
2515
+ throw new Error(`The value '${identifier.lid}' cannot be removed from the included of document '${op.record.lid}' as it is not present`);
2516
+ }
2517
+ })(index !== -1) : {};
2518
+ if (index !== -1) {
2519
+ content.included.splice(index, 1);
2520
+ }
2521
+ }
2522
+
2523
+ // we don't notify in the included case because this is not reactively
2524
+ // exposed. We should possibly consider doing so though for subscribers
2525
+ }
2526
+ }
2527
+ function areAllModelsUnloaded(wrapper, identifiers) {
2528
+ for (let i = 0; i < identifiers.length; ++i) {
2529
+ const identifier = identifiers[i];
2530
+ if (wrapper.hasRecord(identifier)) {
2531
+ return false;
2532
+ }
2533
+ }
2534
+ return true;
2535
+ }
2536
+ function getLocalState(rel) {
2537
+ if (isBelongsTo(rel)) {
2538
+ return rel.localState ? [rel.localState] : [];
2539
+ }
2540
+ return rel.additions ? [...rel.additions] : [];
2541
+ }
2542
+ function getRemoteState(rel) {
2543
+ if (isBelongsTo(rel)) {
2544
+ return rel.remoteState ? [rel.remoteState] : [];
2545
+ }
2546
+ return rel.remoteState;
2547
+ }
2548
+ function schemaHasLegacyDefaultValueFn(schema) {
2549
+ if (!schema) return false;
2550
+ return hasLegacyDefaultValueFn(schema.options);
2551
+ }
2552
+ function hasLegacyDefaultValueFn(options) {
2553
+ return !!options && typeof options.defaultValue === 'function';
2554
+ }
2555
+ function getDefaultValue(schema, identifier, store) {
2556
+ const options = schema?.options;
2557
+ if (!schema || !options && !schema.type) {
2558
+ return;
2559
+ }
2560
+ if (schema.kind !== 'attribute' && schema.kind !== 'field') {
2561
+ return;
2562
+ }
2563
+
2564
+ // legacy support for defaultValues that are functions
2565
+ if (hasLegacyDefaultValueFn(options)) {
2566
+ // If anyone opens an issue for args not working right, we'll restore + deprecate it via a Proxy
2567
+ // that lazily instantiates the record. We don't want to provide any args here
2568
+ // because in a non @ember-data-mirror/model world they don't make sense.
2569
+ return options.defaultValue();
2570
+ // legacy support for defaultValues that are primitives
2571
+ } else if (options && 'defaultValue' in options) {
2572
+ const defaultValue = options.defaultValue;
2573
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
2574
+ if (!test) {
2575
+ throw new Error(`Non primitive defaultValues are not supported because they are shared between all instances. If you would like to use a complex object as a default value please provide a function that returns the complex object.`);
2576
+ }
2577
+ })(typeof defaultValue !== 'object' || defaultValue === null) : {};
2578
+ return defaultValue;
2579
+
2580
+ // new style transforms
2581
+ } else if (schema.kind !== 'attribute' && schema.type) {
2582
+ const transform = store.schema.transformation(schema);
2583
+ if (transform?.defaultValue) {
2584
+ return transform.defaultValue(options || null, identifier);
2585
+ }
2586
+ }
2587
+ }
2588
+ function notifyAttributes(storeWrapper, identifier, keys) {
2589
+ if (!keys) {
2590
+ storeWrapper.notifyChange(identifier, 'attributes', null);
2591
+ return;
2592
+ }
2593
+ for (const key of keys) {
2594
+ storeWrapper.notifyChange(identifier, 'attributes', key);
2595
+ }
2596
+ }
2597
+
2598
+ /*
2599
+ TODO @deprecate IGOR DAVID
2600
+ There seems to be a potential bug here, where we will return keys that are not
2601
+ in the schema
2602
+ */
2603
+ function calculateChangedKeys(cached, updates, fields) {
2604
+ const changedKeys = new Set();
2605
+ const keys = Object.keys(updates);
2606
+ const length = keys.length;
2607
+ const localAttrs = cached.localAttrs;
2608
+ const original = Object.assign(Object.create(null), cached.remoteAttrs, cached.inflightAttrs);
2609
+ for (let i = 0; i < length; i++) {
2610
+ const key = keys[i];
2611
+ if (!fields.has(key)) {
2612
+ continue;
2613
+ }
2614
+ const value = updates[key];
2615
+
2616
+ // A value in localAttrs means the user has a local change to
2617
+ // this attribute. We never override this value when merging
2618
+ // updates from the backend so we should not sent a change
2619
+ // notification if the server value differs from the original.
2620
+ if (localAttrs && localAttrs[key] !== undefined) {
2621
+ continue;
2622
+ }
2623
+ if (original[key] !== value) {
2624
+ changedKeys.add(key);
2625
+ }
2626
+ }
2627
+ return changedKeys;
2628
+ }
2629
+ function cacheIsEmpty(cached) {
2630
+ return !cached || cached.remoteAttrs === null && cached.inflightAttrs === null && cached.localAttrs === null;
2631
+ }
2632
+ function _isEmpty(peeked) {
2633
+ if (!peeked) {
2634
+ return true;
2635
+ }
2636
+ const isNew = peeked.isNew;
2637
+ const isDeleted = peeked.isDeleted;
2638
+ const isEmpty = cacheIsEmpty(peeked);
2639
+ return (!isNew || isDeleted) && isEmpty;
2640
+ }
2641
+ function recordIsLoaded(cached, filterDeleted = false) {
2642
+ if (!cached) {
2643
+ return false;
2644
+ }
2645
+ const isNew = cached.isNew;
2646
+ const isEmpty = cacheIsEmpty(cached);
2647
+
2648
+ // if we are new we must consider ourselves loaded
2649
+ if (isNew) {
2650
+ return !cached.isDeleted;
2651
+ }
2652
+ // even if we have a past request, if we are now empty we are not loaded
2653
+ // typically this is true after an unloadRecord call
2654
+
2655
+ // if we are not empty, not new && we have a fulfilled request then we are loaded
2656
+ // we should consider allowing for something to be loaded that is simply "not empty".
2657
+ // which is how RecordState currently handles this case; however, RecordState is buggy
2658
+ // in that it does not account for unloading.
2659
+ return filterDeleted && cached.isDeletionCommitted ? false : !isEmpty;
2660
+ }
2661
+ function _isLoading(peeked, capabilities, identifier) {
2662
+ assertPrivateCapabilities(capabilities);
2663
+ // TODO refactor things such that the cache is not required to know
2664
+ // about isLoading
2665
+ const req = capabilities._store.getRequestStateService();
2666
+ // const fulfilled = req.getLastRequestForRecord(identifier);
2667
+ const isLoaded = recordIsLoaded(peeked);
2668
+ return !isLoaded &&
2669
+ // fulfilled === null &&
2670
+ req.getPendingRequestsForRecord(identifier).some(r => r.type === 'query');
2671
+ }
2672
+ function setupRelationships(graph, fields, identifier, data) {
2673
+ for (const name in data.relationships) {
2674
+ const relationshipData = data.relationships[name];
2675
+ const field = fields.get(name);
2676
+ // TODO consider asserting if the relationship is not in the schema
2677
+ // we intentionally ignore relationships that are not in the schema
2678
+ if (!relationshipData || !field || !isRelationship(field)) continue;
2679
+ graph.push({
2680
+ op: 'updateRelationship',
2681
+ record: identifier,
2682
+ field: name,
2683
+ value: relationshipData
2684
+ });
2685
+ }
2686
+ }
2687
+ function isRelationship(field) {
2688
+ const {
2689
+ kind
2690
+ } = field;
2691
+ return kind === 'hasMany' || kind === 'belongsTo' || kind === 'resource' || kind === 'collection';
2692
+ }
2693
+ function patchLocalAttributes(cached, changedRemoteKeys) {
2694
+ const {
2695
+ localAttrs,
2696
+ remoteAttrs,
2697
+ inflightAttrs,
2698
+ defaultAttrs,
2699
+ changes
2700
+ } = cached;
2701
+ if (!localAttrs) {
2702
+ cached.changes = null;
2703
+ return false;
2704
+ }
2705
+ let hasAppliedPatch = false;
2706
+ const mutatedKeys = Object.keys(localAttrs);
2707
+ for (let i = 0, length = mutatedKeys.length; i < length; i++) {
2708
+ const attr = mutatedKeys[i];
2709
+ const existing = inflightAttrs && attr in inflightAttrs ? inflightAttrs[attr] : remoteAttrs && attr in remoteAttrs ? remoteAttrs[attr] : undefined;
2710
+ if (existing === localAttrs[attr]) {
2711
+ hasAppliedPatch = true;
2712
+
2713
+ // if the local change is committed, then
2714
+ // the remoteKeyChange is no longer relevant
2715
+ changedRemoteKeys?.delete(attr);
2716
+ delete localAttrs[attr];
2717
+ delete changes[attr];
2718
+ }
2719
+ if (defaultAttrs && attr in defaultAttrs) {
2720
+ delete defaultAttrs[attr];
2721
+ }
2722
+ }
2723
+ return hasAppliedPatch;
2724
+ }
2725
+ function putOne(cache, identifiers, resource) {
2726
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
2727
+ if (!test) {
2728
+ throw new Error(`You must include an 'id' for the resource data ${resource.type}`);
2729
+ }
2730
+ })(resource.id !== null && resource.id !== undefined && resource.id !== '') : {};
2731
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
2732
+ if (!test) {
2733
+ throw new Error(`Missing Resource Type: received resource data with a type '${resource.type}' but no schema could be found with that name.`);
2734
+ }
2735
+ })(cache._capabilities.schema.hasResource(resource)) : {};
2736
+ let identifier = identifiers.peekResourceKey(resource);
2737
+ if (identifier) {
2738
+ identifier = identifiers.updateRecordIdentifier(identifier, resource);
2739
+ } else {
2740
+ identifier = identifiers.getOrCreateRecordIdentifier(resource);
2741
+ }
2742
+ cache.upsert(identifier, resource, cache._capabilities.hasRecord(identifier));
2743
+ // even if the identifier was not "existing" before, it is now
2744
+ return identifier;
2745
+ }
2746
+
2747
+ /*
2748
+ Iterates over the set of internal models reachable from `this` across exactly one
2749
+ relationship.
2750
+ */
2751
+ function _directlyRelatedIdentifiersIterable(storeWrapper, originating) {
2752
+ const graph = peekGraph(storeWrapper);
2753
+ const initializedRelationships = graph?.identifiers.get(originating);
2754
+ if (!initializedRelationships) {
2755
+ return EMPTY_ITERATOR;
2756
+ }
2757
+ const initializedRelationshipsArr = [];
2758
+ Object.keys(initializedRelationships).forEach(key => {
2759
+ const rel = initializedRelationships[key];
2760
+ if (rel && !isImplicit(rel)) {
2761
+ initializedRelationshipsArr.push(rel);
2762
+ }
2763
+ });
2764
+ let i = 0;
2765
+ let j = 0;
2766
+ let k = 0;
2767
+ const findNext = () => {
2768
+ while (i < initializedRelationshipsArr.length) {
2769
+ while (j < 2) {
2770
+ const relatedIdentifiers = j === 0 ? getLocalState(initializedRelationshipsArr[i]) : getRemoteState(initializedRelationshipsArr[i]);
2771
+ while (k < relatedIdentifiers.length) {
2772
+ const relatedIdentifier = relatedIdentifiers[k++];
2773
+ if (relatedIdentifier !== null) {
2774
+ return relatedIdentifier;
2775
+ }
2776
+ }
2777
+ k = 0;
2778
+ j++;
2779
+ }
2780
+ j = 0;
2781
+ i++;
2782
+ }
2783
+ return undefined;
2784
+ };
2785
+ return {
2786
+ iterator() {
2787
+ return {
2788
+ next: () => {
2789
+ const value = findNext();
2790
+ return {
2791
+ value,
2792
+ done: value === undefined
2793
+ };
2794
+ }
2795
+ };
2796
+ }
2797
+ };
2798
+ }
2799
+
2800
+ /*
2801
+ Computes the set of Identifiers reachable from this Identifier.
2802
+
2803
+ Reachability is determined over the relationship graph (ie a graph where
2804
+ nodes are identifiers and edges are belongs to or has many
2805
+ relationships).
2806
+
2807
+ Returns an array including `this` and all identifiers reachable
2808
+ from `this.identifier`.
2809
+ */
2810
+ function _allRelatedIdentifiers(storeWrapper, originating) {
2811
+ const array = [];
2812
+ const queue = [];
2813
+ const seen = new Set();
2814
+ queue.push(originating);
2815
+ while (queue.length > 0) {
2816
+ const identifier = queue.shift();
2817
+ array.push(identifier);
2818
+ seen.add(identifier);
2819
+ const iterator = _directlyRelatedIdentifiersIterable(storeWrapper, originating).iterator();
2820
+ for (let obj = iterator.next(); !obj.done; obj = iterator.next()) {
2821
+ const relatedIdentifier = obj.value;
2822
+ if (relatedIdentifier && !seen.has(relatedIdentifier)) {
2823
+ seen.add(relatedIdentifier);
2824
+ queue.push(relatedIdentifier);
2825
+ }
2826
+ }
2827
+ }
2828
+ return array;
2829
+ }
2830
+ function fromBaseDocument(doc) {
2831
+ const resourceDocument = {};
2832
+ const jsonApiDoc = doc.content;
2833
+ if (jsonApiDoc) {
2834
+ copyLinksAndMeta(resourceDocument, jsonApiDoc);
2835
+ }
2836
+ return resourceDocument;
2837
+ }
2838
+ function fromStructuredError(doc) {
2839
+ const errorDoc = {};
2840
+ if (doc.content) {
2841
+ copyLinksAndMeta(errorDoc, doc.content);
2842
+ if ('errors' in doc.content) {
2843
+ errorDoc.errors = doc.content.errors;
2844
+ } else if (typeof doc.error === 'object' && 'errors' in doc.error) {
2845
+ errorDoc.errors = doc.error.errors;
2846
+ } else {
2847
+ errorDoc.errors = [{
2848
+ title: doc.message
2849
+ }];
2850
+ }
2851
+ }
2852
+ return errorDoc;
2853
+ }
2854
+ function copyLinksAndMeta(target, source) {
2855
+ if ('links' in source) {
2856
+ target.links = source.links;
2857
+ }
2858
+ if ('meta' in source) {
2859
+ target.meta = source.meta;
2860
+ }
2861
+ }
2862
+ function cacheUpsert(cache, identifier, data, calculateChanges) {
2863
+ let changedKeys;
2864
+ const peeked = cache.__safePeek(identifier, false);
2865
+ const existed = !!peeked;
2866
+ const cached = peeked || cache._createCache(identifier);
2867
+ const isLoading = /*#__NOINLINE__*/_isLoading(peeked, cache._capabilities, identifier) || !recordIsLoaded(peeked);
2868
+ const isUpdate = /*#__NOINLINE__*/!_isEmpty(peeked) && !isLoading;
2869
+ if (macroCondition(getGlobalConfig().WarpDriveMirror.activeLogging.LOG_CACHE)) {
2870
+ if (getGlobalConfig().WarpDriveMirror.debug.LOG_CACHE || globalThis.getWarpDriveRuntimeConfig().debug.LOG_CACHE) {
2871
+ logGroup('cache', 'upsert', identifier.type, identifier.lid, existed ? 'merged' : 'inserted', calculateChanges ? 'has-subscription' : '');
2872
+ try {
2873
+ const _data = JSON.parse(JSON.stringify(data));
2874
+
2875
+ // eslint-disable-next-line no-console
2876
+ console.log(_data);
2877
+ } catch {
2878
+ // eslint-disable-next-line no-console
2879
+ console.log(data);
2880
+ }
2881
+ }
2882
+ }
2883
+ if (cached.isNew) {
2884
+ cached.isNew = false;
2885
+ cache._capabilities.notifyChange(identifier, 'identity', null);
2886
+ cache._capabilities.notifyChange(identifier, 'state', null);
2887
+ }
2888
+ const fields = getCacheFields(cache, identifier);
2889
+
2890
+ // if no cache entry existed, no record exists / property has been accessed
2891
+ // and thus we do not need to notify changes to any properties.
2892
+ if (calculateChanges && existed && data.attributes) {
2893
+ changedKeys = calculateChangedKeys(cached, data.attributes, fields);
2894
+ }
2895
+ cached.remoteAttrs = Object.assign(cached.remoteAttrs || Object.create(null), data.attributes);
2896
+ if (cached.localAttrs) {
2897
+ if (patchLocalAttributes(cached, changedKeys)) {
2898
+ cache._capabilities.notifyChange(identifier, 'state', null);
2899
+ }
2900
+ }
2901
+ if (!isUpdate) {
2902
+ cache._capabilities.notifyChange(identifier, 'added', null);
2903
+ }
2904
+ if (data.id) {
2905
+ cached.id = data.id;
2906
+ }
2907
+ if (data.relationships) {
2908
+ setupRelationships(cache.__graph, fields, identifier, data);
2909
+ }
2910
+ if (changedKeys?.size) {
2911
+ notifyAttributes(cache._capabilities, identifier, changedKeys);
2912
+ }
2913
+ if (macroCondition(getGlobalConfig().WarpDriveMirror.activeLogging.LOG_CACHE)) {
2914
+ if (getGlobalConfig().WarpDriveMirror.debug.LOG_CACHE || globalThis.getWarpDriveRuntimeConfig().debug.LOG_CACHE) {
2915
+ // eslint-disable-next-line no-console
2916
+ console.groupEnd();
2917
+ }
2918
+ }
2919
+ return changedKeys?.size ? Array.from(changedKeys) : undefined;
2920
+ }
2921
+ function patchCache(Cache, op) {
2922
+ const isRecord = isResourceKey(op.record);
2923
+ const isDocument = !isRecord && isRequestKey(op.record);
2924
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
2925
+ if (!test) {
2926
+ throw new Error(`Expected Cache.patch op.record to be a record or document identifier`);
2927
+ }
2928
+ })(isRecord || isDocument) : {};
2929
+ if (macroCondition(getGlobalConfig().WarpDriveMirror.activeLogging.LOG_CACHE)) {
2930
+ if (getGlobalConfig().WarpDriveMirror.debug.LOG_CACHE || globalThis.getWarpDriveRuntimeConfig().debug.LOG_CACHE) {
2931
+ logGroup('cache', 'patch', isRecord ? op.record.type : '<@document>', op.record.lid, op.op, 'field' in op ? op.field : op.op === 'mergeIdentifiers' ? op.value.lid : '');
2932
+ try {
2933
+ const _data = JSON.parse(JSON.stringify(op));
2934
+ // eslint-disable-next-line no-console
2935
+ console.log(_data);
2936
+ } catch {
2937
+ // eslint-disable-next-line no-console
2938
+ console.log(op);
2939
+ }
2940
+ }
2941
+ }
2942
+ switch (op.op) {
2943
+ case 'mergeIdentifiers':
2944
+ {
2945
+ const cache = Cache.__cache.get(op.record);
2946
+ if (cache) {
2947
+ Cache.__cache.set(op.value, cache);
2948
+ Cache.__cache.delete(op.record);
2949
+ }
2950
+ Cache.__graph.update(op, true);
2951
+ break;
2952
+ }
2953
+ case 'update':
2954
+ {
2955
+ if (isRecord) {
2956
+ if ('field' in op) {
2957
+ const field = getCacheFields(Cache, op.record).get(op.field);
2958
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
2959
+ if (!test) {
2960
+ throw new Error(`Expected ${op.field} to be a field on ${op.record.type}`);
2961
+ }
2962
+ })(field) : {};
2963
+ if (isRelationship(field)) {
2964
+ Cache.__graph.push(op);
2965
+ } else {
2966
+ Cache.upsert(op.record, {
2967
+ type: op.record.type,
2968
+ id: op.record.id,
2969
+ attributes: {
2970
+ [op.field]: op.value
2971
+ }
2972
+ }, Cache._capabilities.hasRecord(op.record));
2973
+ }
2974
+ } else {
2975
+ Cache.upsert(op.record, op.value, Cache._capabilities.hasRecord(op.record));
2976
+ }
2977
+ } else {
2978
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
2979
+ {
2980
+ throw new Error(`Update operations on documents is not supported`);
2981
+ }
2982
+ })() : {};
2983
+ }
2984
+ break;
2985
+ }
2986
+ case 'add':
2987
+ {
2988
+ if (isRecord) {
2989
+ if ('field' in op) {
2990
+ Cache.__graph.push(op);
2991
+ } else {
2992
+ Cache.upsert(op.record, op.value, Cache._capabilities.hasRecord(op.record));
2993
+ }
2994
+ } else {
2995
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
2996
+ if (!test) {
2997
+ throw new Error(`Expected a field in the add operation`);
2998
+ }
2999
+ })('field' in op) : {};
3000
+ addResourceToDocument(Cache, op);
3001
+ }
3002
+ break;
3003
+ }
3004
+ case 'remove':
3005
+ {
3006
+ if (isRecord) {
3007
+ if ('field' in op) {
3008
+ Cache.__graph.push(op);
3009
+ } else {
3010
+ const cached = Cache.__safePeek(op.record, false);
3011
+ if (cached) {
3012
+ cached.isDeleted = true;
3013
+ cached.isDeletionCommitted = true;
3014
+ Cache.unloadRecord(op.record);
3015
+ } else {
3016
+ peekGraph(Cache._capabilities)?.push({
3017
+ op: 'deleteRecord',
3018
+ record: op.record,
3019
+ isNew: false
3020
+ });
3021
+ }
3022
+ }
3023
+ } else {
3024
+ if ('field' in op) {
3025
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
3026
+ if (!test) {
3027
+ throw new Error(`Expected a field in the remove operation`);
3028
+ }
3029
+ })('field' in op) : {};
3030
+ removeResourceFromDocument(Cache, op);
3031
+ } else {
3032
+ // TODO @runspired teardown associated state ... notify subscribers etc.
3033
+ // This likely means that the instance cache needs to handle
3034
+ // holding onto reactive documents instead of the CacheHandler
3035
+ // and use a subscription to remove them.
3036
+ // Cache.__documents.delete(op.record.lid);
3037
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
3038
+ {
3039
+ throw new Error(`Removing documents from the cache is not yet supported`);
3040
+ }
3041
+ })() : {};
3042
+ }
3043
+ }
3044
+ break;
3045
+ }
3046
+ default:
3047
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
3048
+ {
3049
+ throw new Error(`Unhandled cache.patch operation ${op.op}`);
3050
+ }
3051
+ })() : {};
3052
+ }
3053
+ if (macroCondition(getGlobalConfig().WarpDriveMirror.activeLogging.LOG_CACHE)) {
3054
+ if (getGlobalConfig().WarpDriveMirror.debug.LOG_CACHE || globalThis.getWarpDriveRuntimeConfig().debug.LOG_CACHE) {
3055
+ // eslint-disable-next-line no-console
3056
+ console.groupEnd();
3057
+ }
3058
+ }
3059
+ }
3060
+ function getCacheFields(cache, identifier) {
3061
+ if (cache._capabilities.schema.cacheFields) {
3062
+ return cache._capabilities.schema.cacheFields(identifier);
3063
+ }
3064
+
3065
+ // the model schema service cannot process fields that are not cache fields
3066
+ return cache._capabilities.schema.fields(identifier);
3067
+ }
3068
+ function commitDidError(cache, identifier, errors) {
3069
+ const cached = cache.__peek(identifier, false);
3070
+ if (cached.inflightAttrs) {
3071
+ const keys = Object.keys(cached.inflightAttrs);
3072
+ if (keys.length > 0) {
3073
+ const attrs = cached.localAttrs = cached.localAttrs || Object.create(null);
3074
+ for (let i = 0; i < keys.length; i++) {
3075
+ if (attrs[keys[i]] === undefined) {
3076
+ attrs[keys[i]] = cached.inflightAttrs[keys[i]];
3077
+ }
3078
+ }
3079
+ }
3080
+ cached.inflightAttrs = null;
3081
+ }
3082
+ if (errors) {
3083
+ cached.errors = errors;
3084
+ }
3085
+ cache._capabilities.notifyChange(identifier, 'errors', null);
3086
+ }
3087
+ function didCommit(cache, committedIdentifier, data, op) {
3088
+ if (!data) {
3089
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
3090
+ if (!test) {
3091
+ throw new Error(`Your ${committedIdentifier.type} record was saved to the server, but the response does not have an id and no id has been set client side. Records must have ids. Please update the server response to provide an id in the response or generate the id on the client side either before saving the record or while normalizing the response.`);
3092
+ }
3093
+ })(committedIdentifier.id) : {};
3094
+ }
3095
+ const {
3096
+ cacheKeyManager
3097
+ } = cache._capabilities;
3098
+ const existingId = committedIdentifier.id;
3099
+ const identifier = op !== 'deleteRecord' && data ? cacheKeyManager.updateRecordIdentifier(committedIdentifier, data) : committedIdentifier;
3100
+ const cached = cache.__peek(identifier, false);
3101
+ if (cached.isDeleted || op === 'deleteRecord') {
3102
+ cache.__graph.push({
3103
+ op: 'deleteRecord',
3104
+ record: identifier,
3105
+ isNew: false
3106
+ });
3107
+ cached.isDeleted = true;
3108
+ cached.isDeletionCommitted = true;
3109
+ cache._capabilities.notifyChange(identifier, 'removed', null);
3110
+ // TODO @runspired should we early exit here?
3111
+ }
3112
+ if (macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG)) {
3113
+ if (cached.isNew && !identifier.id && (typeof data?.id !== 'string' || data.id.length > 0)) {
3114
+ const error = new Error(`Expected an id ${String(identifier)} in response ${JSON.stringify(data)}`);
3115
+ //@ts-expect-error
3116
+ error.isAdapterError = true;
3117
+ //@ts-expect-error
3118
+ error.code = 'InvalidError';
3119
+ throw error;
3120
+ }
3121
+ }
3122
+ const fields = getCacheFields(cache, identifier);
3123
+ cached.isNew = false;
3124
+ let newCanonicalAttributes;
3125
+ if (data) {
3126
+ if (data.id && !cached.id) {
3127
+ cached.id = data.id;
3128
+ }
3129
+ if (identifier === committedIdentifier && identifier.id !== existingId) {
3130
+ cache._capabilities.notifyChange(identifier, 'identity', null);
3131
+ }
3132
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
3133
+ if (!test) {
3134
+ throw new Error(`Expected the ID received for the primary '${identifier.type}' resource being saved to match the current id '${cached.id}' but received '${identifier.id}'.`);
3135
+ }
3136
+ })(identifier.id === cached.id) : {};
3137
+ if (data.relationships) {
3138
+ if (macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG)) {
3139
+ if (macroCondition(!getGlobalConfig().WarpDriveMirror.deprecations.DEPRECATE_RELATIONSHIP_REMOTE_UPDATE_CLEARING_LOCAL_STATE)) {
3140
+ // assert against bad API behavior where a belongsTo relationship
3141
+ // is saved but the return payload indicates a different final state.
3142
+ fields.forEach((field, name) => {
3143
+ if (field.kind === 'belongsTo') {
3144
+ const relationshipData = data.relationships[name]?.data;
3145
+ if (relationshipData !== undefined) {
3146
+ const inFlightData = cached.inflightRelationships?.[name];
3147
+ if (!inFlightData || !('data' in inFlightData)) {
3148
+ return;
3149
+ }
3150
+ const actualData = relationshipData ? cache._capabilities.cacheKeyManager.getOrCreateRecordIdentifier(relationshipData) : null;
3151
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
3152
+ if (!test) {
3153
+ throw new Error(`Expected the resource relationship '<${identifier.type}>.${name}' on ${identifier.lid} to be saved as ${inFlightData.data ? inFlightData.data.lid : '<null>'} but it was saved as ${actualData ? actualData.lid : '<null>'}`);
3154
+ }
3155
+ })(inFlightData.data === actualData) : {};
3156
+ }
3157
+ }
3158
+ });
3159
+ cached.inflightRelationships = null;
3160
+ }
3161
+ }
3162
+ setupRelationships(cache.__graph, fields, identifier, data);
3163
+ }
3164
+ newCanonicalAttributes = data.attributes;
3165
+ }
3166
+ const changedKeys = newCanonicalAttributes && calculateChangedKeys(cached, newCanonicalAttributes, fields);
3167
+ cached.remoteAttrs = Object.assign(cached.remoteAttrs || Object.create(null), cached.inflightAttrs, newCanonicalAttributes);
3168
+ cached.inflightAttrs = null;
3169
+ patchLocalAttributes(cached, changedKeys);
3170
+ if (cached.errors) {
3171
+ cached.errors = null;
3172
+ cache._capabilities.notifyChange(identifier, 'errors', null);
3173
+ }
3174
+ if (changedKeys?.size) notifyAttributes(cache._capabilities, identifier, changedKeys);
3175
+ cache._capabilities.notifyChange(identifier, 'state', null);
3176
+ }
3177
+ function willCommit(cache, identifier) {
3178
+ const cached = cache.__peek(identifier, false);
3179
+
3180
+ /*
3181
+ if we have multiple saves in flight at once then
3182
+ we have information loss no matter what. This
3183
+ attempts to lose the least information.
3184
+ If we were to clear inflightAttrs, previous requests
3185
+ would not be able to use it during their didCommit.
3186
+ If we upsert inflightattrs, previous requests incorrectly
3187
+ see more recent inflight changes as part of their own and
3188
+ will incorrectly mark the new state as the correct remote state.
3189
+ We choose this latter behavior to avoid accidentally removing
3190
+ earlier changes.
3191
+ If apps do not want this behavior they can either
3192
+ - chain save requests serially vs allowing concurrent saves
3193
+ - move to using a request handler that caches the inflight state
3194
+ on a per-request basis
3195
+ - change their save requests to only send a "PATCH" instead of a "PUT"
3196
+ so that only latest changes are involved in each request, and then also
3197
+ ensure that the API or their handler reflects only those changes back
3198
+ for upsert into the cache.
3199
+ */
3200
+ if (cached.inflightAttrs) {
3201
+ if (cached.localAttrs) {
3202
+ Object.assign(cached.inflightAttrs, cached.localAttrs);
3203
+ }
3204
+ } else {
3205
+ cached.inflightAttrs = cached.localAttrs;
3206
+ }
3207
+ cached.localAttrs = null;
3208
+ if (macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG)) {
3209
+ if (macroCondition(!getGlobalConfig().WarpDriveMirror.deprecations.DEPRECATE_RELATIONSHIP_REMOTE_UPDATE_CLEARING_LOCAL_STATE)) {
3210
+ // save off info about saved relationships
3211
+ const fields = getCacheFields(cache, identifier);
3212
+ fields.forEach((schema, name) => {
3213
+ if (schema.kind === 'belongsTo') {
3214
+ if (cache.__graph._isDirty(identifier, name)) {
3215
+ const relationshipData = cache.__graph.getData(identifier, name);
3216
+ const inFlight = cached.inflightRelationships = cached.inflightRelationships || Object.create(null);
3217
+ inFlight[name] = relationshipData;
3218
+ }
3219
+ }
3220
+ });
3221
+ }
3222
+ }
3223
+ }
3224
+
3225
+ export { JSONAPICache };