@haverstack/core 0.28.0 → 0.29.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.
Files changed (46) hide show
  1. package/README.md +9 -2
  2. package/dist/access.d.ts +20 -0
  3. package/dist/access.d.ts.map +1 -1
  4. package/dist/access.js +26 -0
  5. package/dist/access.js.map +1 -1
  6. package/dist/changes.d.ts +1 -2
  7. package/dist/changes.d.ts.map +1 -1
  8. package/dist/changes.js +27 -6
  9. package/dist/changes.js.map +1 -1
  10. package/dist/combine.d.ts.map +1 -1
  11. package/dist/combine.js +1 -4
  12. package/dist/combine.js.map +1 -1
  13. package/dist/index.d.ts +1 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/merge.d.ts +1 -1
  17. package/dist/merge.js +2 -2
  18. package/dist/merge.js.map +1 -1
  19. package/dist/schema.d.ts.map +1 -1
  20. package/dist/schema.js +56 -5
  21. package/dist/schema.js.map +1 -1
  22. package/dist/stack.d.ts +104 -90
  23. package/dist/stack.d.ts.map +1 -1
  24. package/dist/stack.js +361 -260
  25. package/dist/stack.js.map +1 -1
  26. package/dist/testing.d.ts +3 -14
  27. package/dist/testing.d.ts.map +1 -1
  28. package/dist/testing.js +27 -41
  29. package/dist/testing.js.map +1 -1
  30. package/dist/types.d.ts +98 -28
  31. package/dist/types.d.ts.map +1 -1
  32. package/dist/types.js +8 -0
  33. package/dist/types.js.map +1 -1
  34. package/dist/validate.d.ts +31 -0
  35. package/dist/validate.d.ts.map +1 -1
  36. package/dist/validate.js +150 -3
  37. package/dist/validate.js.map +1 -1
  38. package/dist/wire-entry.d.ts +1 -1
  39. package/dist/wire-entry.d.ts.map +1 -1
  40. package/dist/wire-entry.js +1 -1
  41. package/dist/wire-entry.js.map +1 -1
  42. package/dist/wire-record.d.ts +16 -1
  43. package/dist/wire-record.d.ts.map +1 -1
  44. package/dist/wire-record.js +56 -0
  45. package/dist/wire-record.js.map +1 -1
  46. package/package.json +1 -1
package/dist/stack.js CHANGED
@@ -15,12 +15,12 @@
15
15
  */
16
16
  import { generateId, generateIdForTimestamp, isValidIdFormat, idTimestamp, MAX_ID_TIMESTAMP, } from './id.js';
17
17
  import { hashSchema, isCompatible, parseTypeId, baseIdOf, diffSchemas, isWellFormedTypeId, } from './schema.js';
18
- import { CONTENT_SEGMENT_METACHARACTERS, SEGMENT_METACHARACTER_RE, validateContent, validateContentKeys, validatePatchValues, validateReservedKeys, validateSchemaFieldNames, } from './validate.js';
18
+ import { CONTENT_SEGMENT_METACHARACTERS, SEGMENT_METACHARACTER_RE, validateContent, validateContentKeys, validatePatchValues, validateReservedKeys, validateSchemaFieldNames, validateSchemaReservedNames, validateSchemaShape, } from './validate.js';
19
19
  import { applyMergePatch } from './merge.js';
20
- import { checkAccess, groupRoleFromAssociations, isOwnerActingAlone, validatePermissions, } from './access.js';
20
+ import { checkAccess, groupRoleFromAssociations, hasGroupAdmin, isGroupAdminAssociation, isOwnerActingAlone, validatePermissions, } from './access.js';
21
21
  import { compareRecordedAttachments } from './attachment-download.js';
22
22
  import { ChangeEmitter, RelayDelivery, Subscription, buildEmission, matchesFilter, passesUnlistedBoundary, } from './changes.js';
23
- import { SYSTEM_TYPES, GRANT_ACTIONS } from './types.js';
23
+ import { SYSTEM_TYPES, GRANT_ACTIONS, RECORD_CHANGE_KEYS } from './types.js';
24
24
  // -------------------------------------------------------
25
25
  // Supporting types
26
26
  // -------------------------------------------------------
@@ -532,7 +532,7 @@ function assertAttachmentSize(byteLength, attachmentBytes) {
532
532
  }
533
533
  /**
534
534
  * The content half of the same pre-check, on Stack.create() and
535
- * Stack.update(). Local adapters declare `limits.contentBytes: null` and skip
535
+ * Stack.mutate(). Local adapters declare `limits.contentBytes: null` and skip
536
536
  * the serialization entirely; only a server declares a ceiling, and its
537
537
  * own request-size limit stays authoritative — this just spares an app the
538
538
  * round trip and gives it a typed failure instead of a 413 it has to
@@ -854,7 +854,7 @@ export class Stack {
854
854
  /**
855
855
  * Announce a mutation that has already been persisted. Called after the
856
856
  * adapter write resolves and before the mutating method settles, so
857
- * `await stack.update(...)` guarantees subscribers have been notified —
857
+ * `await stack.mutate(...)` guarantees subscribers have been notified —
858
858
  * and nothing about work they deferred. A handler cannot fail the write:
859
859
  * there is nothing left to fail. See docs/spec/events.md § Handlers.
860
860
  */
@@ -984,7 +984,17 @@ export class Stack {
984
984
  const priorMax = this.maxDefinedVersion.get(parsed.baseId) ?? 0;
985
985
  if (parsed.version > priorMax)
986
986
  this.maxDefinedVersion.set(parsed.baseId, parsed.version);
987
- const nameErrors = validateSchemaFieldNames(schema);
987
+ // Shape first: the name checks and hashSchema() below both read the
988
+ // schema as a well-formed one, and a schema off the wire is parsed
989
+ // JSON that no compiler has seen.
990
+ const shapeErrors = validateSchemaShape(schema);
991
+ if (shapeErrors.length > 0) {
992
+ throw new StackValidationError(shapeErrors);
993
+ }
994
+ const nameErrors = [
995
+ ...validateSchemaReservedNames(schema),
996
+ ...validateSchemaFieldNames(schema),
997
+ ];
988
998
  if (nameErrors.length > 0) {
989
999
  throw new StackValidationError(nameErrors);
990
1000
  }
@@ -1314,64 +1324,140 @@ export class Stack {
1314
1324
  return opts.presentAt === 'latest' ? this.presentAtLatest(record) : record;
1315
1325
  }
1316
1326
  /**
1317
- * Update a record's content via JSON Merge Patch: omitted fields are
1318
- * kept, null removes a field. Validates the merged result against the
1319
- * record's *current* stored type and never changes typeId (see
1320
- * docs/spec/data-model.md § Type migrations); snapshots the prior state
1321
- * to version history. Associations and permissions have their own methods.
1327
+ * Apply a change set — content patch, `parentId`, `permissions`,
1328
+ * `associations`, `unlisted`, in any combination — as one atomic write
1329
+ * producing one version and one snapshot. Keys are read for presence, so
1330
+ * `unlisted: false` and `parentId: null` are changes.
1331
+ *
1332
+ * Every key present is checked against the record as it stands, and a
1333
+ * change set already satisfied in all of them writes nothing and returns
1334
+ * the record unchanged. The ops the change event carries are derived
1335
+ * from that same comparison, so naming an aspect without moving it is
1336
+ * never reported as moving it.
1337
+ *
1338
+ * Content is validated against the record's *current* stored type and
1339
+ * `typeId` never changes (see docs/spec/data-model.md § Type
1340
+ * migrations). See docs/spec/data-model.md § Mutations.
1322
1341
  */
1323
- async update(id, content, opts = {}) {
1342
+ async mutate(id, changes, opts = {}) {
1324
1343
  this.assertOpen();
1344
+ assertNonEmptyChangeSet(changes);
1325
1345
  const existing = await this.adapter.getRecord(id);
1326
1346
  if (!existing) {
1327
1347
  throw new StackNotFoundError(`Record not found: "${id}"`);
1328
1348
  }
1329
1349
  this.checkIfVersion(existing, opts.ifVersion);
1330
- const type = await this.getTypeCached(existing.typeId);
1331
- if (!type) {
1332
- throw new StackQueryError(`Unknown type: "${existing.typeId}"`);
1333
- }
1334
- // Checked on the raw patch, before the merge: a reserved key in a
1335
- // patch is lost by applyMergePatch rather than stored, and an
1336
- // `undefined` value is indistinguishable from an absent field once
1337
- // merged, so a check on the merged result would see neither. See
1338
- // validateReservedKeys() and validatePatchValues().
1339
- const patchErrors = [
1340
- ...validateReservedKeys(content),
1341
- ...validatePatchValues(content),
1342
- ...validateContentKeys(content),
1343
- ];
1344
- if (patchErrors.length > 0) {
1345
- throw new StackValidationError(patchErrors);
1346
- }
1347
- // The patch is what travels, so the patch is what's measured — a small
1348
- // patch against a large record is not an oversized request.
1349
- assertContentSize(content, this.features.limits.contentBytes, 'Patch');
1350
- // Merge (RFC 7396 / JSON Merge Patch): null values delete a field.
1351
- const merged = applyMergePatch(existing.content, content);
1352
- const errors = validateContent(merged, type.schema);
1353
- if (errors.length > 0) {
1354
- throw new StackValidationError(errors);
1355
- }
1356
- if (existing.typeId === `${SYSTEM_TYPES.ATTACHMENT}@1`) {
1357
- this.checkAttachmentImmutableFields(content, existing.content, merged);
1358
- }
1359
- await this.checkBindingsOnUpdate(existing.typeId, id, content, existing.content, merged);
1360
- if (id === SYSTEM_TYPES.CONFIG) {
1361
- this.checkConfigEntityIdUnchanged(existing.content.entityId, merged.entityId);
1362
- }
1363
- const updated = await this.adapter.patchContent(id, content, {
1350
+ const merged = await this.validateChangeSet(id, existing, changes);
1351
+ const ops = changeSetOps(existing, changes, merged);
1352
+ // Nothing moved, so there is nothing to version. Decided here rather
1353
+ // than in the adapter: "did this change anything" is a question about
1354
+ // the record's meaning, and an adapter is never handed a write that
1355
+ // writes nothing.
1356
+ if (ops.length === 0)
1357
+ return existing;
1358
+ const previousParentId = existing.parentId ?? null;
1359
+ const updated = await this.adapter.mutateRecord(id, effectiveChanges(changes, ops), {
1364
1360
  expectedVersion: opts.ifVersion,
1365
1361
  snapshot: this.buildVersionSnapshot(existing),
1366
1362
  updatedBy: opts.updatedBy,
1367
1363
  updatedVia: opts.updatedVia,
1368
1364
  });
1369
- this.emitChange('update', updated);
1365
+ this.emitChange(ops, updated, ops.includes('reparent') ? { previousParentId } : {});
1370
1366
  return updated;
1371
1367
  }
1368
+ /**
1369
+ * mutate() with `contentPatch` alone. The common case by a wide margin,
1370
+ * and named for what it does rather than for a symmetry with create()
1371
+ * that a patch does not have.
1372
+ */
1373
+ async patchContent(id, patch, opts = {}) {
1374
+ return this.mutate(id, { contentPatch: patch }, opts);
1375
+ }
1376
+ /**
1377
+ * Every check a change set owes before anything is written, in one pass:
1378
+ * one refused key refuses the call, so none of them may run after a
1379
+ * partial write. Returns the merged content when the set carries a
1380
+ * content patch, since the caller needs it to decide whether content
1381
+ * actually moved.
1382
+ *
1383
+ * Parent existence and acyclicity are asked only when the destination
1384
+ * differs from where the record already sits — a move to where it
1385
+ * already is names no new edge, and would otherwise cost a read apiece.
1386
+ */
1387
+ async validateChangeSet(id, existing, changes) {
1388
+ const { contentPatch, permissions, associations, parentId } = changes;
1389
+ const errors = [
1390
+ ...(contentPatch
1391
+ ? [
1392
+ ...validateReservedKeys(contentPatch),
1393
+ ...validatePatchValues(contentPatch),
1394
+ ...validateContentKeys(contentPatch),
1395
+ ]
1396
+ : []),
1397
+ ...(permissions ? validatePermissions(permissions) : []),
1398
+ ...(associations ? validateAssociations(associations) : []),
1399
+ ];
1400
+ if (errors.length > 0)
1401
+ throw new StackValidationError(errors);
1402
+ let merged;
1403
+ if (contentPatch) {
1404
+ // The patch is what travels, so the patch is what's measured — a
1405
+ // small patch against a large record is not an oversized request.
1406
+ assertContentSize(contentPatch, this.features.limits.contentBytes, 'Patch');
1407
+ const type = await this.getTypeCached(existing.typeId);
1408
+ if (!type) {
1409
+ throw new StackQueryError(`Unknown type: "${existing.typeId}"`);
1410
+ }
1411
+ merged = applyMergePatch(existing.content, contentPatch);
1412
+ const contentErrors = validateContent(merged, type.schema);
1413
+ if (contentErrors.length > 0)
1414
+ throw new StackValidationError(contentErrors);
1415
+ if (existing.typeId === `${SYSTEM_TYPES.ATTACHMENT}@1`) {
1416
+ this.checkAttachmentImmutableFields(contentPatch, existing.content, merged);
1417
+ }
1418
+ await this.checkBindingsOnUpdate(existing.typeId, id, contentPatch, existing.content, merged);
1419
+ if (id === SYSTEM_TYPES.CONFIG) {
1420
+ this.checkConfigEntityIdUnchanged(existing.content.entityId, merged.entityId);
1421
+ }
1422
+ }
1423
+ if (associations !== undefined) {
1424
+ this.assertGroupAdminRemains(existing, associations);
1425
+ }
1426
+ if (parentId !== undefined && parentId !== null && parentId !== (existing.parentId ?? null)) {
1427
+ await this.assertParentExists(id, parentId);
1428
+ await this.assertNoParentCycle(id, parentId);
1429
+ }
1430
+ return merged;
1431
+ }
1432
+ /**
1433
+ * Refuse a write that would leave a `_group` Record with no `admin` on
1434
+ * its roster. Asked of the roster the write would *produce* — the one
1435
+ * place a change-set check reads the post-state rather than the
1436
+ * pre-state, because the rule is about what is left behind rather than
1437
+ * what the caller named.
1438
+ *
1439
+ * That framing is the whole of the self-removal question: an `admin`
1440
+ * removing themselves passes while another remains and is refused when
1441
+ * they are the last, and neither case needs to know whose entry went.
1442
+ *
1443
+ * Lives here rather than in `ScopedStack` deliberately. Who may write a
1444
+ * `_group` is a permission question and belongs to the gate up there;
1445
+ * this is an integrity constraint on the Record, so it binds every
1446
+ * requester — the stack owner included, who would otherwise be the hole
1447
+ * that stops anything downstream relying on the invariant.
1448
+ * See docs/spec/identity.md § Group.
1449
+ */
1450
+ assertGroupAdminRemains(record, next) {
1451
+ if (!isGroupRecord(record))
1452
+ return;
1453
+ if (hasGroupAdmin(next))
1454
+ return;
1455
+ throw new StackConflictError(`Cannot leave group "${record.id}" without an admin: a _group record's roster keeps at ` +
1456
+ 'least one `admin` relationship association. Name the incoming admin in the same write.');
1457
+ }
1372
1458
  /**
1373
1459
  * Add an association to a record. Snapshots the record's prior state and
1374
- * bumps version, same as update() — associations are covered by the same
1460
+ * bumps version, same as patchContent() — associations are covered by the same
1375
1461
  * versioning rule as content.
1376
1462
  * If the association already exists (same kind, label, and payload), this
1377
1463
  * is a no-op. Returns the record as it now stands — unchanged on a no-op.
@@ -1416,6 +1502,13 @@ export class Stack {
1416
1502
  if (!(existing.associations ?? []).some((a) => associationEqual(a, association))) {
1417
1503
  return existing;
1418
1504
  }
1505
+ // Removing anything else cannot take the roster to zero, so only an
1506
+ // admin entry is worth deriving the post-state for. Derived rather than
1507
+ // counted so this path and the change set's reach the invariant through
1508
+ // the same helper, asking the same question of the same shape of list.
1509
+ if (isGroupAdminAssociation(association)) {
1510
+ this.assertGroupAdminRemains(existing, (existing.associations ?? []).filter((a) => !associationEqual(a, association)));
1511
+ }
1419
1512
  const updated = await this.adapter.dissociate(id, association, {
1420
1513
  expectedVersion: opts.ifVersion,
1421
1514
  snapshot: this.buildVersionSnapshot(existing),
@@ -1425,104 +1518,10 @@ export class Stack {
1425
1518
  this.emitChange('dissociate', updated);
1426
1519
  return updated;
1427
1520
  }
1428
- /**
1429
- * Replace all permissions on a record. Snapshots and bumps version, same
1430
- * as associate(). Pass an empty array to make the record private (the
1431
- * default). No-op if the new set is deep-equal to the current one. Returns
1432
- * the record as it now stands — unchanged on a no-op.
1433
- */
1434
- async setPermissions(id, permissions, opts = {}) {
1435
- this.assertOpen();
1436
- const errors = validatePermissions(permissions);
1437
- if (errors.length > 0) {
1438
- throw new StackValidationError(errors);
1439
- }
1440
- const existing = await this.adapter.getRecord(id);
1441
- if (!existing) {
1442
- throw new StackNotFoundError(`Record not found: "${id}"`);
1443
- }
1444
- this.checkIfVersion(existing, opts.ifVersion);
1445
- if (permissionsEqual(existing.permissions ?? [], permissions))
1446
- return existing;
1447
- const updated = await this.adapter.setPermissions(id, permissions, {
1448
- expectedVersion: opts.ifVersion,
1449
- snapshot: this.buildVersionSnapshot(existing),
1450
- updatedBy: opts.updatedBy,
1451
- updatedVia: opts.updatedVia,
1452
- });
1453
- this.emitChange('permissions', updated);
1454
- return updated;
1455
- }
1456
- /**
1457
- * Withhold a record from enumeration, or restore it. Orthogonal to
1458
- * setPermissions(): it says nothing about who may read the record, only
1459
- * whether `query()` and the change feed enumerate it by default. No-op if
1460
- * already in the requested state. Returns the record as it now stands —
1461
- * unchanged on a no-op. See docs/spec/access-control.md §
1462
- * Unlisted records.
1463
- *
1464
- * The op passed to emitChange() carries the transition direction —
1465
- * `unlist` (kind `deleted`, so subscribers already holding the record are
1466
- * told to drop it) or `list` (kind `changed`, an upsert like `undelete`,
1467
- * for the record's publish moment).
1468
- */
1469
- async setUnlisted(id, unlisted, opts = {}) {
1470
- this.assertOpen();
1471
- const existing = await this.adapter.getRecord(id);
1472
- if (!existing) {
1473
- throw new StackNotFoundError(`Record not found: "${id}"`);
1474
- }
1475
- this.checkIfVersion(existing, opts.ifVersion);
1476
- if (Boolean(existing.unlistedAt) === unlisted)
1477
- return existing;
1478
- const updated = await this.adapter.setUnlisted(id, unlisted, {
1479
- expectedVersion: opts.ifVersion,
1480
- snapshot: this.buildVersionSnapshot(existing),
1481
- updatedBy: opts.updatedBy,
1482
- updatedVia: opts.updatedVia,
1483
- });
1484
- this.emitChange(unlisted ? 'unlist' : 'list', updated);
1485
- return updated;
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
1521
  /**
1523
1522
  * 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
1523
+ * every site that adds one: a change set naming `parentId`, a restore
1524
+ * putting a container back, and a create naming both its own id and a parent. Nothing
1526
1525
  * downstream (a generator deriving a page path, a folder view) is
1527
1526
  * written to survive a cycle.
1528
1527
  *
@@ -1728,7 +1727,7 @@ export class Stack {
1728
1727
  this.checkConfigEntityIdUnchanged(existing.content.entityId, target.content.entityId);
1729
1728
  }
1730
1729
  // Restoring is a write like any other, so it owes the same immutability
1731
- // check update() pays — a snapshot taken before a card claimed its DID
1730
+ // check a content patch pays — a snapshot taken before a card claimed its DID
1732
1731
  // would otherwise move the binding by rolling content back. Uniqueness
1733
1732
  // needs no separate check here: a restore can only put back a value this
1734
1733
  // same card already held, which immutability already refuses to change.
@@ -1736,7 +1735,7 @@ export class Stack {
1736
1735
  this.checkBindingImmutable(baseIdOf(target.typeId), field, existing.content[field], target.content[field]);
1737
1736
  }
1738
1737
  // 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
1738
+ // a change set's `parentId`, and the chain above that container may have moved since
1740
1739
  // the snapshot was taken. The cycle walk therefore applies — but
1741
1740
  // assertParentExists() deliberately does not: a restore is not a caller
1742
1741
  // naming a destination, it is history being put back, and the container
@@ -1751,11 +1750,14 @@ export class Stack {
1751
1750
  if (moves && targetParentId !== null) {
1752
1751
  await this.assertNoParentCycle(id, targetParentId);
1753
1752
  }
1753
+ // A `_group`'s roster is authority, not data: it does not roll back.
1754
+ // See docs/spec/versioning.md § Restore semantics.
1754
1755
  const restored = await this.adapter.restoreVersion(id, version, {
1755
1756
  expectedVersion: opts.ifVersion,
1756
1757
  snapshot: this.buildVersionSnapshot(existing),
1757
1758
  updatedBy: opts.updatedBy,
1758
1759
  updatedVia: opts.updatedVia,
1760
+ ...(isGroupRecord(existing) && { restoreAssociations: false }),
1759
1761
  });
1760
1762
  this.emitChange('restore', restored, moves ? { previousParentId } : {});
1761
1763
  return restored;
@@ -1763,12 +1765,12 @@ export class Stack {
1763
1765
  /**
1764
1766
  * Commit a per-record migration: replace `content` and `typeId` together
1765
1767
  * in one step, validated against `toTypeId`'s schema exactly as
1766
- * create()/update() validate against a type's schema. The single-record
1768
+ * create()/mutate() validate against a type's schema. The single-record
1767
1769
  * counterpart to migrateAll() — content here is supplied by the caller
1768
1770
  * (computed client-side by the type's owning app, per
1769
1771
  * docs/spec/wire-format.md § Migration commit) rather than a registered
1770
1772
  * Migration function. Snapshots the prior state to version history, same
1771
- * as update()/restoreVersion(), and takes the same optional `ifVersion`
1773
+ * as mutate()/restoreVersion(), and takes the same optional `ifVersion`
1772
1774
  * precondition every version-bumping mutation takes — checked atomically
1773
1775
  * at the adapter, not here (see docs/spec/versioning.md § Optimistic
1774
1776
  * concurrency).
@@ -1778,7 +1780,7 @@ export class Stack {
1778
1780
  * record as it stands, so it owes both sets of integrity checks — the
1779
1781
  * binding rules, the attachment rules, and `_config`'s. Missing either
1780
1782
  * half would make migrate a second, unguarded write path to the same
1781
- * state create()/update() refuse to reach.
1783
+ * state create()/mutate() refuse to reach.
1782
1784
  */
1783
1785
  async commitMigration(id, toTypeId, content, opts = {}) {
1784
1786
  this.assertOpen();
@@ -1864,7 +1866,7 @@ export class Stack {
1864
1866
  /**
1865
1867
  * Immutability for every binding field a patch touches, then uniqueness
1866
1868
  * for the subset that carries it. Fields absent from the patch carry no
1867
- * new claim — update() is a merge, so an untouched binding is the one the
1869
+ * new claim — a content patch is a merge, so an untouched binding is the one the
1868
1870
  * card already holds.
1869
1871
  */
1870
1872
  async checkBindingsOnUpdate(typeId, id, patch, existing, merged) {
@@ -2055,7 +2057,7 @@ export class Stack {
2055
2057
  }
2056
2058
  }
2057
2059
  /**
2058
- * `_config.entityId` defines stack ownership; neither update() nor
2060
+ * `_config.entityId` defines stack ownership; neither patchContent() nor
2059
2061
  * restoreVersion() may change it. A conflict with stack integrity, not a
2060
2062
  * schema violation — hence StackConflictError. See docs/spec.md § The
2061
2063
  * `_config` record.
@@ -2633,6 +2635,89 @@ function permissionEqual(a, b) {
2633
2635
  }
2634
2636
  return false;
2635
2637
  }
2638
+ /**
2639
+ * A change set has to name at least one aspect. Refused rather than read
2640
+ * as a no-op: it addresses nothing, so there is nothing it could have
2641
+ * failed to satisfy, and every way of producing one is a caller bug —
2642
+ * typically a conditional that built an empty object.
2643
+ * See docs/spec/data-model.md § Mutations.
2644
+ */
2645
+ function assertNonEmptyChangeSet(changes) {
2646
+ // Presence, not truthiness: `unlisted: false` and `parentId: null` are
2647
+ // aspects this call names, and reading them as absent would drop a
2648
+ // change the caller asked for.
2649
+ if (RECORD_CHANGE_KEYS.some((key) => changes[key] !== undefined))
2650
+ return;
2651
+ throw new StackQueryError('A change set names at least one of: ' + RECORD_CHANGE_KEYS.join(', ') + '.');
2652
+ }
2653
+ /**
2654
+ * Which aspects a change set actually moves, against the record as it
2655
+ * stands — the no-op decision and the change event's `ops` are the same
2656
+ * comparison, so they can never disagree. A key naming the value a record
2657
+ * already holds contributes nothing.
2658
+ *
2659
+ * `merged` is the content the patch produces, computed once by the
2660
+ * caller that had to validate it anyway.
2661
+ * See docs/spec/events.md § The event shape.
2662
+ */
2663
+ function changeSetOps(existing, changes, merged) {
2664
+ const ops = [];
2665
+ if (merged !== undefined && !contentEqual(existing.content, merged))
2666
+ ops.push('patch');
2667
+ if (changes.parentId !== undefined && changes.parentId !== (existing.parentId ?? null)) {
2668
+ ops.push('reparent');
2669
+ }
2670
+ if (changes.permissions && !permissionsEqual(existing.permissions ?? [], changes.permissions)) {
2671
+ ops.push('permissions');
2672
+ }
2673
+ if (changes.associations) {
2674
+ const before = existing.associations ?? [];
2675
+ const after = changes.associations;
2676
+ if (after.some((a) => !before.some((b) => associationEqual(a, b))))
2677
+ ops.push('associate');
2678
+ if (before.some((b) => !after.some((a) => associationEqual(a, b))))
2679
+ ops.push('dissociate');
2680
+ }
2681
+ if (changes.unlisted !== undefined && Boolean(existing.unlistedAt) !== changes.unlisted) {
2682
+ ops.push(changes.unlisted ? 'unlist' : 'list');
2683
+ }
2684
+ return ops;
2685
+ }
2686
+ /**
2687
+ * The change set narrowed to the aspects that actually moved. An adapter
2688
+ * is handed this rather than what the caller wrote, so restating an aspect
2689
+ * cannot rewrite it: `unlisted: true` on an already-unlisted record would
2690
+ * otherwise drag `unlistedAt` forward, moving the record's publish moment
2691
+ * with no op reporting it. It also keeps the adapter contract honest —
2692
+ * every key an adapter receives is one it must write.
2693
+ */
2694
+ function effectiveChanges(changes, ops) {
2695
+ const effective = {};
2696
+ if (ops.includes('patch'))
2697
+ effective.contentPatch = changes.contentPatch;
2698
+ if (ops.includes('reparent'))
2699
+ effective.parentId = changes.parentId;
2700
+ if (ops.includes('permissions'))
2701
+ effective.permissions = changes.permissions;
2702
+ if (ops.includes('associate') || ops.includes('dissociate')) {
2703
+ effective.associations = changes.associations;
2704
+ }
2705
+ if (ops.includes('unlist') || ops.includes('list'))
2706
+ effective.unlisted = changes.unlisted;
2707
+ return effective;
2708
+ }
2709
+ /**
2710
+ * Whether a merge patch produced the content the record already held.
2711
+ * Compared by serialization: content is JSON by construction — it round
2712
+ * trips through storage that way — and a patch preserves key order for
2713
+ * every field it does not name, so the encoding of an unchanged record is
2714
+ * stable. A reordering patch that changes nothing else is the one case
2715
+ * this reports as a change, which costs an empty version rather than a
2716
+ * wrong answer.
2717
+ */
2718
+ function contentEqual(a, b) {
2719
+ return JSON.stringify(a) === JSON.stringify(b);
2720
+ }
2636
2721
  function permissionsEqual(a, b) {
2637
2722
  return a.length === b.length && a.every((p, i) => permissionEqual(p, b[i]));
2638
2723
  }
@@ -2643,7 +2728,7 @@ function permissionsEqual(a, b) {
2643
2728
  const DEFAULT_QUERY_LIMIT = 50;
2644
2729
  const MAX_QUERY_LIMIT = 1000;
2645
2730
  /**
2646
- * How far setParent() will walk a proposed ancestor chain before refusing
2731
+ * How far a change set's `parentId` will walk a proposed ancestor chain before refusing
2647
2732
  * the move. Bounds the reads one call can cost; a hierarchy deeper than
2648
2733
  * this is beyond what `parentId` is for. See docs/spec/data-model.md
2649
2734
  * § Reparenting.
@@ -2667,6 +2752,13 @@ function stampGroupAdmin(associations, creator) {
2667
2752
  { kind: 'relationship', label: 'admin', target: { scope: 'entity', entityId: creator } },
2668
2753
  ];
2669
2754
  }
2755
+ /**
2756
+ * Whether a Record is a `_group`, in any of its type versions — the family
2757
+ * whose roster rules apply.
2758
+ */
2759
+ function isGroupRecord(record) {
2760
+ return baseIdOf(record.typeId) === SYSTEM_TYPES.GROUP;
2761
+ }
2670
2762
  /**
2671
2763
  * Drops a snapshot's `permissions` — owner-only audit data, never served
2672
2764
  * to a non-owner history reader. `entityId` (change attribution) stays.
@@ -2709,7 +2801,7 @@ const presentDeleted = (record) => record.deletedAt ? tombstoneOf(record) : reco
2709
2801
  *
2710
2802
  * Two identities, one rule: **the principal governs authority, the subject
2711
2803
  * governs attribution.** Grant lookup and the privilege-bearing gates that
2712
- * no grant reaches (setPermissions, group management, hard delete, widening
2804
+ * no grant reaches (resharing, group management, hard delete, widening
2713
2805
  * access at create time) key on `principalEntityId`; authorship, `-own`
2714
2806
  * matching, record-level permission resolution, and "files I uploaded"
2715
2807
  * lookups key on `subjectEntityId`.
@@ -3052,7 +3144,7 @@ export class ScopedStack {
3052
3144
  * `_group` records are managed, not merely written: only the owner or an
3053
3145
  * `admin` roster holder may mutate them — ordinary write permissions and
3054
3146
  * grants don't apply. Asked of both identities under delegation, like
3055
- * setPermissions(). See docs/spec/identity.md § Group.
3147
+ * a reshare. See docs/spec/identity.md § Group.
3056
3148
  */
3057
3149
  isGroupManager(record) {
3058
3150
  if (!this.managesGroup(this.principalEntityId, record))
@@ -3214,7 +3306,7 @@ export class ScopedStack {
3214
3306
  * Gates file-ref content fields on file access, mirroring the
3215
3307
  * attachment-association gate — a file-ref field conveys attachment
3216
3308
  * access exactly like an `attachment` association. Only fields present
3217
- * in `content` are checked (update() is a merge patch; untouched fields
3309
+ * in `content` are checked (a content patch is a merge patch; untouched fields
3218
3310
  * carry no new reference).
3219
3311
  */
3220
3312
  async requireFileRefAccess(typeId, content) {
@@ -3333,7 +3425,7 @@ export class ScopedStack {
3333
3425
  }
3334
3426
  /**
3335
3427
  * Whether this request may decide who else reaches a record — the rule
3336
- * setPermissions() enforces, asked at create time too so the reach it
3428
+ * a reshare enforces, asked at create time too so the reach it
3337
3429
  * withholds can't be taken one step earlier while authoring. A delegated
3338
3430
  * app is denied it: widening access is the one thing containment most
3339
3431
  * needs to hold. Refused rather than silently ignored, so an app never
@@ -3347,7 +3439,7 @@ export class ScopedStack {
3347
3439
  }
3348
3440
  /**
3349
3441
  * Whether one identity, on its own, may decide who else reaches `record`
3350
- * — the owner-or-creator rule setPermissions() enforces, asked of one
3442
+ * — the owner-or-creator rule a reshare enforces, asked of one
3351
3443
  * side at a time. See
3352
3444
  * docs/spec/access-control.md § Delegation: principal and subject.
3353
3445
  */
@@ -3423,28 +3515,105 @@ export class ScopedStack {
3423
3515
  } while (records.length < limit && page.cursor && totalFetched < maxFetched);
3424
3516
  return { records, cursor: page.cursor };
3425
3517
  }
3426
- async update(id, content, opts = {}) {
3427
- const record = await this.requireUpdatable(id);
3428
- // Ahead of the binding fences below, which compare the patch's value
3429
- // for a field against the stored one: `undefined` names a field
3430
- // without claiming a value, so it is refused as a malformed patch
3431
- // rather than read as a change. See Stack.update() for the same check
3432
- // on every other path into a patch.
3433
- const patchErrors = validatePatchValues(content);
3434
- if (patchErrors.length > 0)
3435
- throw new StackValidationError(patchErrors);
3436
- // Value-wise, not presence-wise: a client that reads a card, edits its
3437
- // `name` and sends the whole content object back is not setting the
3438
- // binding it round-trips, and `name` is writable by record-level
3439
- // permission. Same predicate restoreVersion() applies to a snapshot.
3440
- this.requireOwnerForAppIdentity(record.typeId, (field) => field in content && content[field] !== record.content[field]);
3441
- // Likewise value-wise: re-sending the DID a card already holds claims
3442
- // nothing, and immutability refuses changing it regardless.
3443
- if ('did' in content && content.did !== record.content.did) {
3444
- this.requireOwnerForOwnerDid(record.typeId, content.did);
3445
- }
3446
- await this.requireFileRefAccess(record.typeId, content);
3447
- return this.stack.update(id, content, { ...opts, ...this.actor });
3518
+ /**
3519
+ * Apply a change set on behalf of the subject. Authority is resolved
3520
+ * **per key** and every gate reads the record as it stands, never as the
3521
+ * change set would leave it: a widened `permissions` never satisfies the
3522
+ * read check on a `parentId` named in the same call, and a `_group`
3523
+ * roster never satisfies the admin check that same call has to pass.
3524
+ * One refused key refuses the whole call, so nothing is partially
3525
+ * applied and no key is silently dropped.
3526
+ * See docs/spec/access-control.md § Composing a change set.
3527
+ */
3528
+ async mutate(id, changes, opts = {}) {
3529
+ // Ahead of every gate: a malformed change set is a validation error
3530
+ // for every requester, rather than one for the owner and a permission
3531
+ // refusal for everyone else.
3532
+ assertNonEmptyChangeSet(changes);
3533
+ if (changes.contentPatch) {
3534
+ const patchErrors = validatePatchValues(changes.contentPatch);
3535
+ if (patchErrors.length > 0)
3536
+ throw new StackValidationError(patchErrors);
3537
+ }
3538
+ const reshares = changes.permissions !== undefined || changes.unlisted !== undefined;
3539
+ const writes = changes.contentPatch !== undefined ||
3540
+ changes.associations !== undefined ||
3541
+ changes.parentId !== undefined;
3542
+ // requireUpdatable() reads the record and applies the write gate; the
3543
+ // reshare keys need the record before their own gate, and a change set
3544
+ // carrying only those must not be held to the write gate it doesn't
3545
+ // need. One read either way.
3546
+ const record = writes ? await this.requireUpdatable(id) : await this.requireReshareable(id);
3547
+ if (writes && reshares)
3548
+ await this.requireReshareOf(record);
3549
+ if (changes.contentPatch) {
3550
+ const patch = changes.contentPatch;
3551
+ // Value-wise, not presence-wise: a client that reads a card, edits
3552
+ // its `name` and sends the whole content object back is not setting
3553
+ // the binding it round-trips, and `name` is writable by record-level
3554
+ // permission. Same predicate restoreVersion() applies to a snapshot.
3555
+ this.requireOwnerForAppIdentity(record.typeId, (field) => field in patch && patch[field] !== record.content[field]);
3556
+ // Likewise value-wise: re-sending the DID a card already holds
3557
+ // claims nothing, and immutability refuses changing it regardless.
3558
+ if ('did' in patch && patch.did !== record.content.did) {
3559
+ this.requireOwnerForOwnerDid(record.typeId, patch.did);
3560
+ }
3561
+ await this.requireFileRefAccess(record.typeId, patch);
3562
+ }
3563
+ // Every association the change set would add is a reference this
3564
+ // requester has to be allowed to create — asked against the stored
3565
+ // set, so an association already on the record is not re-gated.
3566
+ for (const assoc of changes.associations ?? []) {
3567
+ if ((record.associations ?? []).some((a) => associationEqual(a, assoc)))
3568
+ continue;
3569
+ await this.requireAssociationAccess(record.typeId, assoc);
3570
+ }
3571
+ if (changes.parentId != null && !(await this.canReadReferent(changes.parentId))) {
3572
+ throw new StackPermissionError();
3573
+ }
3574
+ return this.stack.mutate(id, changes, { ...opts, ...this.actor });
3575
+ }
3576
+ async patchContent(id, patch, opts = {}) {
3577
+ return this.mutate(id, { contentPatch: patch }, opts);
3578
+ }
3579
+ /**
3580
+ * The record, having established that this request may decide who else
3581
+ * reaches it — the owner-or-creator rule, asked of both sides of a
3582
+ * delegation, or a Group's admin rule for a `_group`. The gate the
3583
+ * `permissions` and `unlisted` keys carry, which the write bit
3584
+ * deliberately does not confer.
3585
+ */
3586
+ async requireReshareable(id) {
3587
+ const record = await this.stack.get(id);
3588
+ if (!record)
3589
+ throw new StackNotFoundError(`Record not found: "${id}"`);
3590
+ await this.requireOwnerForGrantRecord(record);
3591
+ await this.requireReshareOf(record);
3592
+ // Reached through its own gate rather than requireUpdatable(), so the
3593
+ // soft-delete refusal has to be asked here too — after the authority
3594
+ // decision above, for the reason refuseIfDeleted() gives.
3595
+ return this.refuseIfDeleted(record, true);
3596
+ }
3597
+ /** The reshare decision alone, for a record already read and write-gated. */
3598
+ async requireReshareOf(record) {
3599
+ if (baseIdOf(record.typeId) === SYSTEM_TYPES.GROUP) {
3600
+ // Group management, not authorship: a creator later demoted from the
3601
+ // admin roster shouldn't retain a side door to reassign who can read
3602
+ // or write the group record.
3603
+ if (!this.isGroupManager(record))
3604
+ throw await this.denialFor(record);
3605
+ return;
3606
+ }
3607
+ // Intersected like every other authority here: the principal must hold
3608
+ // the verb, and the subject must be able to reach this record —
3609
+ // without which an owner principal would carry its subject to records
3610
+ // the subject cannot touch. create() withholds the same reach via
3611
+ // mayGrantAccess().
3612
+ if (!this.mayReshare(this.principalEntityId, record))
3613
+ throw await this.denialFor(record);
3614
+ if (this.delegated && !this.mayReshare(this.subjectEntityId, record)) {
3615
+ throw await this.denialFor(record);
3616
+ }
3448
3617
  }
3449
3618
  /**
3450
3619
  * Naming the software behind a key is the trust decision the `_app`
@@ -3543,81 +3712,6 @@ export class ScopedStack {
3543
3712
  await this.requireUpdatable(id);
3544
3713
  return this.stack.dissociate(id, association, { ...opts, ...this.actor });
3545
3714
  }
3546
- async setPermissions(id, permissions, opts = {}) {
3547
- const record = await this.stack.get(id);
3548
- if (!record)
3549
- throw new StackNotFoundError(`Record not found: "${id}"`);
3550
- await this.requireOwnerForGrantRecord(record);
3551
- if (baseIdOf(record.typeId) === SYSTEM_TYPES.GROUP) {
3552
- // Group management, not authorship: a creator later demoted from the
3553
- // admin roster shouldn't retain a side door to reassign who can read
3554
- // or write the group record. Same gate as update/associate/delete.
3555
- if (!this.isGroupManager(record))
3556
- throw await this.denialFor(record);
3557
- }
3558
- else {
3559
- // Intersected like every other authority here: the principal must
3560
- // hold the verb, and the subject must be able to reach this record —
3561
- // without which an owner principal would carry its subject to records
3562
- // the subject cannot touch. create() withholds the same reach via
3563
- // mayGrantAccess().
3564
- if (!this.mayReshare(this.principalEntityId, record))
3565
- throw await this.denialFor(record);
3566
- if (this.delegated && !this.mayReshare(this.subjectEntityId, record)) {
3567
- throw await this.denialFor(record);
3568
- }
3569
- }
3570
- // Reached through its own gate rather than requireUpdatable(), so the
3571
- // soft-delete refusal has to be asked here too — after the authority
3572
- // decision above, for the reason refuseIfDeleted() gives.
3573
- this.refuseIfDeleted(record, true);
3574
- return this.stack.setPermissions(id, permissions, { ...opts, ...this.actor });
3575
- }
3576
- /**
3577
- * Withhold a record from enumeration, or restore it — gated exactly like
3578
- * setPermissions(), since both decide who or what can discover the
3579
- * record rather than merely read it once found. See
3580
- * docs/spec/unlisted.md.
3581
- */
3582
- async setUnlisted(id, unlisted, opts = {}) {
3583
- const record = await this.stack.get(id);
3584
- if (!record)
3585
- throw new StackNotFoundError(`Record not found: "${id}"`);
3586
- await this.requireOwnerForGrantRecord(record);
3587
- if (baseIdOf(record.typeId) === SYSTEM_TYPES.GROUP) {
3588
- if (!this.isGroupManager(record))
3589
- throw await this.denialFor(record);
3590
- }
3591
- else {
3592
- if (!this.mayReshare(this.principalEntityId, record))
3593
- throw await this.denialFor(record);
3594
- if (this.delegated && !this.mayReshare(this.subjectEntityId, record)) {
3595
- throw await this.denialFor(record);
3596
- }
3597
- }
3598
- // See setPermissions() above — same gate, same reason.
3599
- this.refuseIfDeleted(record, true);
3600
- return this.stack.setUnlisted(id, unlisted, { ...opts, ...this.actor });
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
- }
3621
3715
  /**
3622
3716
  * Hard delete is owner-only: it is irreversible and destroys version
3623
3717
  * history, so neither the write bit nor delete-own/delete-any grants
@@ -3642,7 +3736,7 @@ export class ScopedStack {
3642
3736
  }
3643
3737
  /**
3644
3738
  * History is the mutation/recovery surface, not a read surface — gated
3645
- * like update(), with snapshot `permissions` stripped for everyone but
3739
+ * like patchContent(), with snapshot `permissions` stripped for everyone but
3646
3740
  * the owner acting alone — a snapshot's permissions are the stack's
3647
3741
  * sharing graph, which delegation is not a route to. Reading history
3648
3742
  * changes nothing, so it is the one path the `_grant` write fence leaves
@@ -3668,7 +3762,7 @@ export class ScopedStack {
3668
3762
  * Re-runs the reference-creation checks against the snapshot, so a
3669
3763
  * restore can't re-convey access to a file or record the subject can no
3670
3764
  * longer reach today — the snapshot's `parentId` among them, gated
3671
- * exactly as setParent() gates a destination named directly. Only the
3765
+ * exactly as a change set's `parentId` gates a destination named directly. Only the
3672
3766
  * owner acting alone is exempt: under delegation the checks resolve
3673
3767
  * against the subject, which is whose reach the restore would widen.
3674
3768
  * See docs/spec/versioning.md § Restore semantics.
@@ -3679,7 +3773,7 @@ export class ScopedStack {
3679
3773
  const target = await this.stack.getVersion(id, version);
3680
3774
  if (target) {
3681
3775
  // A rollback that would move a card's binding is the same trust
3682
- // decision update() reserves to the owner, reached by another route.
3776
+ // decision a content patch reserves to the owner, reached by another route.
3683
3777
  this.requireOwnerForAppIdentity(record.typeId, (field) => target.content[field] !==
3684
3778
  record.content[field]);
3685
3779
  // Only a restore that moves the record *into* a container creates a
@@ -3693,8 +3787,15 @@ export class ScopedStack {
3693
3787
  throw new StackPermissionError();
3694
3788
  }
3695
3789
  await this.requireFileRefAccess(target.typeId, target.content);
3696
- for (const association of target.associations ?? []) {
3697
- await this.requireAssociationAccess(target.typeId, association);
3790
+ // Gated against the associations the restore will actually write.
3791
+ // A `_group`'s don't roll back at all, so such a restore introduces
3792
+ // no association and there is nothing here to gate — the same
3793
+ // reason a `parentId` the restore would not change is not re-gated
3794
+ // above.
3795
+ if (!isGroupRecord(record)) {
3796
+ for (const association of target.associations ?? []) {
3797
+ await this.requireAssociationAccess(target.typeId, association);
3798
+ }
3698
3799
  }
3699
3800
  }
3700
3801
  }
@@ -3712,7 +3813,7 @@ export class ScopedStack {
3712
3813
  * The restriction is what makes the verb safe to expose at all. Migrate
3713
3814
  * replaces `content` and `typeId` wholesale, so a grant-based version
3714
3815
  * would have to re-derive every gate `create()` applies at the
3715
- * destination *and* every gate `update()` applies over the existing
3816
+ * destination *and* every gate `mutate()` applies over the existing
3716
3817
  * content, and would reopen each one it missed. The sharpest is the
3717
3818
  * non-owner `_attachment@1` refusal create() carries: without it, a
3718
3819
  * requester holding a create grant on `_attachment@1` and write access