@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/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
- ...(opts.parentId && { parentId: opts.parentId }),
1221
- ...(opts.entityId && { entityId: opts.entityId }),
1222
- ...(opts.appId && { appId: opts.appId }),
1223
- ...(opts.principalId && { principalId: opts.principalId }),
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 never restores permissions. See docs/spec/versioning.md § Restore
1576
- * semantics.
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
- * is always present ([] when empty) so restore can distinguish "cleared"
2428
- * from a snapshot that omits the key entirely ("leave as-is"). See
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. Only the owner acting alone is exempt: under
3497
- * delegation the checks resolve against the subject, which is whose reach
3498
- * the restore would widen. See docs/spec/versioning.md § Restore semantics.
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);