@haverstack/core 0.23.0 → 0.25.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
@@ -18,7 +18,7 @@ import { hashSchema, isCompatible, parseTypeId, baseIdOf, diffSchemas, isWellFor
18
18
  import { CONTENT_SEGMENT_METACHARACTERS, SEGMENT_METACHARACTER_RE, validateContent, validateContentKeys, validatePatchValues, validateReservedKeys, validateSchemaFieldNames, } from './validate.js';
19
19
  import { applyMergePatch } from './merge.js';
20
20
  import { checkAccess, groupRoleFromAssociations, isOwnerActingAlone, validatePermissions, } from './access.js';
21
- import { firstRecordedAttachment } from './attachment-download.js';
21
+ import { compareRecordedAttachments } from './attachment-download.js';
22
22
  import { ChangeEmitter, RelayDelivery, Subscription, buildEmission, matchesFilter, passesUnlistedBoundary, } from './changes.js';
23
23
  import { SYSTEM_TYPES, GRANT_ACTIONS } from './types.js';
24
24
  // -------------------------------------------------------
@@ -301,12 +301,18 @@ export function assertQueryCapabilities(filter, capabilities) {
301
301
  if (filter?.search && !capabilities.fullTextSearch) {
302
302
  throw new StackQueryError('Query uses filter.search, but this adapter does not declare the fullTextSearch capability.');
303
303
  }
304
- if (!filter?.content)
304
+ const present = filter?.contentPresent?.length ? filter.contentPresent : undefined;
305
+ if (!filter?.content && !present)
305
306
  return;
306
307
  if (!capabilities.contentFieldQuery) {
307
- throw new StackQueryError('Query uses filter.content, but this adapter does not declare the contentFieldQuery capability.');
308
+ throw new StackQueryError(`Query uses ${present && !filter?.content ? 'filter.contentPresent' : 'filter.content'}, ` +
309
+ 'but this adapter does not declare the contentFieldQuery capability.');
308
310
  }
309
- for (const key of Object.keys(filter.content)) {
311
+ if (present && !capabilities.contentPresenceQuery) {
312
+ throw new StackQueryError('Query uses filter.contentPresent, but this adapter does not declare the ' +
313
+ 'contentPresenceQuery capability.');
314
+ }
315
+ for (const key of [...Object.keys(filter?.content ?? {}), ...(present ?? [])]) {
310
316
  if (parseContentFilterKey(key).length > 1 && !capabilities.nestedContentQuery) {
311
317
  throw new StackQueryError(`Query uses the nested content path "${key}", but this adapter does not declare ` +
312
318
  'the nestedContentQuery capability.');
@@ -358,13 +364,48 @@ const VALID_SORT_DIRECTIONS = new Set(['asc', 'desc']);
358
364
  export function assertValidSort(sort) {
359
365
  if (!sort)
360
366
  return;
367
+ if (sort.field !== undefined && sort.contentField !== undefined) {
368
+ throw new StackQueryError('A sort names either a native field or a content field, never both.');
369
+ }
361
370
  if (sort.field !== undefined && !VALID_SORT_FIELDS.has(sort.field)) {
362
371
  throw new StackQueryError(`Invalid sort field "${sort.field}": expected one of createdAt, updatedAt, version.`);
363
372
  }
373
+ if (sort.contentField !== undefined) {
374
+ // Parsed by the same rule a filter key is, so a name a filter could
375
+ // never address is not one a sort can either — then held to one
376
+ // segment, because a value inside an array or object has no single
377
+ // position to order its record by (docs/spec/data-model.md
378
+ // § Sorting by a content field).
379
+ if (parseContentFilterKey(sort.contentField).length > 1) {
380
+ throw new StackQueryError(`Invalid sort content field "${sort.contentField}": sorting reaches top-level fields only.`);
381
+ }
382
+ }
364
383
  if (sort.direction !== undefined && !VALID_SORT_DIRECTIONS.has(sort.direction)) {
365
384
  throw new StackQueryError(`Invalid sort direction "${sort.direction}": expected "asc" or "desc".`);
366
385
  }
367
386
  }
387
+ /**
388
+ * Fail loud rather than silently reorder: an adapter that can't honor the
389
+ * requested sort would otherwise answer in some other order, which a
390
+ * caller paging a bounded window has no way to notice. The companion to
391
+ * assertQueryCapabilities(), split from it because a sort is not a filter
392
+ * — see docs/spec/data-model.md § Capability-gated filters.
393
+ */
394
+ export function assertSortCapability(sort, capabilities) {
395
+ if (!sort)
396
+ return;
397
+ if (sort.contentField !== undefined) {
398
+ if (!capabilities.contentFieldSort) {
399
+ throw new StackQueryError('Query uses sort.contentField, but this adapter does not declare the contentFieldSort ' +
400
+ 'capability.');
401
+ }
402
+ return;
403
+ }
404
+ const field = sort.field ?? 'createdAt';
405
+ if (!capabilities.sortableFields.includes(field)) {
406
+ throw new StackQueryError(`Query sorts by "${field}", which this adapter does not declare in sortableFields.`);
407
+ }
408
+ }
368
409
  /** The identifier spaces a relationship target may name. */
369
410
  const TARGET_SCOPES = new Set(['record', 'entity', 'external']);
370
411
  /**
@@ -1469,6 +1510,7 @@ export class Stack {
1469
1510
  const { presentAt, filter, limit: rawLimit, ...rest } = query;
1470
1511
  assertQueryCapabilities(filter, this.adapter.capabilities);
1471
1512
  assertValidSort(query.sort);
1513
+ assertSortCapability(query.sort, this.adapter.capabilities);
1472
1514
  assertValidRelatedTo(filter?.relatedTo);
1473
1515
  const limit = rawLimit !== undefined ? Math.min(rawLimit, MAX_QUERY_LIMIT) : undefined;
1474
1516
  const resolvedFilter = await this.resolveBaseIdFilter(filter);
@@ -1770,37 +1812,24 @@ export class Stack {
1770
1812
  /**
1771
1813
  * mimeType is a property of the fileId, not the uploader's perspective:
1772
1814
  * the first metadata record for a fileId fixes it, and a conflicting
1773
- * later upload is rejected. Cursor-walked so earlier records past page
1774
- * one are seen. See docs/spec/attachments.md § The `_attachment` record
1775
- * type.
1815
+ * later upload is rejected. See docs/spec/attachments.md § The
1816
+ * `_attachment` record type.
1776
1817
  *
1777
1818
  * Best-effort by construction — check-then-create with no storage-level
1778
1819
  * uniqueness behind it, so two racing first uploads can both land on a
1779
- * concurrent server. What survives that race is the *resolution*: which
1780
- * record establishes the type is firstRecordedAttachment()'s total
1781
- * order, the same one a server serving Content-Type applies, so both
1782
- * sides name the same winner however many records exist.
1820
+ * concurrent server. What survives that race is the *resolution*:
1821
+ * getAttachmentRecords() returns the candidates in the same total order
1822
+ * a server serving Content-Type applies, so both sides name the same
1823
+ * winner however many records exist.
1783
1824
  */
1784
1825
  async checkAttachmentMimeTypeOnCreate(content) {
1785
1826
  const { fileId, mimeType } = content;
1786
1827
  if (typeof fileId !== 'string')
1787
1828
  return; // schema validation already rejected this
1788
- const metadataTypeId = `${SYSTEM_TYPES.ATTACHMENT}@1`;
1789
- const results = await queryAllPages((q) => this.query(q), {
1790
- filter: {
1791
- typeId: metadataTypeId,
1792
- includeDeleted: true,
1793
- includeUnlisted: true,
1794
- ...(this.features.contentFieldQuery && { content: { fileId } }),
1795
- },
1796
- });
1797
- const existing = this.features.contentFieldQuery
1798
- ? results
1799
- : results.filter((r) => r.content.fileId === fileId);
1829
+ const existing = await this.getAttachmentRecords(fileId);
1800
1830
  if (existing.length === 0)
1801
1831
  return;
1802
- const first = firstRecordedAttachment(existing);
1803
- const establishedMimeType = first.content.mimeType;
1832
+ const establishedMimeType = existing[0].content.mimeType;
1804
1833
  if (mimeType !== establishedMimeType) {
1805
1834
  // Deliberately does not name the established mimeType — that would
1806
1835
  // confirm a guessed fileId's content type. See the anti-oracle rule
@@ -1911,26 +1940,61 @@ export class Stack {
1911
1940
  return this.adapter.getAttachment(fileId);
1912
1941
  }
1913
1942
  /**
1914
- * Delete an attachment's bytes and its _attachment@1 metadata record(s).
1915
- * Throws StackConflictError if any record in the stack still references the file.
1916
- * Throws StackNotFoundError if neither metadata records nor bytes exist.
1943
+ * Every `_attachment` record describing `fileId`, earliest-recorded
1944
+ * first — so `records[0]` is the one that establishes the file's
1945
+ * mimeType, and firstRecordedAttachment() is only needed by a caller
1946
+ * narrowing the set further. See docs/spec/attachments.md § Finding a
1947
+ * `fileId`'s metadata records.
1948
+ *
1949
+ * On `Stack` and not `StackClient`: the lookup answers a presentation
1950
+ * question about an access decision already made, so a scoped version
1951
+ * would impose a second, different permission check rather than a
1952
+ * narrower correct answer.
1953
+ */
1954
+ async getAttachmentRecords(fileId) {
1955
+ this.assertOpen();
1956
+ const results = await queryAllPages((q) => this.query(q), {
1957
+ filter: {
1958
+ baseId: SYSTEM_TYPES.ATTACHMENT,
1959
+ includeDeleted: true,
1960
+ includeUnlisted: true,
1961
+ ...(this.features.contentFieldQuery && { content: { fileId } }),
1962
+ },
1963
+ });
1964
+ return results
1965
+ .filter((r) => r.content.fileId === fileId)
1966
+ .sort(compareRecordedAttachments);
1967
+ }
1968
+ /**
1969
+ * The `_attachment` family as concrete typeIds. Resolved here rather
1970
+ * than in the adapter, which has no baseId concept of its own — see
1971
+ * resolveBaseIdFilter().
1972
+ */
1973
+ async attachmentTypeIds() {
1974
+ const types = await this.adapter.listTypes();
1975
+ return types.filter((t) => t.baseId === SYSTEM_TYPES.ATTACHMENT).map((t) => t.id);
1976
+ }
1977
+ /**
1978
+ * Delete an attachment's bytes and every _attachment metadata record for
1979
+ * it, family-wide. Throws StackConflictError if any record in the stack
1980
+ * still references the file, StackNotFoundError if neither metadata
1981
+ * records nor bytes exist.
1917
1982
  */
1918
1983
  async deleteAttachment(fileId, opts = {}) {
1919
1984
  this.assertOpen();
1920
- const metadataTypeId = `${SYSTEM_TYPES.ATTACHMENT}@1`;
1921
1985
  let deletedRecords;
1922
1986
  if (this.adapter.deleteUnreferencedAttachmentRecords) {
1923
1987
  // Purged inside the adapter's own transaction, so these never reach
1924
1988
  // delete() and are announced here instead — from the records it
1925
1989
  // hands back, which are the last copies that will ever exist.
1926
- deletedRecords = await this.adapter.deleteUnreferencedAttachmentRecords(fileId, metadataTypeId);
1990
+ deletedRecords = await this.adapter.deleteUnreferencedAttachmentRecords(fileId, await this.attachmentTypeIds());
1927
1991
  const at = new Date();
1928
1992
  for (const record of deletedRecords) {
1929
1993
  this.emitChange('hard-delete', record, { actor: Stack.purgeActor(opts), at });
1930
1994
  }
1931
1995
  }
1932
1996
  else {
1933
- deletedRecords = await this.deleteUnreferencedAttachmentRecordsFallback(fileId, metadataTypeId, opts);
1997
+ deletedRecords = await this.deleteUnreferencedAttachmentRecordsFallback(fileId, opts);
1934
1998
  }
1935
1999
  if (!deletedRecords.length) {
1936
2000
  try {
@@ -1947,7 +2011,7 @@ export class Stack {
1947
2011
  * deleteUnreferencedAttachmentRecords(): a concurrent associate() can
1948
2012
  * race between the reference check below and the deletes it guards.
1949
2013
  */
1950
- async deleteUnreferencedAttachmentRecordsFallback(fileId, metadataTypeId, opts = {}) {
2014
+ async deleteUnreferencedAttachmentRecordsFallback(fileId, opts = {}) {
1951
2015
  // A soft-deleted or unlisted record still counts as a reference — it
1952
2016
  // must find its attachments intact on undelete or relisting. See
1953
2017
  // docs/spec/attachments.md § Deleting attachments.
@@ -1958,20 +2022,9 @@ export class Stack {
1958
2022
  if (refResult.records.length > 0) {
1959
2023
  throw new StackConflictError('Attachment is still referenced by one or more records');
1960
2024
  }
1961
- // Cursor-walk with includeDeleted/includeUnlisted: metadata past page
1962
- // one, soft-deleted, or unlisted must be cleaned up too — not left
1963
- // pointing at deleted bytes.
1964
- const metaResults = await queryAllPages((q) => this.query(q), {
1965
- filter: {
1966
- typeId: metadataTypeId,
1967
- includeDeleted: true,
1968
- includeUnlisted: true,
1969
- ...(this.features.contentFieldQuery && { content: { fileId } }),
1970
- },
1971
- });
1972
- const metaRecords = this.features.contentFieldQuery
1973
- ? metaResults
1974
- : metaResults.filter((r) => r.content.fileId === fileId);
2025
+ // Soft-deleted, unlisted, and later-version metadata is cleaned up
2026
+ // too — none of it may be left pointing at deleted bytes.
2027
+ const metaRecords = await this.getAttachmentRecords(fileId);
1975
2028
  for (const record of metaRecords) {
1976
2029
  await this.delete(record.id, { hard: true, ...opts });
1977
2030
  }
@@ -1988,9 +2041,8 @@ export class Stack {
1988
2041
  const graceMs = opts.graceMs ?? DEFAULT_GC_GRACE_MS;
1989
2042
  const dryRun = opts.dryRun ?? false;
1990
2043
  const now = Date.now();
1991
- const metadataTypeId = `${SYSTEM_TYPES.ATTACHMENT}@1`;
1992
2044
  const metaRecords = await queryAllPages((q) => this.query(q), {
1993
- filter: { typeId: metadataTypeId, includeDeleted: true, includeUnlisted: true },
2045
+ filter: { baseId: SYSTEM_TYPES.ATTACHMENT, includeDeleted: true, includeUnlisted: true },
1994
2046
  });
1995
2047
  // Newest metadata record's createdAt per fileId, and its size (constant
1996
2048
  // across records sharing a fileId, since content-addressing guarantees
@@ -3172,6 +3224,7 @@ export class ScopedStack {
3172
3224
  */
3173
3225
  async query(query = {}) {
3174
3226
  assertValidSort(query.sort);
3227
+ assertSortCapability(query.sort, this.stack.features);
3175
3228
  assertValidRelatedTo(query.filter?.relatedTo);
3176
3229
  if (query.filter?.includeUnlisted && !this.ownerActingAlone) {
3177
3230
  throw new StackPermissionError('includeUnlisted is owner-only');