@haverstack/core 0.40.0 → 0.41.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
@@ -25,6 +25,7 @@ import { UseAfterCloseError, InvalidAdapterError, StackConflictError, StackMigra
25
25
  import { assertQueryCapabilities, assertSortCapability, assertValidAssociationFilters, assertValidBaseIdFilter, assertValidJournalQuery, assertValidVersionsQuery, assertValidSort, normalizeSort, assertAssociationEdits, assertAuthorityAssociations, assertDataAssociations, filtersContent, validateAssociations, } from './query-validation.js';
26
26
  import { GRANT_ACTION_SET, READ_COMPANIONS, grantConveys, grantReach, matchesGrantTarget, validateGrantTarget, validateGrantee, validateGrantBaseId, grantCoversGrantee, loadGrantRecords, } from './grants.js';
27
27
  import { bindingFieldsOf, uniqueBindingFieldsOf } from './identity-bindings.js';
28
+ import { claimedFamilies, familyStanding, grantIsRequest, installAppLink, installGrantLink, installReader, linkedIds, namespaceOf, ownTypeIds, planFingerprint, sameRequest, snapshotManifest, validateInstall, INSTALL_APP_LABEL, INSTALL_GRANT_LABEL, } from './install.js';
28
29
  import { assertAttachmentSize, assertContentSize } from './limits.js';
29
30
  import { validateParentId, validateRecordId, validateClockField, validateIdTimestampSkew, DEFAULT_ID_TIMESTAMP_SKEW_MS, } from './record-id.js';
30
31
  import { queryAllPages, findFirstMatch, lookupEntityByDid, MAX_QUERY_LIMIT, } from './stack-reads.js';
@@ -376,8 +377,13 @@ export class Stack {
376
377
  * the only way disk state changes version. Sweeps soft-deleted records
377
378
  * too, validates each result before writing, and aborts on the first
378
379
  * validation failure. See docs/spec/data-model.md § Type migrations.
380
+ *
381
+ * `sweep: 'listed'` is for a contained app over `APIAdapter`, which can
382
+ * neither enumerate unlisted records nor read a deleted one's content:
383
+ * it migrates live, listed records and counts the deleted ones it passed
384
+ * over in `skipped`. See docs/spec/apps.md § Migrating an installed app's types.
379
385
  */
380
- async migrateAll(baseId) {
386
+ async migrateAll(baseId, opts = {}) {
381
387
  this.assertOpen();
382
388
  const problem = familyIdProblem(baseId, 'migrateAll');
383
389
  if (problem)
@@ -387,7 +393,9 @@ export class Stack {
387
393
  if (familyTypeIds.length === 0) {
388
394
  throw new StackMigrationError(`migrateAll: no registered types found for baseId "${baseId}"`);
389
395
  }
396
+ const listedOnly = opts.sweep === 'listed';
390
397
  let migrated = 0;
398
+ let skipped = 0;
391
399
  for (const typeId of familyTypeIds) {
392
400
  const latestId = this.latestTypeId(typeId);
393
401
  if (typeId === latestId)
@@ -402,11 +410,15 @@ export class Stack {
402
410
  let cursor;
403
411
  do {
404
412
  const result = await this.adapter.queryRecords({
405
- filter: { typeId, includeDeleted: true, includeUnlisted: true },
413
+ filter: { typeId, includeDeleted: true, includeUnlisted: !listedOnly },
406
414
  limit: 100,
407
415
  cursor,
408
416
  });
409
417
  for (const record of result.records) {
418
+ if (listedOnly && record.deletedAt) {
419
+ skipped++;
420
+ continue;
421
+ }
410
422
  // Same checked path commitMigration() takes — a migration
411
423
  // function is no more entitled to move a DID binding or repoint
412
424
  // an attachment than a request body is. No ifVersion: a batch
@@ -417,7 +429,7 @@ export class Stack {
417
429
  cursor = result.cursor ?? undefined;
418
430
  } while (cursor);
419
431
  }
420
- return { migrated };
432
+ return { migrated, skipped };
421
433
  }
422
434
  async create(typeIdOrHandle, input, opts = {}) {
423
435
  if (isTypeHandle(typeIdOrHandle))
@@ -450,6 +462,7 @@ export class Stack {
450
462
  ...validateContent(content, type.schema),
451
463
  ...validateGrantee(typeId, content),
452
464
  ...validateGrantBaseId(typeId, content),
465
+ ...validateInstall(typeId, content),
453
466
  ...validateAssociations(opts.permissions, 'permissions'),
454
467
  ...validatePermissions(opts.permissions),
455
468
  ...validateAssociations(opts.associations),
@@ -694,6 +707,7 @@ export class Stack {
694
707
  ...validateContent(merged, type.schema),
695
708
  ...validateGrantee(existing.typeId, merged),
696
709
  ...validateGrantBaseId(existing.typeId, merged),
710
+ ...validateInstall(existing.typeId, merged),
697
711
  ];
698
712
  if (contentErrors.length > 0)
699
713
  throw new StackValidationError(contentErrors);
@@ -1115,6 +1129,7 @@ export class Stack {
1115
1129
  ...validateContent(target.content, type.schema),
1116
1130
  ...validateGrantee(target.typeId, target.content),
1117
1131
  ...validateGrantBaseId(target.typeId, target.content),
1132
+ ...validateInstall(target.typeId, target.content),
1118
1133
  ];
1119
1134
  if (errors.length > 0) {
1120
1135
  throw new StackValidationError(errors);
@@ -1193,6 +1208,7 @@ export class Stack {
1193
1208
  ...validateContent(content, type.schema),
1194
1209
  ...validateGrantee(toTypeId, content),
1195
1210
  ...validateGrantBaseId(toTypeId, content),
1211
+ ...validateInstall(toTypeId, content),
1196
1212
  ];
1197
1213
  if (errors.length > 0) {
1198
1214
  throw new StackValidationError(errors);
@@ -1849,6 +1865,294 @@ export class Stack {
1849
1865
  return matches;
1850
1866
  }
1851
1867
  // -------------------------------------------------------
1868
+ // App installs
1869
+ // -------------------------------------------------------
1870
+ /**
1871
+ * What applying `manifest` for the key `did` would change, for the owner
1872
+ * to review before installApp(). Writes nothing. Refuses outright what no
1873
+ * approval could make valid: a type outside the app's own namespace and
1874
+ * the commons, a request the grant rules refuse, or a `did` already
1875
+ * registered to a different app. See docs/spec/apps.md § Plan, then apply.
1876
+ */
1877
+ async planInstall(submitted, opts) {
1878
+ this.assertOpen();
1879
+ const { did } = opts;
1880
+ const manifest = snapshotManifest(submitted);
1881
+ this.checkManifest(manifest, did);
1882
+ const installs = await this.loadInstalls();
1883
+ const existing = installs.find((r) => r.content.appId === manifest.appId) ?? null;
1884
+ const ownVersions = ownTypeIds(manifest);
1885
+ const ownFamilies = new Set(ownVersions.map(baseIdOf));
1886
+ const card = await this.findAppCard(did);
1887
+ if (card && card.content.appId !== manifest.appId) {
1888
+ throw new StackConflictError(`${did} is registered to "${card.content.appId}", not "${manifest.appId}"`);
1889
+ }
1890
+ const claimed = existing ? claimedFamilies(existing.content) : new Set();
1891
+ const defined = new Set(existing?.content.defines ?? []);
1892
+ const prior = existing?.content.requests ?? [];
1893
+ const foreignRequests = [];
1894
+ for (const r of manifest.requests) {
1895
+ const standing = familyStanding(r.baseId, manifest.appId);
1896
+ if (standing === 'own')
1897
+ continue;
1898
+ const owner = standing === 'foreign'
1899
+ ? namespaceOf(r.baseId)
1900
+ : standing === 'commons'
1901
+ ? 'commons'
1902
+ : 'system';
1903
+ foreignRequests.push({ ...r, owner });
1904
+ }
1905
+ const typeChanges = [];
1906
+ for (const t of manifest.types) {
1907
+ const current = await this.getTypeCached(t.id);
1908
+ if (!current)
1909
+ typeChanges.push({ id: t.id, change: 'new' });
1910
+ else if (current.schemaHash !== (await hashSchema(t.schema))) {
1911
+ // Refused here, not left to defineType(): installApp() defines types one
1912
+ // by one, so a drift found there leaves the earlier ones written.
1913
+ const violations = diffSchemas(current.schema, t.schema);
1914
+ if (violations.length > 0)
1915
+ throw new StackSchemaDriftError(t.id, violations);
1916
+ typeChanges.push({ id: t.id, change: 'schema' });
1917
+ }
1918
+ else if (current.name !== t.name)
1919
+ typeChanges.push({ id: t.id, change: 'name' });
1920
+ }
1921
+ const linkedKeys = [];
1922
+ for (const id of existing ? linkedIds(existing, INSTALL_APP_LABEL) : []) {
1923
+ const key = (await this.get(id))?.content?.did;
1924
+ if (typeof key === 'string')
1925
+ linkedKeys.push(key);
1926
+ }
1927
+ return {
1928
+ manifest,
1929
+ did,
1930
+ existing,
1931
+ newFamilies: [...ownFamilies].filter((f) => !claimed.has(f)),
1932
+ newVersions: ownVersions.filter((id) => !defined.has(id)),
1933
+ requestsAdded: manifest.requests.filter((r) => !prior.some((p) => sameRequest(p, r))),
1934
+ requestsRemoved: prior.filter((p) => !manifest.requests.some((r) => sameRequest(p, r))),
1935
+ foreignRequests,
1936
+ typeChanges,
1937
+ newKey: !existing ||
1938
+ !card ||
1939
+ card.deletedAt !== undefined ||
1940
+ !linkedIds(existing, INSTALL_APP_LABEL).includes(card.id),
1941
+ linkedKeys,
1942
+ };
1943
+ }
1944
+ /**
1945
+ * Apply an approved plan: define the manifest's types, register the
1946
+ * key's `_app` card if it has none, write the `_install` Record, and
1947
+ * bring every linked key's grants to exactly what the manifest requests.
1948
+ * Refuses a plan the stack has moved on from with StackConflictError, so
1949
+ * what is applied is what was approved. Reinstalls a soft-deleted
1950
+ * install. See docs/spec/apps.md § Plan, then apply.
1951
+ */
1952
+ async installApp(plan) {
1953
+ this.assertOpen();
1954
+ const fresh = await this.planInstall(plan.manifest, { did: plan.did });
1955
+ if (planFingerprint(fresh) !== planFingerprint(plan)) {
1956
+ throw new StackConflictError(`The stack changed since the install of "${plan.manifest.appId}" was planned; plan it again`);
1957
+ }
1958
+ const { manifest, did, existing } = fresh;
1959
+ for (const type of manifest.types)
1960
+ await this.defineType(type);
1961
+ const card = await this.ensureAppCard(manifest, did);
1962
+ const defines = [...new Set([...(existing?.content.defines ?? []), ...ownTypeIds(manifest)])];
1963
+ const requests = manifest.requests.map((r) => ({
1964
+ baseId: r.baseId,
1965
+ actions: [...r.actions],
1966
+ }));
1967
+ let install;
1968
+ if (!existing) {
1969
+ install = await this.create(`${SYSTEM_TYPES.INSTALL}@1`, {
1970
+ appId: manifest.appId,
1971
+ name: manifest.name,
1972
+ ...(manifest.version !== undefined && { version: manifest.version }),
1973
+ defines,
1974
+ requests,
1975
+ }, { associations: [installAppLink(card.id)] });
1976
+ }
1977
+ else {
1978
+ const current = existing.deletedAt ? await this.undelete(existing.id) : existing;
1979
+ install = await this.patchContent(existing.id, { name: manifest.name, version: manifest.version ?? null, defines, requests }, { ifVersion: current.version });
1980
+ if (!linkedIds(install, INSTALL_APP_LABEL).includes(card.id)) {
1981
+ install = await this.associate(install.id, [installAppLink(card.id)]);
1982
+ }
1983
+ }
1984
+ return (await this.reconcileInstallGrants(install));
1985
+ }
1986
+ /**
1987
+ * Withdraw every grant an install produced and soft-delete it. The app's
1988
+ * records and `_app` cards stay: the data is the owner's, and the cards
1989
+ * are what its records' attribution resolves through. Returns the
1990
+ * install's tombstone. See docs/spec/apps.md § Uninstalling.
1991
+ */
1992
+ async uninstallApp(appId) {
1993
+ this.assertOpen();
1994
+ const install = (await this.loadInstalls()).find((r) => r.content.appId === appId && !r.deletedAt);
1995
+ if (!install)
1996
+ throw new StackNotFoundError(`No install for "${appId}"`);
1997
+ const edits = [];
1998
+ for (const id of linkedIds(install, INSTALL_GRANT_LABEL)) {
1999
+ if (await this.get(id))
2000
+ await this.delete(id);
2001
+ edits.push({ op: 'remove', association: installGrantLink(id) });
2002
+ }
2003
+ if (edits.length > 0)
2004
+ await this.amendAssociations(install.id, edits);
2005
+ const { record } = await this.deleteAndReturn(install.id);
2006
+ return record;
2007
+ }
2008
+ /** A manifest's own shape, and every request held to grantType()'s rules. */
2009
+ checkManifest(manifest, did) {
2010
+ const errors = [];
2011
+ if (typeof did !== 'string' || !did.startsWith('did:')) {
2012
+ errors.push({ path: 'did', message: 'Expected a DID' });
2013
+ }
2014
+ if (typeof manifest.appId !== 'string' || manifest.appId === '') {
2015
+ errors.push({ path: 'appId', message: 'Expected a non-empty appId' });
2016
+ }
2017
+ if (typeof manifest.name !== 'string' || manifest.name === '') {
2018
+ errors.push({ path: 'name', message: 'Expected a non-empty name' });
2019
+ }
2020
+ const seen = new Set();
2021
+ manifest.types.forEach((t, i) => {
2022
+ if (seen.has(t.id)) {
2023
+ errors.push({ path: `types[${i}].id`, message: `"${t.id}" is listed more than once` });
2024
+ }
2025
+ seen.add(t.id);
2026
+ const schemaPath = `types[${i}].schema`;
2027
+ const shapeErrors = validateSchemaShape(t.schema, schemaPath);
2028
+ errors.push(...shapeErrors);
2029
+ if (shapeErrors.length === 0) {
2030
+ const schema = t.schema;
2031
+ for (const e of validateSchemaReservedNames(schema)) {
2032
+ errors.push({ ...e, path: `${schemaPath}.${e.path}` });
2033
+ }
2034
+ validateSchemaFieldNames(schema, schemaPath, errors);
2035
+ }
2036
+ const parsed = parseTypeId(t.id);
2037
+ if (!parsed) {
2038
+ errors.push({ path: `types[${i}].id`, message: 'Expected a versioned TypeId' });
2039
+ }
2040
+ else {
2041
+ const standing = familyStanding(parsed.baseId, manifest.appId);
2042
+ if (standing === 'system' || standing === 'foreign') {
2043
+ errors.push({
2044
+ path: `types[${i}].id`,
2045
+ message: standing === 'system'
2046
+ ? `"${parsed.baseId}" is a system type; no app can define it`
2047
+ : `"${parsed.baseId}" is outside the namespace "${manifest.appId}"; request access to it instead of defining it`,
2048
+ });
2049
+ }
2050
+ }
2051
+ });
2052
+ if (errors.length > 0)
2053
+ throw new StackValidationError(errors);
2054
+ for (const r of manifest.requests)
2055
+ this.checkGrantValid(r.baseId, r.actions);
2056
+ }
2057
+ /** Every `_install` Record, deleted and unlisted included — a deleted install keeps its claims. */
2058
+ async loadInstalls() {
2059
+ const records = await queryAllPages((q) => this.query(q), {
2060
+ filter: { baseId: SYSTEM_TYPES.INSTALL, includeDeleted: true, includeUnlisted: true },
2061
+ });
2062
+ return records;
2063
+ }
2064
+ /** The `_app` card claiming `did`, deleted and unlisted included. */
2065
+ findAppCard(did) {
2066
+ return findFirstMatch((q) => this.query(q), {
2067
+ filter: {
2068
+ baseId: SYSTEM_TYPES.APP,
2069
+ includeDeleted: true,
2070
+ includeUnlisted: true,
2071
+ ...(filtersContent(this.capabilities) && { content: { did } }),
2072
+ },
2073
+ }, (r) => r.content.did === did);
2074
+ }
2075
+ /** The key's `_app` card, created or undeleted as needed. */
2076
+ async ensureAppCard(manifest, did) {
2077
+ const card = await this.findAppCard(did);
2078
+ if (!card) {
2079
+ return this.create(`${SYSTEM_TYPES.APP}@1`, {
2080
+ appId: manifest.appId,
2081
+ name: manifest.name,
2082
+ ...(manifest.version !== undefined && { version: manifest.version }),
2083
+ did,
2084
+ });
2085
+ }
2086
+ return card.deletedAt ? this.undelete(card.id) : card;
2087
+ }
2088
+ /**
2089
+ * Make the grants linked to `install` exactly its `requests`, once per
2090
+ * live linked key: withdraw any that no longer match, write any missing,
2091
+ * and drop links to grants that are gone.
2092
+ */
2093
+ async reconcileInstallGrants(install) {
2094
+ const { requests } = install.content;
2095
+ const dids = [];
2096
+ for (const id of linkedIds(install, INSTALL_APP_LABEL)) {
2097
+ const did = (await this.get(id))?.content?.did;
2098
+ if (typeof did === 'string')
2099
+ dids.push(did);
2100
+ }
2101
+ const edits = [];
2102
+ const held = [];
2103
+ for (const id of linkedIds(install, INSTALL_GRANT_LABEL)) {
2104
+ const grant = await this.get(id);
2105
+ const content = grant?.content;
2106
+ const wanted = content !== undefined &&
2107
+ baseIdOf(grant.typeId) === SYSTEM_TYPES.GRANT &&
2108
+ dids.some((did) => requests.some((r) => grantIsRequest(content, r, did)));
2109
+ if (wanted) {
2110
+ held.push(content);
2111
+ continue;
2112
+ }
2113
+ if (grant)
2114
+ await this.delete(id);
2115
+ edits.push({ op: 'remove', association: installGrantLink(id) });
2116
+ }
2117
+ for (const did of dids) {
2118
+ for (const r of requests) {
2119
+ if (held.some((g) => grantIsRequest(g, r, did)))
2120
+ continue;
2121
+ const grant = await this.grantType(r.baseId, {
2122
+ actions: r.actions,
2123
+ grantee: { kind: 'entity', entityId: did },
2124
+ });
2125
+ held.push(grant.content);
2126
+ edits.push({ op: 'add', association: installGrantLink(grant.id) });
2127
+ }
2128
+ }
2129
+ let result = edits.length > 0 ? await this.amendAssociations(install.id, edits) : install;
2130
+ // Each linked key may read its own install, which is how an app learns
2131
+ // what was approved; a key of this app no longer linked may not.
2132
+ // See docs/spec/apps.md § Over the wire.
2133
+ const appKeys = new Set((await queryAllPages((q) => this.query(q), {
2134
+ filter: { baseId: SYSTEM_TYPES.APP, includeDeleted: true, includeUnlisted: true },
2135
+ }))
2136
+ .map((r) => r.content)
2137
+ .filter((c) => c.appId === install.content.appId)
2138
+ .map((c) => c.did));
2139
+ const readerOf = (p) => p.kind === 'permission' && p.label === 'read' && p.grantee.kind === 'entity'
2140
+ ? p.grantee.entityId
2141
+ : null;
2142
+ const current = (result.permissions ?? []).map(readerOf);
2143
+ const access = [
2144
+ ...dids
2145
+ .filter((did) => !current.includes(did))
2146
+ .map((entityId) => ({ op: 'add', association: installReader(entityId) })),
2147
+ ...current
2148
+ .filter((did) => did !== null && appKeys.has(did) && !dids.includes(did))
2149
+ .map((entityId) => ({ op: 'remove', association: installReader(entityId) })),
2150
+ ];
2151
+ if (access.length > 0)
2152
+ result = await this.amendAccess(install.id, access);
2153
+ return result;
2154
+ }
2155
+ // -------------------------------------------------------
1852
2156
  // Private helpers
1853
2157
  // -------------------------------------------------------
1854
2158
  /**
@@ -1962,6 +2266,27 @@ export class Stack {
1962
2266
  filename: { kind: 'string' },
1963
2267
  },
1964
2268
  });
2269
+ await this.defineType({
2270
+ id: `${SYSTEM_TYPES.INSTALL}@1`,
2271
+ name: 'Install',
2272
+ schema: {
2273
+ appId: { kind: 'string', required: true },
2274
+ name: { kind: 'string', required: true },
2275
+ version: { kind: 'string' },
2276
+ defines: { kind: 'array', items: { kind: 'string' }, required: true },
2277
+ requests: {
2278
+ kind: 'array',
2279
+ required: true,
2280
+ items: {
2281
+ kind: 'object',
2282
+ properties: {
2283
+ baseId: { kind: 'string', required: true },
2284
+ actions: { kind: 'array', items: { kind: 'string' }, required: true },
2285
+ },
2286
+ },
2287
+ },
2288
+ },
2289
+ });
1965
2290
  }
1966
2291
  /**
1967
2292
  * Fast-fail for the ifVersion precondition using the already-fetched