@haverstack/core 0.24.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/stack.js CHANGED
@@ -298,21 +298,38 @@ export class StackQueryError extends StackError {
298
298
  * § Capability-gated filters.
299
299
  */
300
300
  export function assertQueryCapabilities(filter, capabilities) {
301
- if (filter?.search && !capabilities.fullTextSearch) {
302
- throw new StackQueryError('Query uses filter.search, but this adapter does not declare the fullTextSearch capability.');
301
+ const { content: reach, contentPresent, search } = capabilities.filter;
302
+ if (filter?.search && !search) {
303
+ throw new StackQueryError('Query uses filter.search, but this adapter does not declare the filter.search capability.');
303
304
  }
304
- if (!filter?.content)
305
+ const present = filter?.contentPresent?.length ? filter.contentPresent : undefined;
306
+ if (!filter?.content && !present)
305
307
  return;
306
- if (!capabilities.contentFieldQuery) {
307
- throw new StackQueryError('Query uses filter.content, but this adapter does not declare the contentFieldQuery capability.');
308
+ if (reach === 'none') {
309
+ throw new StackQueryError(`Query uses ${present && !filter?.content ? 'filter.contentPresent' : 'filter.content'}, ` +
310
+ 'but this adapter declares filter.content: "none".');
308
311
  }
309
- for (const key of Object.keys(filter.content)) {
310
- if (parseContentFilterKey(key).length > 1 && !capabilities.nestedContentQuery) {
311
- throw new StackQueryError(`Query uses the nested content path "${key}", but this adapter does not declare ` +
312
- 'the nestedContentQuery capability.');
312
+ if (present && !contentPresent) {
313
+ throw new StackQueryError('Query uses filter.contentPresent, but this adapter does not declare the ' +
314
+ 'filter.contentPresent capability.');
315
+ }
316
+ for (const key of [...Object.keys(filter?.content ?? {}), ...(present ?? [])]) {
317
+ if (parseContentFilterKey(key).length > 1 && reach !== 'path') {
318
+ throw new StackQueryError(`Query uses the nested content path "${key}", but this adapter declares ` +
319
+ `filter.content: "${reach}".`);
313
320
  }
314
321
  }
315
322
  }
323
+ /**
324
+ * Whether a single-segment content filter can be pushed down to the
325
+ * adapter. Callers that read a content field pair this with an in-memory
326
+ * predicate over the wider result, so the query stays correct against an
327
+ * adapter that reaches no content at all — the rung comparison lives here
328
+ * rather than at each of them.
329
+ */
330
+ function filtersContent(features) {
331
+ return features.filter.content !== 'none';
332
+ }
316
333
  /** The longest path both SQLite engines can execute — see the spec link below. */
317
334
  const MAX_CONTENT_PATH_SEGMENTS = 32;
318
335
  /**
@@ -358,13 +375,48 @@ const VALID_SORT_DIRECTIONS = new Set(['asc', 'desc']);
358
375
  export function assertValidSort(sort) {
359
376
  if (!sort)
360
377
  return;
378
+ if (sort.field !== undefined && sort.contentField !== undefined) {
379
+ throw new StackQueryError('A sort names either a native field or a content field, never both.');
380
+ }
361
381
  if (sort.field !== undefined && !VALID_SORT_FIELDS.has(sort.field)) {
362
382
  throw new StackQueryError(`Invalid sort field "${sort.field}": expected one of createdAt, updatedAt, version.`);
363
383
  }
384
+ if (sort.contentField !== undefined) {
385
+ // Parsed by the same rule a filter key is, so a name a filter could
386
+ // never address is not one a sort can either — then held to one
387
+ // segment, because a value inside an array or object has no single
388
+ // position to order its record by (docs/spec/data-model.md
389
+ // § Sorting by a content field).
390
+ if (parseContentFilterKey(sort.contentField).length > 1) {
391
+ throw new StackQueryError(`Invalid sort content field "${sort.contentField}": sorting reaches top-level fields only.`);
392
+ }
393
+ }
364
394
  if (sort.direction !== undefined && !VALID_SORT_DIRECTIONS.has(sort.direction)) {
365
395
  throw new StackQueryError(`Invalid sort direction "${sort.direction}": expected "asc" or "desc".`);
366
396
  }
367
397
  }
398
+ /**
399
+ * Fail loud rather than silently reorder: an adapter that can't honor the
400
+ * requested sort would otherwise answer in some other order, which a
401
+ * caller paging a bounded window has no way to notice. The companion to
402
+ * assertQueryCapabilities(), split from it because a sort is not a filter
403
+ * — see docs/spec/data-model.md § Capability-gated filters.
404
+ */
405
+ export function assertSortCapability(sort, capabilities) {
406
+ if (!sort)
407
+ return;
408
+ if (sort.contentField !== undefined) {
409
+ if (!capabilities.sort.contentField) {
410
+ throw new StackQueryError('Query uses sort.contentField, but this adapter does not declare the sort.contentField ' +
411
+ 'capability.');
412
+ }
413
+ return;
414
+ }
415
+ const field = sort.field ?? 'createdAt';
416
+ if (!capabilities.sort.fields.includes(field)) {
417
+ throw new StackQueryError(`Query sorts by "${field}", which this adapter does not declare in sort.fields.`);
418
+ }
419
+ }
368
420
  /** The identifier spaces a relationship target may name. */
369
421
  const TARGET_SCOPES = new Set(['record', 'entity', 'external']);
370
422
  /**
@@ -439,8 +491,8 @@ export function assertValidRelatedTo(relatedTo) {
439
491
  }
440
492
  /**
441
493
  * Thrown when an attachment upload exceeds the adapter's declared
442
- * `maxAttachmentBytes` ceiling — checked client-side before any bytes are
443
- * sent; a server still enforces 413 authoritatively regardless. See
494
+ * `limits.attachmentBytes` ceiling — checked client-side before any bytes
495
+ * are sent; a server still enforces 413 authoritatively regardless. See
444
496
  * docs/spec/wire-format.md § Attachments.
445
497
  */
446
498
  export class StackPayloadTooLargeError extends StackError {
@@ -473,25 +525,25 @@ export class StackTimeoutError extends StackError {
473
525
  }
474
526
  }
475
527
  /** Shared by Stack.putAttachment() and ScopedStack.putAttachment(). */
476
- function assertAttachmentSize(byteLength, maxAttachmentBytes) {
477
- if (maxAttachmentBytes !== null && byteLength > maxAttachmentBytes) {
478
- throw new StackPayloadTooLargeError(`Attachment (${byteLength} bytes) exceeds the ${maxAttachmentBytes}-byte limit.`);
528
+ function assertAttachmentSize(byteLength, attachmentBytes) {
529
+ if (attachmentBytes !== null && byteLength > attachmentBytes) {
530
+ throw new StackPayloadTooLargeError(`Attachment (${byteLength} bytes) exceeds the ${attachmentBytes}-byte limit.`);
479
531
  }
480
532
  }
481
533
  /**
482
534
  * The content half of the same pre-check, on Stack.create() and
483
- * Stack.update(). Local adapters declare `maxContentBytes: null` and skip
535
+ * Stack.update(). Local adapters declare `limits.contentBytes: null` and skip
484
536
  * the serialization entirely; only a server declares a ceiling, and its
485
537
  * own request-size limit stays authoritative — this just spares an app the
486
538
  * round trip and gives it a typed failure instead of a 413 it has to
487
539
  * interpret. See docs/spec/wire-format.md § Request size limits.
488
540
  */
489
- function assertContentSize(content, maxContentBytes, what) {
490
- if (maxContentBytes === null)
541
+ function assertContentSize(content, contentBytes, what) {
542
+ if (contentBytes === null)
491
543
  return;
492
544
  const byteLength = new TextEncoder().encode(JSON.stringify(content)).length;
493
- if (byteLength > maxContentBytes) {
494
- throw new StackPayloadTooLargeError(`${what} (${byteLength} bytes) exceeds the ${maxContentBytes}-byte limit.`);
545
+ if (byteLength > contentBytes) {
546
+ throw new StackPayloadTooLargeError(`${what} (${byteLength} bytes) exceeds the ${contentBytes}-byte limit.`);
495
547
  }
496
548
  }
497
549
  /**
@@ -742,13 +794,13 @@ async function findFirstMatch(run, query, predicate, max = QUERY_ALL_MAX) {
742
794
  * hardcoded, so a caller not permitted to set it can omit it instead of
743
795
  * having query() throw.
744
796
  */
745
- async function lookupEntityByDid(run, did, contentFieldQuery, includeUnlisted) {
797
+ async function lookupEntityByDid(run, did, canFilterContent, includeUnlisted) {
746
798
  const record = await findFirstMatch(run, {
747
799
  filter: {
748
800
  baseId: SYSTEM_TYPES.ENTITY,
749
801
  includeDeleted: true,
750
802
  ...(includeUnlisted && { includeUnlisted: true }),
751
- ...(contentFieldQuery && { content: { did } }),
803
+ ...(canFilterContent && { content: { did } }),
752
804
  },
753
805
  }, (r) => r.content.did === did);
754
806
  return record ?? null;
@@ -829,7 +881,7 @@ export class Stack {
829
881
  }
830
882
  /**
831
883
  * Idempotent bootstrap for StackOptions.ownerProfile. Filters by family
832
- * only (contentFieldQuery is capability-gated) and matches `content.did`
884
+ * only (content filtering is capability-gated) and matches `content.did`
833
885
  * in memory, cursor-walking so an owner card past page one isn't missed
834
886
  * and duplicated. The probe must be blind to nothing the binding rules
835
887
  * see, or it mints a card they then refuse and the stack won't open with
@@ -852,7 +904,7 @@ export class Stack {
852
904
  return this.adapter.ownerEntityId;
853
905
  }
854
906
  async getEntityByDid(did) {
855
- return lookupEntityByDid((q) => this.query(q), did, this.features.contentFieldQuery, true);
907
+ return lookupEntityByDid((q) => this.query(q), did, filtersContent(this.features), true);
856
908
  }
857
909
  async getOwnerEntity() {
858
910
  return this.getEntityByDid(this.ownerEntityId);
@@ -1129,7 +1181,7 @@ export class Stack {
1129
1181
  if (errors.length > 0) {
1130
1182
  throw new StackValidationError(errors);
1131
1183
  }
1132
- assertContentSize(content, this.features.maxContentBytes, 'Content');
1184
+ assertContentSize(content, this.features.limits.contentBytes, 'Content');
1133
1185
  if (typeId === `${SYSTEM_TYPES.ATTACHMENT}@1`) {
1134
1186
  await this.checkAttachmentMimeTypeOnCreate(content);
1135
1187
  }
@@ -1255,7 +1307,7 @@ export class Stack {
1255
1307
  }
1256
1308
  // The patch is what travels, so the patch is what's measured — a small
1257
1309
  // patch against a large record is not an oversized request.
1258
- assertContentSize(content, this.features.maxContentBytes, 'Patch');
1310
+ assertContentSize(content, this.features.limits.contentBytes, 'Patch');
1259
1311
  // Merge (RFC 7396 / JSON Merge Patch): null values delete a field.
1260
1312
  const merged = applyMergePatch(existing.content, content);
1261
1313
  const errors = validateContent(merged, type.schema);
@@ -1469,6 +1521,7 @@ export class Stack {
1469
1521
  const { presentAt, filter, limit: rawLimit, ...rest } = query;
1470
1522
  assertQueryCapabilities(filter, this.adapter.capabilities);
1471
1523
  assertValidSort(query.sort);
1524
+ assertSortCapability(query.sort, this.adapter.capabilities);
1472
1525
  assertValidRelatedTo(filter?.relatedTo);
1473
1526
  const limit = rawLimit !== undefined ? Math.min(rawLimit, MAX_QUERY_LIMIT) : undefined;
1474
1527
  const resolvedFilter = await this.resolveBaseIdFilter(filter);
@@ -1619,7 +1672,7 @@ export class Stack {
1619
1672
  if (errors.length > 0) {
1620
1673
  throw new StackValidationError(errors);
1621
1674
  }
1622
- assertContentSize(content, this.features.maxContentBytes, 'Content');
1675
+ assertContentSize(content, this.features.limits.contentBytes, 'Content');
1623
1676
  const fromFamily = baseIdOf(existing.typeId);
1624
1677
  const toFamily = baseIdOf(toTypeId);
1625
1678
  const existingContent = existing.content;
@@ -1736,7 +1789,7 @@ export class Stack {
1736
1789
  baseId: family,
1737
1790
  includeDeleted: true,
1738
1791
  includeUnlisted: true,
1739
- ...(this.features.contentFieldQuery && { content: { [field]: value } }),
1792
+ ...(filtersContent(this.features) && { content: { [field]: value } }),
1740
1793
  },
1741
1794
  }, (r) => r.id !== excludeId && r.content[field] === value);
1742
1795
  if (clash) {
@@ -1877,7 +1930,7 @@ export class Stack {
1877
1930
  */
1878
1931
  async putAttachment(data, mimeType, filename, appId) {
1879
1932
  this.assertOpen();
1880
- assertAttachmentSize(data.byteLength, this.features.maxAttachmentBytes);
1933
+ assertAttachmentSize(data.byteLength, this.features.limits.attachmentBytes);
1881
1934
  if (this.adapter.putAttachmentWithMetadata) {
1882
1935
  // The metadata record is written inside the adapter, so create()
1883
1936
  // never sees it and this is the only place it can be announced.
@@ -1916,7 +1969,7 @@ export class Stack {
1916
1969
  baseId: SYSTEM_TYPES.ATTACHMENT,
1917
1970
  includeDeleted: true,
1918
1971
  includeUnlisted: true,
1919
- ...(this.features.contentFieldQuery && { content: { fileId } }),
1972
+ ...(filtersContent(this.features) && { content: { fileId } }),
1920
1973
  },
1921
1974
  });
1922
1975
  return results
@@ -2980,7 +3033,7 @@ export class ScopedStack {
2980
3033
  }
2981
3034
  if (this.subjectEntityId === this.stack.ownerEntityId)
2982
3035
  return true;
2983
- return this.stack.features.contentFieldQuery
3036
+ return filtersContent(this.stack.features)
2984
3037
  ? (await this.stack.query({
2985
3038
  filter: {
2986
3039
  typeId: `${SYSTEM_TYPES.ATTACHMENT}@1`,
@@ -3169,7 +3222,7 @@ export class ScopedStack {
3169
3222
  * docs/spec/identity.md § DID bindings.
3170
3223
  */
3171
3224
  async getEntityByDid(did) {
3172
- return lookupEntityByDid((q) => this.query(q), did, this.stack.features.contentFieldQuery, this.ownerActingAlone);
3225
+ return lookupEntityByDid((q) => this.query(q), did, filtersContent(this.stack.features), this.ownerActingAlone);
3173
3226
  }
3174
3227
  async getOwnerEntity() {
3175
3228
  return this.getEntityByDid(this.stack.ownerEntityId);
@@ -3182,6 +3235,7 @@ export class ScopedStack {
3182
3235
  */
3183
3236
  async query(query = {}) {
3184
3237
  assertValidSort(query.sort);
3238
+ assertSortCapability(query.sort, this.stack.features);
3185
3239
  assertValidRelatedTo(query.filter?.relatedTo);
3186
3240
  if (query.filter?.includeUnlisted && !this.ownerActingAlone) {
3187
3241
  throw new StackPermissionError('includeUnlisted is owner-only');
@@ -3284,7 +3338,7 @@ export class ScopedStack {
3284
3338
  baseId: SYSTEM_TYPES.APP,
3285
3339
  includeDeleted: true,
3286
3340
  includeUnlisted: true,
3287
- ...(this.stack.features.contentFieldQuery && {
3341
+ ...(filtersContent(this.stack.features) && {
3288
3342
  content: { did: this.principalEntityId },
3289
3343
  }),
3290
3344
  },
@@ -3511,7 +3565,7 @@ export class ScopedStack {
3511
3565
  throw new StackPermissionError(`No create grant for type "${SYSTEM_TYPES.ATTACHMENT}@1"`);
3512
3566
  }
3513
3567
  await this.requireAppIdMatchesPrincipal(appId);
3514
- assertAttachmentSize(data.byteLength, this.features.maxAttachmentBytes);
3568
+ assertAttachmentSize(data.byteLength, this.features.limits.attachmentBytes);
3515
3569
  const fileId = await this.adapter.putAttachment(data);
3516
3570
  return this.stack.create(`${SYSTEM_TYPES.ATTACHMENT}@1`, {
3517
3571
  fileId,