@haverstack/core 0.27.0 → 0.28.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/changes.d.ts +11 -0
- package/dist/changes.d.ts.map +1 -1
- package/dist/changes.js +9 -1
- package/dist/changes.js.map +1 -1
- package/dist/combine.d.ts.map +1 -1
- package/dist/combine.js +1 -0
- package/dist/combine.js.map +1 -1
- package/dist/stack.d.ts +79 -8
- package/dist/stack.d.ts.map +1 -1
- package/dist/stack.js +199 -13
- package/dist/stack.js.map +1 -1
- package/dist/testing.d.ts +4 -0
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +26 -1
- package/dist/testing.js.map +1 -1
- package/dist/types.d.ts +32 -4
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +2 -2
package/dist/stack.js
CHANGED
|
@@ -657,6 +657,20 @@ const DEFAULT_GC_GRACE_MS = 24 * 60 * 60 * 1000;
|
|
|
657
657
|
* pagination cursor a StackQueryError rather than a content-validation
|
|
658
658
|
* failure. See StackQueryError's doc comment.
|
|
659
659
|
*/
|
|
660
|
+
/**
|
|
661
|
+
* The same format rule applied to a `parentId` a caller names. Separate
|
|
662
|
+
* from validateRecordId() only for its message: the id under discussion is
|
|
663
|
+
* the destination, not the record being written, and a shared message would
|
|
664
|
+
* report the wrong one. See docs/spec/data-model.md § Reparenting.
|
|
665
|
+
*/
|
|
666
|
+
function validateParentId(parentId) {
|
|
667
|
+
if (parentId.startsWith(RESERVED_ID_PREFIX)) {
|
|
668
|
+
throw new StackQueryError(`Invalid parentId "${parentId}": uses the reserved "${RESERVED_ID_PREFIX}" prefix.`);
|
|
669
|
+
}
|
|
670
|
+
if (!isValidIdFormat(parentId)) {
|
|
671
|
+
throw new StackQueryError(`Invalid parentId "${parentId}": expected 12 lowercase Crockford base-32 characters.`);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
660
674
|
function validateRecordId(id) {
|
|
661
675
|
if (id.startsWith(RESERVED_ID_PREFIX)) {
|
|
662
676
|
throw new StackQueryError(`ID "${id}" uses the reserved "${RESERVED_ID_PREFIX}" prefix.`);
|
|
@@ -1193,6 +1207,13 @@ export class Stack {
|
|
|
1193
1207
|
if (opts.createdAt !== undefined) {
|
|
1194
1208
|
validateIdTimestampSkew(opts.id, this.idTimestampSkewMsValue, opts.createdAt.getTime(), 'createdAt');
|
|
1195
1209
|
}
|
|
1210
|
+
// A generated id names nothing, so a create under a parent cannot
|
|
1211
|
+
// close a loop. A caller-supplied one can: existing records may
|
|
1212
|
+
// already point at it, and the chain above the parent can lead back
|
|
1213
|
+
// to it — so the walk is asked only for that combination.
|
|
1214
|
+
// See docs/spec/data-model.md § Reparenting.
|
|
1215
|
+
if (opts.parentId !== undefined)
|
|
1216
|
+
await this.assertNoParentCycle(opts.id, opts.parentId);
|
|
1196
1217
|
}
|
|
1197
1218
|
const associations = baseIdOf(typeId) === SYSTEM_TYPES.GROUP
|
|
1198
1219
|
? stampGroupAdmin(opts.associations, opts.entityId ?? this.ownerEntityId)
|
|
@@ -1210,6 +1231,20 @@ export class Stack {
|
|
|
1210
1231
|
(opts.createdAt !== undefined
|
|
1211
1232
|
? generateIdForTimestamp(createdAt.getTime())
|
|
1212
1233
|
: generateId(createdAt.getTime()));
|
|
1234
|
+
// An id field a caller supplies is a value or it is absent — never the
|
|
1235
|
+
// empty string, which names nobody. Refused rather than dropped: an
|
|
1236
|
+
// ignored field is the silent-normalization this mapper family was just
|
|
1237
|
+
// fixed for, one layer up.
|
|
1238
|
+
for (const field of ['entityId', 'appId', 'principalId']) {
|
|
1239
|
+
if (opts[field] === '') {
|
|
1240
|
+
throw new StackQueryError(`Invalid ${field}: the empty string is not an id.`);
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
// Every create naming a parent owes the reference check, whether or not
|
|
1244
|
+
// it supplied an id: a destination a caller names has to be one that
|
|
1245
|
+
// exists. This is the read an ordinary create used to skip.
|
|
1246
|
+
if (opts.parentId !== undefined)
|
|
1247
|
+
await this.assertParentExists(id, opts.parentId);
|
|
1213
1248
|
const record = {
|
|
1214
1249
|
id,
|
|
1215
1250
|
typeId,
|
|
@@ -1217,10 +1252,14 @@ export class Stack {
|
|
|
1217
1252
|
updatedAt,
|
|
1218
1253
|
content,
|
|
1219
1254
|
version: 1,
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1255
|
+
// Read for presence, not truthiness — every one of these is checked
|
|
1256
|
+
// above, so '' never reaches here and absence is the only thing a
|
|
1257
|
+
// falsy value could mean. Presence says that outright instead of
|
|
1258
|
+
// relying on it.
|
|
1259
|
+
...(opts.parentId !== undefined && { parentId: opts.parentId }),
|
|
1260
|
+
...(opts.entityId !== undefined && { entityId: opts.entityId }),
|
|
1261
|
+
...(opts.appId !== undefined && { appId: opts.appId }),
|
|
1262
|
+
...(opts.principalId !== undefined && { principalId: opts.principalId }),
|
|
1224
1263
|
// A create's actor is its author, so these are derived rather than
|
|
1225
1264
|
// taken: stamping them here keeps "absent means an unscoped write"
|
|
1226
1265
|
// true of version 1 as it is of every later version.
|
|
@@ -1445,6 +1484,96 @@ export class Stack {
|
|
|
1445
1484
|
this.emitChange(unlisted ? 'unlist' : 'list', updated);
|
|
1446
1485
|
return updated;
|
|
1447
1486
|
}
|
|
1487
|
+
/**
|
|
1488
|
+
* Move a record into another container, or to the root with `null`.
|
|
1489
|
+
* Snapshots and bumps version, same as setUnlisted(). No-op if the
|
|
1490
|
+
* record is already there. Returns the record as it now stands —
|
|
1491
|
+
* unchanged on a no-op.
|
|
1492
|
+
*
|
|
1493
|
+
* `parentId` is the one native field a write-holder may move after
|
|
1494
|
+
* creation, and it confers nothing: containment is not an
|
|
1495
|
+
* access-control edge, so a move changes which queries and feeds
|
|
1496
|
+
* enumerate a record and nothing about who may read it. See
|
|
1497
|
+
* docs/spec/data-model.md § Reparenting.
|
|
1498
|
+
*/
|
|
1499
|
+
async setParent(id, parentId, opts = {}) {
|
|
1500
|
+
this.assertOpen();
|
|
1501
|
+
const existing = await this.adapter.getRecord(id);
|
|
1502
|
+
if (!existing) {
|
|
1503
|
+
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
1504
|
+
}
|
|
1505
|
+
this.checkIfVersion(existing, opts.ifVersion);
|
|
1506
|
+
const previousParentId = existing.parentId ?? null;
|
|
1507
|
+
if (previousParentId === parentId)
|
|
1508
|
+
return existing;
|
|
1509
|
+
if (parentId !== null) {
|
|
1510
|
+
await this.assertParentExists(id, parentId);
|
|
1511
|
+
await this.assertNoParentCycle(id, parentId);
|
|
1512
|
+
}
|
|
1513
|
+
const updated = await this.adapter.setParent(id, parentId, {
|
|
1514
|
+
expectedVersion: opts.ifVersion,
|
|
1515
|
+
snapshot: this.buildVersionSnapshot(existing),
|
|
1516
|
+
updatedBy: opts.updatedBy,
|
|
1517
|
+
updatedVia: opts.updatedVia,
|
|
1518
|
+
});
|
|
1519
|
+
this.emitChange('reparent', updated, { previousParentId });
|
|
1520
|
+
return updated;
|
|
1521
|
+
}
|
|
1522
|
+
/**
|
|
1523
|
+
* Refuse an edge that would make a record its own ancestor — asked at
|
|
1524
|
+
* every site that adds one: setParent(), a restore putting a container
|
|
1525
|
+
* back, and a create naming both its own id and a parent. Nothing
|
|
1526
|
+
* downstream (a generator deriving a page path, a folder view) is
|
|
1527
|
+
* written to survive a cycle.
|
|
1528
|
+
*
|
|
1529
|
+
* Walks with the unscoped adapter deliberately: a walk that skipped the
|
|
1530
|
+
* links a requester cannot read would let a cycle be assembled through
|
|
1531
|
+
* them and break the invariant for every reader. The walk is bounded
|
|
1532
|
+
* because a chain long enough to exhaust it is already pathological.
|
|
1533
|
+
*
|
|
1534
|
+
* Read-then-write, so two moves racing on opposite ends of one chain can
|
|
1535
|
+
* both pass — closing that means the invariant lives in the adapter,
|
|
1536
|
+
* where the write is atomic, which puts a graph constraint in the
|
|
1537
|
+
* storage contract every adapter then implements. Same deferral as
|
|
1538
|
+
* checkBindingUnique() above. Consumers that walk `parentId` should
|
|
1539
|
+
* carry a visited set rather than trust this alone; MAX_PARENT_DEPTH is
|
|
1540
|
+
* the same posture applied here.
|
|
1541
|
+
* See docs/spec/data-model.md § Reparenting.
|
|
1542
|
+
*/
|
|
1543
|
+
/**
|
|
1544
|
+
* The checks a *caller-named* destination owes, before the cycle walk:
|
|
1545
|
+
* `parentId` is a real, well-formed record id. Format first, so a
|
|
1546
|
+
* malformed one is a 400 naming the problem rather than a read that
|
|
1547
|
+
* cannot match. Existence closes the gap between the owner path and a
|
|
1548
|
+
* non-owner's, where canReadReferent() already refuses a parent that
|
|
1549
|
+
* isn't there.
|
|
1550
|
+
*
|
|
1551
|
+
* restoreVersion() deliberately does not call this — see its own comment.
|
|
1552
|
+
* See docs/spec/data-model.md § Reparenting.
|
|
1553
|
+
*/
|
|
1554
|
+
async assertParentExists(id, parentId) {
|
|
1555
|
+
validateParentId(parentId);
|
|
1556
|
+
if (!(await this.adapter.getRecord(parentId))) {
|
|
1557
|
+
throw new StackConflictError(`Cannot parent record "${id}" to "${parentId}": no such record. A container has to ` +
|
|
1558
|
+
'exist when it is named.');
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
async assertNoParentCycle(id, parentId) {
|
|
1562
|
+
let cursor = parentId;
|
|
1563
|
+
for (let depth = 0; cursor !== undefined; depth++) {
|
|
1564
|
+
if (cursor === id) {
|
|
1565
|
+
throw new StackConflictError(`Cannot parent record "${id}" to "${parentId}": it is a descendant of "${id}", ` +
|
|
1566
|
+
'and the move would make the record its own ancestor.');
|
|
1567
|
+
}
|
|
1568
|
+
// The cap bounds work, and guarantees this terminates on a chain that
|
|
1569
|
+
// is already cyclic. It does not refuse the move: a chain this long is
|
|
1570
|
+
// past what the check can speak to, not evidence of a loop, and
|
|
1571
|
+
// refusing it would claim an invariant core does not maintain.
|
|
1572
|
+
if (depth >= MAX_PARENT_DEPTH)
|
|
1573
|
+
return;
|
|
1574
|
+
cursor = (await this.adapter.getRecord(cursor))?.parentId;
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1448
1577
|
/**
|
|
1449
1578
|
* Soft-delete a record (default) or hard-delete it permanently, removing
|
|
1450
1579
|
* the record and its version history. Soft delete snapshots and bumps
|
|
@@ -1571,9 +1700,10 @@ export class Stack {
|
|
|
1571
1700
|
/**
|
|
1572
1701
|
* Restore a record to a previous version by creating a new version —
|
|
1573
1702
|
* never rewrites history. The snapshot is validated against its own
|
|
1574
|
-
* stored typeId (not the record's current type), restores associations
|
|
1575
|
-
* and
|
|
1576
|
-
*
|
|
1703
|
+
* stored typeId (not the record's current type), restores associations
|
|
1704
|
+
* and `parentId` (absent on the snapshot is the root, so a restore
|
|
1705
|
+
* always settles containment), and never restores permissions. See
|
|
1706
|
+
* docs/spec/versioning.md § Restore semantics.
|
|
1577
1707
|
*/
|
|
1578
1708
|
async restoreVersion(id, version, opts = {}) {
|
|
1579
1709
|
this.assertOpen();
|
|
@@ -1605,13 +1735,29 @@ export class Stack {
|
|
|
1605
1735
|
for (const field of bindingFieldsOf(baseIdOf(target.typeId))) {
|
|
1606
1736
|
this.checkBindingImmutable(baseIdOf(target.typeId), field, existing.content[field], target.content[field]);
|
|
1607
1737
|
}
|
|
1738
|
+
// A restore that puts a container back is an edge-adding site like
|
|
1739
|
+
// setParent(), and the chain above that container may have moved since
|
|
1740
|
+
// the snapshot was taken. The cycle walk therefore applies — but
|
|
1741
|
+
// assertParentExists() deliberately does not: a restore is not a caller
|
|
1742
|
+
// naming a destination, it is history being put back, and the container
|
|
1743
|
+
// may have been hard-deleted since. Refusing here would make an
|
|
1744
|
+
// unrelated deletion cost the record its content rollback, which is the
|
|
1745
|
+
// recoverability this whole verb exists for. A dangling parentId is
|
|
1746
|
+
// legal at rest anyway — deleting a container never touches its
|
|
1747
|
+
// children. See docs/spec/versioning.md § Restore semantics.
|
|
1748
|
+
const previousParentId = existing.parentId ?? null;
|
|
1749
|
+
const targetParentId = target.parentId ?? null;
|
|
1750
|
+
const moves = targetParentId !== previousParentId;
|
|
1751
|
+
if (moves && targetParentId !== null) {
|
|
1752
|
+
await this.assertNoParentCycle(id, targetParentId);
|
|
1753
|
+
}
|
|
1608
1754
|
const restored = await this.adapter.restoreVersion(id, version, {
|
|
1609
1755
|
expectedVersion: opts.ifVersion,
|
|
1610
1756
|
snapshot: this.buildVersionSnapshot(existing),
|
|
1611
1757
|
updatedBy: opts.updatedBy,
|
|
1612
1758
|
updatedVia: opts.updatedVia,
|
|
1613
1759
|
});
|
|
1614
|
-
this.emitChange('restore', restored);
|
|
1760
|
+
this.emitChange('restore', restored, moves ? { previousParentId } : {});
|
|
1615
1761
|
return restored;
|
|
1616
1762
|
}
|
|
1617
1763
|
/**
|
|
@@ -2424,8 +2570,9 @@ export class Stack {
|
|
|
2424
2570
|
/**
|
|
2425
2571
|
* Snapshot of a record's prior state, passed with the mutating adapter
|
|
2426
2572
|
* call so snapshot and mutation land in one atomic write. `associations`
|
|
2427
|
-
*
|
|
2428
|
-
*
|
|
2573
|
+
* and `parentId` are always present (`[]` and `null` where the record has
|
|
2574
|
+
* neither) so restore can distinguish "cleared" and "at the root" from a
|
|
2575
|
+
* snapshot that omits the key entirely ("leave as-is"). See
|
|
2429
2576
|
* docs/spec/versioning.md § Version history.
|
|
2430
2577
|
*/
|
|
2431
2578
|
buildVersionSnapshot(record) {
|
|
@@ -2437,6 +2584,7 @@ export class Stack {
|
|
|
2437
2584
|
...(record.entityId && { entityId: record.entityId }),
|
|
2438
2585
|
...(record.updatedBy && { updatedBy: record.updatedBy }),
|
|
2439
2586
|
...(record.updatedVia && { updatedVia: record.updatedVia }),
|
|
2587
|
+
...(record.parentId !== undefined && { parentId: record.parentId }),
|
|
2440
2588
|
associations: record.associations ?? [],
|
|
2441
2589
|
...(record.permissions && { permissions: record.permissions }),
|
|
2442
2590
|
};
|
|
@@ -2494,6 +2642,13 @@ function permissionsEqual(a, b) {
|
|
|
2494
2642
|
/** Default page size used to fill a permission-filtered query result. */
|
|
2495
2643
|
const DEFAULT_QUERY_LIMIT = 50;
|
|
2496
2644
|
const MAX_QUERY_LIMIT = 1000;
|
|
2645
|
+
/**
|
|
2646
|
+
* How far setParent() will walk a proposed ancestor chain before refusing
|
|
2647
|
+
* the move. Bounds the reads one call can cost; a hierarchy deeper than
|
|
2648
|
+
* this is beyond what `parentId` is for. See docs/spec/data-model.md
|
|
2649
|
+
* § Reparenting.
|
|
2650
|
+
*/
|
|
2651
|
+
const MAX_PARENT_DEPTH = 64;
|
|
2497
2652
|
/**
|
|
2498
2653
|
* Ensures `creator` carries an `admin` relationship association, adding one
|
|
2499
2654
|
* if it's not already present. Used to bootstrap a `_group` record's first
|
|
@@ -3444,6 +3599,25 @@ export class ScopedStack {
|
|
|
3444
3599
|
this.refuseIfDeleted(record, true);
|
|
3445
3600
|
return this.stack.setUnlisted(id, unlisted, { ...opts, ...this.actor });
|
|
3446
3601
|
}
|
|
3602
|
+
/**
|
|
3603
|
+
* Move a record into another container, or to the root with `null`.
|
|
3604
|
+
* Gated as an ordinary write on the record, plus read access to the
|
|
3605
|
+
* destination — the same reference gate `create()` applies to a
|
|
3606
|
+
* `parentId`, asked again here so a move cannot reach a container an
|
|
3607
|
+
* authoring call could not have named. Not reshare-gated like
|
|
3608
|
+
* setUnlisted(): containment decides which listings enumerate a record,
|
|
3609
|
+
* never who may read it, so a move discloses it to nobody who could not
|
|
3610
|
+
* already read it. The origin is ungated — knowing it requires reading
|
|
3611
|
+
* the record, which this caller has already had to do. See
|
|
3612
|
+
* docs/spec/access-control.md § Reference-creation gating.
|
|
3613
|
+
*/
|
|
3614
|
+
async setParent(id, parentId, opts = {}) {
|
|
3615
|
+
await this.requireUpdatable(id);
|
|
3616
|
+
if (parentId !== null && !(await this.canReadReferent(parentId))) {
|
|
3617
|
+
throw new StackPermissionError();
|
|
3618
|
+
}
|
|
3619
|
+
return this.stack.setParent(id, parentId, { ...opts, ...this.actor });
|
|
3620
|
+
}
|
|
3447
3621
|
/**
|
|
3448
3622
|
* Hard delete is owner-only: it is irreversible and destroys version
|
|
3449
3623
|
* history, so neither the write bit nor delete-own/delete-any grants
|
|
@@ -3493,9 +3667,11 @@ export class ScopedStack {
|
|
|
3493
3667
|
/**
|
|
3494
3668
|
* Re-runs the reference-creation checks against the snapshot, so a
|
|
3495
3669
|
* restore can't re-convey access to a file or record the subject can no
|
|
3496
|
-
* longer reach today
|
|
3497
|
-
*
|
|
3498
|
-
*
|
|
3670
|
+
* longer reach today — the snapshot's `parentId` among them, gated
|
|
3671
|
+
* exactly as setParent() gates a destination named directly. Only the
|
|
3672
|
+
* owner acting alone is exempt: under delegation the checks resolve
|
|
3673
|
+
* against the subject, which is whose reach the restore would widen.
|
|
3674
|
+
* See docs/spec/versioning.md § Restore semantics.
|
|
3499
3675
|
*/
|
|
3500
3676
|
async restoreVersion(id, version, opts = {}) {
|
|
3501
3677
|
const record = await this.requireUpdatable(id);
|
|
@@ -3506,6 +3682,16 @@ export class ScopedStack {
|
|
|
3506
3682
|
// decision update() reserves to the owner, reached by another route.
|
|
3507
3683
|
this.requireOwnerForAppIdentity(record.typeId, (field) => target.content[field] !==
|
|
3508
3684
|
record.content[field]);
|
|
3685
|
+
// Only a restore that moves the record *into* a container creates a
|
|
3686
|
+
// reference. A snapshot at the root names nothing, and a parentId
|
|
3687
|
+
// the restore would not change is not re-gated: the record is
|
|
3688
|
+
// already there, so a content rollback is not refused over a move
|
|
3689
|
+
// it isn't making.
|
|
3690
|
+
if (target.parentId !== undefined &&
|
|
3691
|
+
target.parentId !== record.parentId &&
|
|
3692
|
+
!(await this.canReadReferent(target.parentId))) {
|
|
3693
|
+
throw new StackPermissionError();
|
|
3694
|
+
}
|
|
3509
3695
|
await this.requireFileRefAccess(target.typeId, target.content);
|
|
3510
3696
|
for (const association of target.associations ?? []) {
|
|
3511
3697
|
await this.requireAssociationAccess(target.typeId, association);
|