@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/adapter-entry.d.ts +3 -1
- package/dist/adapter-entry.d.ts.map +1 -1
- package/dist/adapter-entry.js +2 -1
- package/dist/adapter-entry.js.map +1 -1
- package/dist/attachment-download.d.ts +10 -0
- package/dist/attachment-download.d.ts.map +1 -1
- package/dist/attachment-download.js +13 -8
- package/dist/attachment-download.js.map +1 -1
- package/dist/combine.js +1 -1
- package/dist/combine.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/sort.d.ts +58 -0
- package/dist/sort.d.ts.map +1 -0
- package/dist/sort.js +110 -0
- package/dist/sort.js.map +1 -0
- package/dist/stack.d.ts +41 -12
- package/dist/stack.d.ts.map +1 -1
- package/dist/stack.js +101 -48
- package/dist/stack.js.map +1 -1
- package/dist/testing.d.ts +15 -0
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +46 -0
- package/dist/testing.js.map +1 -1
- package/dist/types.d.ts +49 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/wire-request.d.ts.map +1 -1
- package/dist/wire-request.js +29 -10
- package/dist/wire-request.js.map +1 -1
- package/package.json +1 -1
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 {
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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.
|
|
1774
|
-
*
|
|
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*:
|
|
1780
|
-
*
|
|
1781
|
-
*
|
|
1782
|
-
*
|
|
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
|
|
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
|
|
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
|
-
*
|
|
1915
|
-
*
|
|
1916
|
-
*
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
//
|
|
1962
|
-
//
|
|
1963
|
-
|
|
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: {
|
|
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');
|