@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/adapter-entry.d.ts +4 -2
- 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/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 +12 -4
- package/dist/stack.d.ts.map +1 -1
- package/dist/stack.js +87 -33
- package/dist/stack.js.map +1 -1
- package/dist/testing.d.ts +19 -6
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +72 -18
- package/dist/testing.js.map +1 -1
- package/dist/types.d.ts +105 -39
- package/dist/types.d.ts.map +1 -1
- package/dist/wire-request.d.ts +1 -1
- package/dist/wire-request.d.ts.map +1 -1
- package/dist/wire-request.js +30 -11
- package/dist/wire-request.js.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
302
|
-
|
|
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
|
-
|
|
305
|
+
const present = filter?.contentPresent?.length ? filter.contentPresent : undefined;
|
|
306
|
+
if (!filter?.content && !present)
|
|
305
307
|
return;
|
|
306
|
-
if (
|
|
307
|
-
throw new StackQueryError(
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
* `
|
|
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,
|
|
477
|
-
if (
|
|
478
|
-
throw new StackPayloadTooLargeError(`Attachment (${byteLength} bytes) exceeds the ${
|
|
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 `
|
|
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,
|
|
490
|
-
if (
|
|
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 >
|
|
494
|
-
throw new StackPayloadTooLargeError(`${what} (${byteLength} bytes) exceeds the ${
|
|
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,
|
|
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
|
-
...(
|
|
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 (
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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,
|