@hasna/instructions 0.6.1 → 0.7.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 (80) hide show
  1. package/README.md +123 -6
  2. package/dist/chunks/{apply-ynfn1av7.js → apply-7svah2qm.js} +5 -3
  3. package/dist/chunks/{apply-sfzja1f3.js → apply-jp13sa1e.js} +6 -3
  4. package/dist/chunks/database-wstmr56z.js +23 -0
  5. package/dist/chunks/{index-85n5wnd8.js → index-1cnyjnd7.js} +5 -7
  6. package/dist/chunks/{index-pkscjnj5.js → index-3djthcm6.js} +0 -1
  7. package/dist/chunks/{index-v7dkb384.js → index-4pfq42t5.js} +129 -77
  8. package/dist/chunks/index-5d7zdftc.js +186 -0
  9. package/dist/chunks/{index-1hpwskwd.js → index-7m4j8a33.js} +205 -329
  10. package/dist/chunks/index-8rb7ng2y.js +148 -0
  11. package/dist/chunks/index-arrrx0ed.js +344 -0
  12. package/dist/chunks/{index-9p9gn7gh.js → index-bzyxvwcd.js} +5 -7
  13. package/dist/chunks/index-fpeyzn3n.js +186 -0
  14. package/dist/chunks/{index-axhwg61p.js → index-fwbs6tma.js} +15 -4
  15. package/dist/chunks/index-n3fjspr0.js +119 -0
  16. package/dist/chunks/{index-e9epbsvs.js → index-z3ma4aqk.js} +15 -4
  17. package/dist/chunks/legacy-store-migration-z2m5yzyk.js +322 -0
  18. package/dist/chunks/{local-a9nen65z.js → local-5wvv5xv3.js} +2 -2
  19. package/dist/chunks/{local-0d8qrc92.js → local-zxa5dpf4.js} +13 -188
  20. package/dist/chunks/s3-backup-n2z4fwyh.js +240 -0
  21. package/dist/chunks/s3-config-hhght91k.js +14 -0
  22. package/dist/chunks/s3-object-store-90tgbh6k.js +14 -0
  23. package/dist/chunks/{sync-m46fc257.js → sync-2075deve.js} +5 -4
  24. package/dist/chunks/{sync-vy1yjvs6.js → sync-r8t7zsp3.js} +4 -4
  25. package/dist/chunks/{template-02wjvv05.js → template-fsfxpe4p.js} +1 -1
  26. package/dist/chunks/{template-ke972qqe.js → template-wgnzdn7h.js} +1 -1
  27. package/dist/cli/index.js +1011 -247
  28. package/dist/data/config-store.d.ts +1 -0
  29. package/dist/data/config-store.d.ts.map +1 -1
  30. package/dist/index.js +840 -215
  31. package/dist/lib/apply.d.ts +7 -0
  32. package/dist/lib/apply.d.ts.map +1 -1
  33. package/dist/lib/compact-output.d.ts +1 -0
  34. package/dist/lib/compact-output.d.ts.map +1 -1
  35. package/dist/lib/export.d.ts +22 -3
  36. package/dist/lib/export.d.ts.map +1 -1
  37. package/dist/lib/import.d.ts +18 -0
  38. package/dist/lib/import.d.ts.map +1 -1
  39. package/dist/lib/legacy-store-migration.d.ts +39 -0
  40. package/dist/lib/legacy-store-migration.d.ts.map +1 -0
  41. package/dist/lib/managed-skill-runtimes.d.ts +1 -0
  42. package/dist/lib/managed-skill-runtimes.d.ts.map +1 -1
  43. package/dist/lib/sync-dir.d.ts.map +1 -1
  44. package/dist/mcp/index.js +31 -20
  45. package/dist/sdk/index.d.ts +2 -0
  46. package/dist/sdk/index.d.ts.map +1 -1
  47. package/dist/sdk/index.js +205 -10
  48. package/dist/sdk/resolve.d.ts +1 -1
  49. package/dist/sdk/resolve.d.ts.map +1 -1
  50. package/dist/sdk/v1-client.d.ts +57 -0
  51. package/dist/sdk/v1-client.d.ts.map +1 -0
  52. package/dist/sdk/v1.generated.d.ts +212 -21
  53. package/dist/sdk/v1.generated.d.ts.map +1 -1
  54. package/dist/server/cloud.d.ts.map +1 -1
  55. package/dist/server/index.d.ts +13 -5
  56. package/dist/server/index.d.ts.map +1 -1
  57. package/dist/server/index.js +1180 -432
  58. package/dist/server/openapi.d.ts +1264 -92
  59. package/dist/server/openapi.d.ts.map +1 -1
  60. package/dist/server/v1.d.ts +25 -1
  61. package/dist/server/v1.d.ts.map +1 -1
  62. package/dist/storage/cloud-store.d.ts +57 -1
  63. package/dist/storage/cloud-store.d.ts.map +1 -1
  64. package/dist/storage/index.d.ts +7 -0
  65. package/dist/storage/index.d.ts.map +1 -0
  66. package/dist/storage/index.js +725 -0
  67. package/dist/storage/s3-backup.d.ts +76 -0
  68. package/dist/storage/s3-backup.d.ts.map +1 -0
  69. package/dist/storage/s3-config.d.ts +46 -0
  70. package/dist/storage/s3-config.d.ts.map +1 -0
  71. package/dist/storage/s3-object-store.d.ts +70 -0
  72. package/dist/storage/s3-object-store.d.ts.map +1 -0
  73. package/dist/types/index.d.ts +65 -3
  74. package/dist/types/index.d.ts.map +1 -1
  75. package/hasna.contract.json +110 -0
  76. package/migrations/0001_instructions.sql +63 -0
  77. package/migrations/0002_api_keys.sql +20 -0
  78. package/migrations/0003_idempotency_receipts.sql +24 -0
  79. package/package.json +22 -11
  80. package/assets/skills/inbox/SKILL.md +0 -86
package/README.md CHANGED
@@ -18,9 +18,15 @@ bun add --global @hasna/instructions
18
18
 
19
19
  ```bash
20
20
  instructions --help
21
- instructions init
21
+
22
+ # Hosted-by-default: resolves the approved key and uses
23
+ # https://api.hasna.com/instructions/v1.
24
+ instructions status
22
25
  instructions list
23
- instructions profile resolve
26
+
27
+ # Explicit on-box SQLite mode; there is never an implicit fallback.
28
+ HASNA_INSTRUCTIONS_LOCAL=1 instructions init
29
+ HASNA_INSTRUCTIONS_LOCAL=1 instructions list
24
30
  ```
25
31
 
26
32
  ## Migrating from `@hasna/configs`
@@ -29,6 +35,19 @@ This package was formerly `@hasna/configs`. The `configs` CLI alias and
29
35
  `configs-mcp` alias remain available for existing integrations; new usage can
30
36
  use the `instructions` names.
31
37
 
38
+ Historical on-box rows can be migrated without overwriting the current store:
39
+
40
+ ```bash
41
+ export HASNA_INSTRUCTIONS_LOCAL=1
42
+ instructions migrate-legacy --confirm-local --json # no-write plan
43
+ instructions migrate-legacy --confirm-local --apply --json # backup + transaction
44
+ ```
45
+
46
+ The source defaults to `~/.hasna/configs/configs.db`; the destination defaults
47
+ to the current `instructions.db`. A non-empty destination is refused unless
48
+ `--merge-preserve-destination` is explicit, and that merge never overwrites an
49
+ existing row.
50
+
32
51
  ## CLI Usage
33
52
 
34
53
  ```bash
@@ -109,8 +128,9 @@ instructions-mcp --http # http://127.0.0.1:8807/mcp
109
128
  MCP_HTTP=1 instructions-mcp
110
129
  ```
111
130
 
112
- Health: `GET http://127.0.0.1:8807/health`. MCP is also mounted on
113
- `instructions-serve` at `/mcp`.
131
+ Health: `GET http://127.0.0.1:8807/health`. The production
132
+ `instructions-serve` process deliberately does **not** mount MCP; local MCP and
133
+ the authenticated `/v1` service remain separate authorities.
114
134
 
115
135
  ## HTTP API server (`instructions-serve`)
116
136
 
@@ -120,14 +140,20 @@ instructions-serve
120
140
 
121
141
  Surfaces:
122
142
 
123
- - `GET /health`, `GET /ready`, `GET /version` → `{ status, version, mode }`
143
+ - `GET /health`, `GET /ready`, `GET /version` → `{ status, version, backend }`
124
144
  - `GET /openapi.json`, `GET /v1/openapi.json` → the OpenAPI 3.1 document the SDK
125
145
  is generated from.
126
146
  - `/v1/*` — versioned cloud API (configs, profiles, snapshots, stats).
127
147
  - No `/api/*` — the former local REST surface is not mounted (the removed
128
148
  bundled dashboard was its only consumer).
129
149
 
130
- ### Server data backend (postgresql)
150
+ ### Server data backend (PostgreSQL, fail closed)
151
+
152
+ `instructions-serve` requires `HASNA_INSTRUCTIONS_DATABASE_URL` (or its documented
153
+ alias) for a usable `/v1` service. Without a DSN, `/health` remains a liveness
154
+ probe but reports `backend: "unconfigured"`; `/ready` returns 503 and `/v1`
155
+ returns a stable unavailable error. It never exposes local SQLite over the
156
+ service boundary.
131
157
 
132
158
  When `HASNA_INSTRUCTIONS_DATABASE_URL` is set the `/v1` API reads/writes the
133
159
  shared Postgres **directly** (no local sync/cache in the service) and every
@@ -150,6 +176,16 @@ they resolve `HASNA_INSTRUCTIONS_API_KEY` (or the Keychain / credentials-file
150
176
  tiers) through the one `@hasna/contracts` client resolver, and the authority
151
177
  defaults to the fleet gateway `https://api.hasna.com/instructions`.
152
178
 
179
+ Collection responses are producer-bounded with `limit`/`cursor` envelopes.
180
+ Current clients follow every page; `view=identity` returns an allowlisted
181
+ metadata-only projection for configs, profiles, and machines, without loading
182
+ instruction content or private profile fields. Retryable create and binding
183
+ writes may send `Idempotency-Key`; PostgreSQL stores the authenticated
184
+ principal, operation, canonical request digest, and first committed response in
185
+ the same transaction as the domain mutation. A same-body retry replays that
186
+ response, while key reuse with different bytes returns
187
+ `409 IDEMPOTENCY_KEY_REUSED`.
188
+
153
189
  ## SDK
154
190
 
155
191
  The importable module ships INSIDE `@hasna/instructions` at the `./sdk` export
@@ -215,6 +251,87 @@ transport is decided by what resolves, never by a mode word.
215
251
  Clients never hold a database DSN. The raw Postgres connection is a server-only
216
252
  concern (`instructions-serve`), selected by `HASNA_INSTRUCTIONS_DATABASE_URL`.
217
253
 
254
+
255
+
256
+ ## Domain export and import
257
+
258
+ `instructions export` writes a restorable Instructions domain archive v2. The
259
+ archive contains config content and every retained config snapshot, profiles,
260
+ ordered profile membership and config-binding metadata, profile asset bindings,
261
+ and registered machines. Relationships are recorded by stable config/profile
262
+ slugs and machine hostnames, so import can map them safely onto destination IDs.
263
+
264
+ ```bash
265
+ instructions export --output ./instructions-domain.tar.gz
266
+ instructions import ./instructions-domain.tar.gz # exact recovery into an empty destination
267
+ ```
268
+
269
+ V2 is deliberately a recovery format, not a merge format. Import validates the
270
+ complete archive and requires a destination with zero configs, profiles, and
271
+ machines before the first mutation. `--overwrite` is rejected for v2 before
272
+ destination inspection or mutation. Any validation, mutation, or readback
273
+ failure throws and makes the CLI exit nonzero; discard that attempted
274
+ destination and retry from a newly empty database.
275
+
276
+ The manifest contains deterministic per-collection counts and SHA-256 logical
277
+ hashes for pre/post deployment comparison without placing instruction content
278
+ in logs. Exact deployment integrity includes config `created_at`, `updated_at`,
279
+ and `synced_at`; snapshot `created_at`; profile `created_at` and `updated_at`;
280
+ and machine `created_at` plus the exact `last_applied_at` value. After recovery,
281
+ import reads the complete domain back through `ConfigStore` and verifies every
282
+ field that interface can reproduce: config data and versions, retained snapshot
283
+ contents, profiles, ordered bindings, asset mappings, machines, and whether a
284
+ machine was ever applied.
285
+
286
+ `ConfigStore` cannot assign archived config/profile/snapshot/machine creation or
287
+ update timestamps, nor an exact machine `last_applied_at`; generated destination
288
+ IDs are also intentionally remapped by stable slugs and hostnames. These values
289
+ remain protected in the archive's exact integrity hashes but are not recreated
290
+ by recovery. ConfigStore also has no cross-entity transaction, so an operational
291
+ failure may leave a partial destination; the nonzero result is terminal and that
292
+ destination must not be reused. Legacy v1 config-only archives remain importable,
293
+ including their historical skip/overwrite conflict behavior.
294
+
295
+ API keys and idempotency receipts are intentionally excluded: they are security
296
+ and transport state that must be provisioned independently. Feedback is also
297
+ excluded because it is product telemetry, not part of the Instructions
298
+ configuration domain.
299
+
300
+ ## Native S3 backup storage
301
+
302
+ S3 is an **adjunct immutable backup plane**, never a database selector. SQLite
303
+ remains the explicit local authority and PostgreSQL remains the hosted `/v1`
304
+ authority. Setting S3 variables alone never opens SQLite, never selects HTTP,
305
+ and never changes CRUD routing.
306
+
307
+ ```bash
308
+ export HASNA_INSTRUCTIONS_S3_BUCKET=your-private-bucket
309
+ export HASNA_INSTRUCTIONS_AWS_REGION=us-east-1
310
+ # Optional: HASNA_INSTRUCTIONS_S3_PREFIX (default: instructions/)
311
+ # Optional local/S3-compatible endpoint and explicit static credentials.
312
+
313
+ instructions storage status --json
314
+ instructions export --output ./instructions-backup.tar.gz
315
+ instructions storage backup push ./instructions-backup.tar.gz --id 2026-09-15-pre-deploy --dry-run --json
316
+ instructions storage backup push ./instructions-backup.tar.gz --id 2026-09-15-pre-deploy --json
317
+ instructions storage backup verify 2026-09-15-pre-deploy --json
318
+ instructions storage backup pull 2026-09-15-pre-deploy --output ./restored-instructions.tar.gz --json
319
+ ```
320
+
321
+ Each backup uses traversal-safe deterministic keys, an atomically created
322
+ payload, and an atomically created manifest containing SHA-256, byte size,
323
+ content type, and creation time. Native S3 creation uses `If-None-Match: *`;
324
+ concurrent different-byte writers cannot replace the winner, identical replay
325
+ is idempotent, and an injected store without conditional-create support fails
326
+ closed. Pulls verify the payload before an owner-only local file is written.
327
+ Production backup buckets must enable versioning, encryption, public-access
328
+ blocking, and S3 Object Lock with a default retention period. AWS runtime
329
+ credentials may come from Bun's standard AWS chain (including an ECS task role);
330
+ explicit static credentials are optional and must be a complete pair. Status
331
+ never prints credential values or the bucket name.
332
+
333
+ The public importable surface is available at `@hasna/instructions/storage`.
334
+
218
335
  ## Data Directory
219
336
 
220
337
  Local data (the SQLite store and backups) lives under the configs store home,
@@ -3,20 +3,22 @@ import {
3
3
  applyConfig,
4
4
  applyConfigs,
5
5
  applyConfigsWithReport,
6
+ compactPathForConfigHome,
6
7
  expandPath,
7
8
  getConfigHome,
8
9
  normalizeTargetPath,
9
10
  previewConfigs
10
- } from "./index-axhwg61p.js";
11
- import"./index-v7dkb384.js";
11
+ } from "./index-fwbs6tma.js";
12
+ import"./index-4pfq42t5.js";
12
13
  import"./index-35z65jsa.js";
13
- import"./index-pkscjnj5.js";
14
+ import"./index-3djthcm6.js";
14
15
  import"./index-nnwp92tk.js";
15
16
  export {
16
17
  previewConfigs,
17
18
  normalizeTargetPath,
18
19
  getConfigHome,
19
20
  expandPath,
21
+ compactPathForConfigHome,
20
22
  applyConfigsWithReport,
21
23
  applyConfigs,
22
24
  applyConfig
@@ -3,19 +3,22 @@ import {
3
3
  applyConfig,
4
4
  applyConfigs,
5
5
  applyConfigsWithReport,
6
+ compactPathForConfigHome,
6
7
  expandPath,
7
8
  getConfigHome,
8
9
  normalizeTargetPath,
9
10
  previewConfigs
10
- } from "./index-e9epbsvs.js";
11
- import"./index-1hpwskwd.js";
12
- import"./index-pkscjnj5.js";
11
+ } from "./index-z3ma4aqk.js";
12
+ import"./index-7m4j8a33.js";
13
+ import"./index-n3fjspr0.js";
14
+ import"./index-fpeyzn3n.js";
13
15
  import"./index-bsan4c16.js";
14
16
  export {
15
17
  previewConfigs,
16
18
  normalizeTargetPath,
17
19
  getConfigHome,
18
20
  expandPath,
21
+ compactPathForConfigHome,
19
22
  applyConfigsWithReport,
20
23
  applyConfigs,
21
24
  applyConfig
@@ -0,0 +1,23 @@
1
+ // @bun
2
+ import {
3
+ getDatabase,
4
+ insertFeedback,
5
+ resetDatabase,
6
+ resetLocalDatabase
7
+ } from "./index-5d7zdftc.js";
8
+ import {
9
+ now,
10
+ slugify,
11
+ uuid
12
+ } from "./index-ma2xmp6c.js";
13
+ import"./index-fpeyzn3n.js";
14
+ import"./index-bsan4c16.js";
15
+ export {
16
+ uuid,
17
+ slugify,
18
+ resetLocalDatabase,
19
+ resetDatabase,
20
+ now,
21
+ insertFeedback,
22
+ getDatabase
23
+ };
@@ -1,10 +1,11 @@
1
1
  // @bun
2
2
  import {
3
3
  applyConfigsWithReport,
4
+ compactPathForConfigHome,
4
5
  expandPath,
5
6
  getConfigHome,
6
7
  normalizeTargetPath
7
- } from "./index-e9epbsvs.js";
8
+ } from "./index-z3ma4aqk.js";
8
9
  import {
9
10
  applyTransform,
10
11
  detectMachineContext,
@@ -15,7 +16,7 @@ import {
15
16
  resolveConfigStore,
16
17
  retiredOrUnsupportedAgentReason,
17
18
  templateizeMachineContent
18
- } from "./index-1hpwskwd.js";
19
+ } from "./index-7m4j8a33.js";
19
20
 
20
21
  // src/lib/sync.ts
21
22
  import { existsSync as existsSync2, readdirSync as readdirSync2, readFileSync as readFileSync2 } from "fs";
@@ -24,7 +25,6 @@ import { basename, extname, join as join2 } from "path";
24
25
  // src/lib/sync-dir.ts
25
26
  import { existsSync, readdirSync, readFileSync, statSync } from "fs";
26
27
  import { join, relative } from "path";
27
- import { homedir } from "os";
28
28
  var SKIP = [".db", ".db-shm", ".db-wal", ".log", ".lock", ".DS_Store", "node_modules", ".git"];
29
29
  function shouldSkip(p) {
30
30
  return SKIP.some((s) => p.includes(s));
@@ -36,7 +36,6 @@ async function syncFromDir(dir, opts = {}) {
36
36
  return { added: 0, updated: 0, unchanged: 0, skipped: [`Not found: ${absDir}`] };
37
37
  const files = opts.recursive !== false ? walkDir(absDir) : readdirSync(absDir).map((f) => join(absDir, f)).filter((f) => statSync(f).isFile());
38
38
  const result = { added: 0, updated: 0, unchanged: 0, skipped: [] };
39
- const home = homedir();
40
39
  const allConfigs = await store.listConfigs();
41
40
  for (const file of files) {
42
41
  if (shouldSkip(file)) {
@@ -49,7 +48,7 @@ async function syncFromDir(dir, opts = {}) {
49
48
  result.skipped.push(file + " (too large)");
50
49
  continue;
51
50
  }
52
- const targetPath = file.replace(home, "~");
51
+ const targetPath = compactPathForConfigHome(file);
53
52
  const redacted = redactContent(content, redactFormatForTarget(targetPath, detectFormat(file)));
54
53
  const existing = allConfigs.find((c) => c.target_path === targetPath);
55
54
  if (!existing) {
@@ -71,9 +70,8 @@ async function syncFromDir(dir, opts = {}) {
71
70
  }
72
71
  async function syncToDir(dir, opts = {}) {
73
72
  const store = opts.store ?? resolveConfigStore();
74
- const home = homedir();
75
73
  const absDir = expandPath(dir);
76
- const normalized = dir.startsWith("~/") ? dir : absDir.replace(home, "~");
74
+ const normalized = dir.startsWith("~/") ? dir : compactPathForConfigHome(absDir);
77
75
  const configs = (await store.listConfigs()).filter((c) => c.target_path && (c.target_path.startsWith(normalized) || c.target_path.startsWith(absDir)));
78
76
  const result = { added: 0, updated: 0, unchanged: 0, skipped: [] };
79
77
  for (const config of configs) {
@@ -28,7 +28,6 @@ var ASSET_DESTINATION_STRATEGIES = ["emit-file", "install-local", "install-marke
28
28
  var ASSET_SCOPES = ["global", "project", "session"];
29
29
  var ASSET_UNINSTALL_POLICIES = ["remove-managed", "retain"];
30
30
  var ASSET_ROLLBACK_POLICIES = ["snapshot", "installer-receipt", "none"];
31
-
32
31
  class ConfigNotFoundError extends Error {
33
32
  constructor(id) {
34
33
  super(`Config not found: ${id}`);
@@ -15,7 +15,7 @@ import {
15
15
  isTemplate,
16
16
  renderTemplate,
17
17
  renderTemplatePreview
18
- } from "./index-pkscjnj5.js";
18
+ } from "./index-3djthcm6.js";
19
19
  import {
20
20
  __export,
21
21
  __require
@@ -203,6 +203,7 @@ import { randomUUID } from "crypto";
203
203
  // src/lib/compact-output.ts
204
204
  var DEFAULT_LIST_LIMIT = 20;
205
205
  var MAX_LIST_LIMIT = 100;
206
+ var MAX_LIST_CURSOR = 1e5;
206
207
  function parseLimit(value, fallback = DEFAULT_LIST_LIMIT, max = MAX_LIST_LIMIT) {
207
208
  const parsed = typeof value === "number" ? value : Number.parseInt(String(value ?? ""), 10);
208
209
  if (!Number.isFinite(parsed) || parsed <= 0)
@@ -213,7 +214,7 @@ function parseCursor(value) {
213
214
  const parsed = typeof value === "number" ? value : Number.parseInt(String(value ?? ""), 10);
214
215
  if (!Number.isFinite(parsed) || parsed < 0)
215
216
  return 0;
216
- return Math.floor(parsed);
217
+ return Math.min(Math.floor(parsed), MAX_LIST_CURSOR);
217
218
  }
218
219
  function paginate(items, opts = {}) {
219
220
  const limit = parseLimit(opts.limit, opts.defaultLimit ?? DEFAULT_LIST_LIMIT, opts.maxLimit ?? MAX_LIST_LIMIT);
@@ -720,7 +721,7 @@ function optionalString(value, label) {
720
721
  return requiredString(value, label);
721
722
  }
722
723
 
723
- // ../contracts/dist/client/transport.js
724
+ // ../../node_modules/.bun/@hasna+contracts@1.0.2+e8014c875821e0be/node_modules/@hasna/contracts/dist/client/transport.js
724
725
  import { createRequire } from "module";
725
726
  function envToken(name) {
726
727
  return name.toUpperCase().replace(/-/g, "_");
@@ -754,7 +755,7 @@ var AUTHORITY_OVERRIDE_HEADERS = new Set([
754
755
  "x-original-host"
755
756
  ]);
756
757
 
757
- // ../contracts/dist/client/storage.js
758
+ // ../../node_modules/.bun/@hasna+contracts@1.0.2+e8014c875821e0be/node_modules/@hasna/contracts/dist/client/storage.js
758
759
  import { isIP } from "net";
759
760
  import { spawnSync } from "child_process";
760
761
  import { closeSync, fstatSync, openSync, readFileSync } from "fs";
@@ -2032,11 +2033,11 @@ function announceLocalInstructionsMode(write = (line) => process.stderr.write(`$
2032
2033
  }
2033
2034
 
2034
2035
  // src/data/config-store.ts
2035
- function parseBoundedPagePayload(value, label) {
2036
+ function parseBoundedPagePayload(value, label, expected) {
2036
2037
  const page = value;
2037
2038
  const consumed = Number(page?.cursor) + (page?.items?.length ?? 0);
2038
2039
  const complete = Boolean(page && Number.isSafeInteger(page.total) && consumed >= Number(page.total));
2039
- if (!page || !Array.isArray(page.items) || !Number.isSafeInteger(page.total) || Number(page.total) < 0 || !Number.isSafeInteger(page.limit) || Number(page.limit) < 1 || !Number.isSafeInteger(page.cursor) || Number(page.cursor) < 0 || page.items.length > Number(page.limit) || typeof page.has_more !== "boolean" || typeof page.complete !== "boolean" || page.truncated !== false || page.next_cursor !== null && !Number.isSafeInteger(page.next_cursor) || page.complete !== complete || page.has_more !== !complete || page.next_cursor !== (complete ? null : consumed)) {
2040
+ if (!page || !Array.isArray(page.items) || !Number.isSafeInteger(page.total) || Number(page.total) < 0 || !Number.isSafeInteger(page.limit) || Number(page.limit) < 1 || !Number.isSafeInteger(page.cursor) || Number(page.cursor) < 0 || page.items.length > Number(page.limit) || typeof page.has_more !== "boolean" || typeof page.complete !== "boolean" || page.truncated !== false || page.next_cursor !== null && !Number.isSafeInteger(page.next_cursor) || page.complete !== complete || page.has_more !== !complete || page.next_cursor !== (complete ? null : consumed) || expected !== undefined && Number(page.cursor) !== expected.cursor) {
2040
2041
  throw new Error(`${label} returned an invalid or truncated bounded-read envelope`);
2041
2042
  }
2042
2043
  return {
@@ -2048,7 +2049,7 @@ function parseBoundedOrLegacyPage(value, legacyItems, options, label) {
2048
2049
  if (value && typeof value === "object") {
2049
2050
  const candidate = value;
2050
2051
  if ("items" in candidate || "total" in candidate || "complete" in candidate || "truncated" in candidate || "next_cursor" in candidate) {
2051
- return parseBoundedPagePayload(value, label);
2052
+ return parseBoundedPagePayload(value, label, normalizeBoundedReadOptions(options));
2052
2053
  }
2053
2054
  }
2054
2055
  if (!Array.isArray(legacyItems)) {
@@ -2058,13 +2059,72 @@ function parseBoundedOrLegacyPage(value, legacyItems, options, label) {
2058
2059
  const page = boundedReadPage(legacyItems.slice(normalized.cursor, normalized.cursor + normalized.limit), legacyItems.length, normalized);
2059
2060
  return { ...page, source_bounded: false };
2060
2061
  }
2062
+
2063
+ class CollectionChangedWhilePagingError extends Error {
2064
+ constructor(label, detail) {
2065
+ super(`${label} changed while paging: ${detail}`);
2066
+ this.name = "CollectionChangedWhilePagingError";
2067
+ }
2068
+ }
2069
+ var COLLECTION_READ_ATTEMPTS = 2;
2070
+ async function aggregateBoundedCollection(label, readPage, identity, options = {}) {
2071
+ let lastError = null;
2072
+ for (let attempt = 0;attempt < COLLECTION_READ_ATTEMPTS; attempt += 1) {
2073
+ try {
2074
+ const items = [];
2075
+ const seen = new Set;
2076
+ let expectedTotal = null;
2077
+ let cursor = 0;
2078
+ let previousIdentity = null;
2079
+ while (true) {
2080
+ const page = await readPage(cursor);
2081
+ if (page.cursor !== cursor) {
2082
+ throw new CollectionChangedWhilePagingError(label, `server returned cursor ${page.cursor} for requested cursor ${cursor}`);
2083
+ }
2084
+ if (expectedTotal === null)
2085
+ expectedTotal = page.total;
2086
+ else if (page.total !== expectedTotal) {
2087
+ throw new CollectionChangedWhilePagingError(label, `total changed from ${expectedTotal} to ${page.total}`);
2088
+ }
2089
+ for (const item of page.items) {
2090
+ const key = identity(item);
2091
+ if (!key)
2092
+ throw new CollectionChangedWhilePagingError(label, "an item had no stable identity");
2093
+ if (seen.has(key))
2094
+ throw new CollectionChangedWhilePagingError(label, `duplicate identity ${key}`);
2095
+ if (options.requireAscendingIdentity && page.source_bounded && previousIdentity !== null && key <= previousIdentity) {
2096
+ throw new CollectionChangedWhilePagingError(label, `identity order was not strictly increasing at ${key}`);
2097
+ }
2098
+ seen.add(key);
2099
+ previousIdentity = key;
2100
+ items.push(item);
2101
+ }
2102
+ if (page.complete) {
2103
+ if (items.length !== expectedTotal) {
2104
+ throw new CollectionChangedWhilePagingError(label, `received ${items.length} unique rows for total ${expectedTotal}`);
2105
+ }
2106
+ return items;
2107
+ }
2108
+ if (page.next_cursor === null || page.next_cursor <= cursor) {
2109
+ throw new CollectionChangedWhilePagingError(label, `cursor did not advance from ${cursor}`);
2110
+ }
2111
+ cursor = page.next_cursor;
2112
+ }
2113
+ } catch (error) {
2114
+ if (!(error instanceof CollectionChangedWhilePagingError))
2115
+ throw error;
2116
+ lastError = error;
2117
+ }
2118
+ }
2119
+ throw lastError ?? new CollectionChangedWhilePagingError(label, "read did not stabilize");
2120
+ }
2061
2121
  function isNotFoundHttpError2(err) {
2062
2122
  return typeof err === "object" && err !== null && err.name === "HasnaHttpError" && err.status === 404;
2063
2123
  }
2064
2124
  var localStoreModulePromise = null;
2065
2125
  function localStoreModule() {
2066
2126
  if (!localStoreModulePromise)
2067
- localStoreModulePromise = import("./local-a9nen65z.js");
2127
+ localStoreModulePromise = import("./local-5wvv5xv3.js");
2068
2128
  return localStoreModulePromise;
2069
2129
  }
2070
2130
 
@@ -2112,15 +2172,7 @@ class LocalConfigStore {
2112
2172
  return (await localStoreModule()).pruneSnapshots(configId, keep, this.db);
2113
2173
  }
2114
2174
  async listProfiles() {
2115
- const profiles = [];
2116
- let cursor = 0;
2117
- while (true) {
2118
- const page = await this.listProfilesPage({ limit: 100, cursor });
2119
- profiles.push(...page.items);
2120
- if (page.complete)
2121
- return profiles;
2122
- cursor = page.next_cursor;
2123
- }
2175
+ return aggregateBoundedCollection("profile list", (cursor) => this.listProfilesPage({ limit: 100, cursor }), (profile) => profile.id);
2124
2176
  }
2125
2177
  async listProfilesPage(options = {}) {
2126
2178
  return (await localStoreModule()).listProfilesPage(options, this.db);
@@ -2129,15 +2181,7 @@ class LocalConfigStore {
2129
2181
  return (await localStoreModule()).getProfile(idOrSlug, this.db);
2130
2182
  }
2131
2183
  async getProfileConfigs(idOrSlug) {
2132
- const configs = [];
2133
- let cursor = 0;
2134
- while (true) {
2135
- const page = await this.getProfileConfigsPage(idOrSlug, { limit: 100, cursor });
2136
- configs.push(...page.items);
2137
- if (page.complete)
2138
- return configs;
2139
- cursor = page.next_cursor;
2140
- }
2184
+ return aggregateBoundedCollection("profile membership", (cursor) => this.getProfileConfigsPage(idOrSlug, { limit: 100, cursor }), (config) => config.id);
2141
2185
  }
2142
2186
  async getProfileConfigsPage(idOrSlug, options = {}) {
2143
2187
  return (await localStoreModule()).getProfileConfigsPage(idOrSlug, options, this.db);
@@ -2218,7 +2262,8 @@ class CloudConfigStore {
2218
2262
  throw err;
2219
2263
  }
2220
2264
  }
2221
- async listConfigs(filter = {}) {
2265
+ async listConfigsPageRemote(filter, options) {
2266
+ const normalized = normalizeBoundedReadOptions(options);
2222
2267
  const params = new URLSearchParams;
2223
2268
  if (filter.category)
2224
2269
  params.set("category", filter.category);
@@ -2228,16 +2273,21 @@ class CloudConfigStore {
2228
2273
  params.set("kind", filter.kind);
2229
2274
  if (filter.search)
2230
2275
  params.set("search", filter.search);
2231
- const qs = params.toString();
2232
- const { data } = await this.request("GET", `/configs${qs ? `?${qs}` : ""}`);
2233
- let configs = data?.configs ?? [];
2276
+ params.set("limit", String(normalized.limit));
2277
+ params.set("cursor", String(normalized.cursor));
2278
+ const { data } = await this.request("GET", `/configs?${params.toString()}`);
2279
+ return parseBoundedOrLegacyPage(data, data?.configs, normalized, "config list");
2280
+ }
2281
+ async listConfigs(filter = {}) {
2282
+ const configs = await aggregateBoundedCollection("config list", (cursor) => this.listConfigsPageRemote(filter, { limit: 100, cursor }), (config) => config.id, { requireAscendingIdentity: true });
2283
+ let filtered = configs;
2234
2284
  if (filter.tags && filter.tags.length > 0) {
2235
- configs = configs.filter((c) => filter.tags.every((t) => c.tags.includes(t)));
2285
+ filtered = filtered.filter((config) => filter.tags.every((tag) => config.tags.includes(tag)));
2236
2286
  }
2237
2287
  if (filter.is_template !== undefined) {
2238
- configs = configs.filter((c) => c.is_template === filter.is_template);
2288
+ filtered = filtered.filter((config) => config.is_template === filter.is_template);
2239
2289
  }
2240
- return configs;
2290
+ return filtered;
2241
2291
  }
2242
2292
  async getConfig(idOrSlug) {
2243
2293
  const { status, data } = await this.request("GET", `/configs/${encodeURIComponent(idOrSlug)}`, undefined, { allow404: true });
@@ -2268,8 +2318,11 @@ class CloudConfigStore {
2268
2318
  return data ?? { total: 0 };
2269
2319
  }
2270
2320
  async listSnapshots(configId) {
2271
- const { data } = await this.request("GET", `/configs/${encodeURIComponent(configId)}/snapshots`);
2272
- return data?.snapshots ?? [];
2321
+ return aggregateBoundedCollection("snapshot list", async (cursor) => {
2322
+ const normalized = normalizeBoundedReadOptions({ limit: 100, cursor });
2323
+ const { data } = await this.request("GET", `/configs/${encodeURIComponent(configId)}/snapshots?limit=${normalized.limit}&cursor=${normalized.cursor}`);
2324
+ return parseBoundedOrLegacyPage(data, data?.snapshots, normalized, "snapshot list");
2325
+ }, (snapshot) => snapshot.id);
2273
2326
  }
2274
2327
  async getSnapshot(id) {
2275
2328
  const { status, data } = await this.request("GET", `/snapshots/${encodeURIComponent(id)}`, undefined, { allow404: true });
@@ -2292,15 +2345,7 @@ class CloudConfigStore {
2292
2345
  return data?.pruned ?? 0;
2293
2346
  }
2294
2347
  async listProfiles() {
2295
- const profiles = [];
2296
- let cursor = 0;
2297
- while (true) {
2298
- const page = await this.listProfilesPage({ limit: 100, cursor });
2299
- profiles.push(...page.items);
2300
- if (page.complete)
2301
- return profiles;
2302
- cursor = page.next_cursor;
2303
- }
2348
+ return aggregateBoundedCollection("profile list", (cursor) => this.listProfilesPage({ limit: 100, cursor }), (profile) => profile.id, { requireAscendingIdentity: true });
2304
2349
  }
2305
2350
  async listProfilesPage(options = {}) {
2306
2351
  const normalized = normalizeBoundedReadOptions(options);
@@ -2324,15 +2369,7 @@ class CloudConfigStore {
2324
2369
  return profile;
2325
2370
  }
2326
2371
  async getProfileConfigs(idOrSlug) {
2327
- const configs = [];
2328
- let cursor = 0;
2329
- while (true) {
2330
- const page = await this.getProfileConfigsPage(idOrSlug, { limit: 100, cursor });
2331
- configs.push(...page.items);
2332
- if (page.complete)
2333
- return configs;
2334
- cursor = page.next_cursor;
2335
- }
2372
+ return aggregateBoundedCollection("profile membership", (cursor) => this.getProfileConfigsPage(idOrSlug, { limit: 100, cursor }), (config) => config.id);
2336
2373
  }
2337
2374
  async getProfileConfigsPage(idOrSlug, options = {}) {
2338
2375
  const normalized = normalizeBoundedReadOptions(options);
@@ -2346,20 +2383,26 @@ class CloudConfigStore {
2346
2383
  return parseBoundedOrLegacyPage(data.configs, data.profile.configs, normalized, "profile membership");
2347
2384
  }
2348
2385
  async getProfileConfigBindings(idOrSlug) {
2349
- const { status, data } = await this.requestProfileRoute(idOrSlug, (profileId) => `/profiles/${encodeURIComponent(profileId)}/bindings`, (value) => Array.isArray(value?.bindings));
2350
- if (status === 404) {
2351
- const profile = await this.getProfile(idOrSlug);
2352
- const configs = await this.getProfileConfigs(idOrSlug);
2353
- return configs.map((config, sort_order) => ({
2354
- profile_id: profile.id,
2355
- config_id: config.id,
2356
- sort_order,
2357
- binding: legacyProfileConfigBinding()
2358
- }));
2359
- }
2360
- if (!data || !Array.isArray(data.bindings))
2361
- throw new ProfileNotFoundError(idOrSlug);
2362
- return data.bindings;
2386
+ let routeMissing = false;
2387
+ const bindings = await aggregateBoundedCollection("profile config bindings", async (cursor) => {
2388
+ const normalized = normalizeBoundedReadOptions({ limit: 100, cursor });
2389
+ const { status, data } = await this.requestProfileRoute(idOrSlug, (profileId) => `/profiles/${encodeURIComponent(profileId)}/bindings?limit=${normalized.limit}&cursor=${normalized.cursor}`, (value) => Boolean(value && (Array.isArray(value.bindings) || Array.isArray(value.items))));
2390
+ if (status === 404) {
2391
+ routeMissing = true;
2392
+ return { ...boundedReadPage([], 0, normalized), source_bounded: false };
2393
+ }
2394
+ return parseBoundedOrLegacyPage(data, data?.bindings, normalized, "profile config bindings");
2395
+ }, (binding) => `${binding.profile_id}\x00${binding.config_id}`);
2396
+ if (!routeMissing)
2397
+ return bindings;
2398
+ const profile = await this.getProfile(idOrSlug);
2399
+ const configs = await this.getProfileConfigs(idOrSlug);
2400
+ return configs.map((config, sort_order) => ({
2401
+ profile_id: profile.id,
2402
+ config_id: config.id,
2403
+ sort_order,
2404
+ binding: legacyProfileConfigBinding()
2405
+ }));
2363
2406
  }
2364
2407
  async requestProfileRoute(idOrSlug, pathForId, isUsable) {
2365
2408
  const attempted = new Set;
@@ -2417,14 +2460,20 @@ class CloudConfigStore {
2417
2460
  await this.request("DELETE", `/profiles/${encodeURIComponent(profileIdOrSlug)}/configs/${encodeURIComponent(configId)}`, undefined, { allow404: true });
2418
2461
  }
2419
2462
  async getProfileAssetBindings(profileIdOrSlug) {
2420
- const { status, data } = await this.requestProfileRoute(profileIdOrSlug, (profileId) => `/profiles/${encodeURIComponent(profileId)}/assets`, (value) => Array.isArray(value?.assets));
2421
- if (status === 404) {
2422
- await this.getProfile(profileIdOrSlug);
2423
- return [];
2424
- }
2425
- if (!data || !Array.isArray(data.assets))
2426
- throw new ProfileNotFoundError(profileIdOrSlug);
2427
- return data.assets;
2463
+ let routeMissing = false;
2464
+ const assets = await aggregateBoundedCollection("profile asset bindings", async (cursor) => {
2465
+ const normalized = normalizeBoundedReadOptions({ limit: 100, cursor });
2466
+ const { status, data } = await this.requestProfileRoute(profileIdOrSlug, (profileId) => `/profiles/${encodeURIComponent(profileId)}/assets?limit=${normalized.limit}&cursor=${normalized.cursor}`, (value) => Boolean(value && (Array.isArray(value.assets) || Array.isArray(value.items))));
2467
+ if (status === 404) {
2468
+ routeMissing = true;
2469
+ return { ...boundedReadPage([], 0, normalized), source_bounded: false };
2470
+ }
2471
+ return parseBoundedOrLegacyPage(data, data?.assets, normalized, "profile asset bindings");
2472
+ }, (asset) => `${asset.profile_id}\x00${asset.binding.assetKey}`);
2473
+ if (!routeMissing)
2474
+ return assets;
2475
+ await this.getProfile(profileIdOrSlug);
2476
+ return [];
2428
2477
  }
2429
2478
  async addAssetToProfile(profileIdOrSlug, sourceConfigId, binding) {
2430
2479
  const { data } = await this.request("POST", `/profiles/${encodeURIComponent(profileIdOrSlug)}/assets`, { source_config_id: sourceConfigId, binding }, { idempotent: true });
@@ -2494,8 +2543,11 @@ class CloudConfigStore {
2494
2543
  await this.request("POST", "/machines/applied", { hostname });
2495
2544
  }
2496
2545
  async listMachines() {
2497
- const { data } = await this.request("GET", "/machines");
2498
- return data?.machines ?? [];
2546
+ return aggregateBoundedCollection("machine list", async (cursor) => {
2547
+ const normalized = normalizeBoundedReadOptions({ limit: 100, cursor });
2548
+ const { data } = await this.request("GET", `/machines?limit=${normalized.limit}&cursor=${normalized.cursor}`);
2549
+ return parseBoundedOrLegacyPage(data, data?.machines, normalized, "machine list");
2550
+ }, (machine) => machine.id, { requireAscendingIdentity: true });
2499
2551
  }
2500
2552
  async sendFeedback(input) {
2501
2553
  await this.request("POST", "/feedback", {