@lotics/cli 0.76.1 → 0.86.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/client.js CHANGED
@@ -143,15 +143,6 @@ export class LoticsClient {
143
143
  async listWorkspaces() {
144
144
  return this.request("GET", "/v1/workspaces");
145
145
  }
146
- /**
147
- * Resolve one workspace's info by id from the org's workspace list (the only
148
- * API-key-accessible source carrying `is_dev`). Returns null when the
149
- * workspace isn't visible to these credentials.
150
- */
151
- async getWorkspaceInfo(id) {
152
- const workspaces = await this.listWorkspaces();
153
- return workspaces.find((w) => w.id === id) ?? null;
154
- }
155
146
  async createWorkspace(body) {
156
147
  return this.request("POST", "/v1/workspaces", body);
157
148
  }
@@ -218,7 +209,7 @@ export class LoticsClient {
218
209
  /**
219
210
  * Uninstall a standalone content package — delete the installation row. By
220
211
  * default the package-bound docs are ARCHIVED; `keep_content` retains them as
221
- * ordinary workspace docs. Admin-only. Backs `lotics package uninstall`.
212
+ * ordinary workspace docs. Admin-only. Backs `lotics uninstall`.
222
213
  */
223
214
  async uninstallContentPackage(installation_id, opts = {}) {
224
215
  const qs = opts.keep_content ? "?keep_content=true" : "";
@@ -253,7 +244,7 @@ export class LoticsClient {
253
244
  return this.request("GET", `/v1/workspaces/${encodeURIComponent(workspace_id)}/content-installations`);
254
245
  }
255
246
  /**
256
- * Uninstall a package installation (backs `lotics package uninstall`). Does
247
+ * Uninstall a package installation (backs `lotics uninstall`). Does
257
248
  * everything DELETE does plus archives the installation's lifecycle
258
249
  * artifacts; with `archive_tables` it also archives the scaffolded entity
259
250
  * tables — refused server-side unless this installation created them
@@ -271,10 +262,11 @@ export class LoticsClient {
271
262
  return this.request("PATCH", `/v1/apps/${encodeURIComponent(app_id)}/package-config`, body);
272
263
  }
273
264
  /**
274
- * Retire (or `undo` un-retire) a registry package (backs `lotics package
275
- * retire`). Retiring refuses NEW installs and hides the package from
276
- * non-owning orgs; existing installations keep working and may still upgrade.
277
- * Owner-org admin-only.
265
+ * Retire (or `undo` un-retire) a registry package (backs `lotics app
266
+ * unpublish` the endpoint/audit action keep the `retire` name to avoid API
267
+ * churn). Retiring refuses NEW installs and hides the package from non-owning
268
+ * orgs; existing installations keep working and may still upgrade. Owner-org
269
+ * admin-only.
278
270
  */
279
271
  async retirePackage(package_id, body) {
280
272
  return this.request("POST", `/v1/packages/${encodeURIComponent(package_id)}/retire`, body);
@@ -289,42 +281,12 @@ export class LoticsClient {
289
281
  async ejectPackage(app_id) {
290
282
  return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/eject`);
291
283
  }
292
- /**
293
- * Extract a DRAFT app package from an existing bespoke app — the promotion
294
- * read (docs/packages.md § Promotion). Pure: nothing is written. Returns
295
- * the alias-keyed draft `contract` (opaque to the CLI — the server is the
296
- * validating authority), the origin workspace's `binding` (which doubles as
297
- * the adopt binding), a findings `report` (any `error` ⇒ not publishable
298
- * as-is), and the file-backed `template_files` the CLI must stage into the
299
- * project at their `bytes_ref` paths. Backs `lotics package extract`.
300
- * Admin-only.
301
- */
302
- async extractPackage(app_id, opts = {}) {
303
- // The knowledge declaration ([{alias, doc_id}]) rides as a JSON-string query
304
- // param — extract stays a GET (a pure read); the server validates it.
305
- const qs = opts.knowledge && opts.knowledge.length > 0
306
- ? `?knowledge=${encodeURIComponent(JSON.stringify(opts.knowledge))}`
307
- : "";
308
- return this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}/package-extract${qs}`);
309
- }
310
- /**
311
- * Adopt a published package onto an EXISTING (bespoke or ejected) app — the
312
- * final promotion step. The workspace already holds the concrete objects, so
313
- * nothing is scaffolded or rewritten: the server verifies the `binding` is
314
- * complete, live, and FAITHFUL to the version's contract, then writes only the
315
- * installation pin (the app becomes installation #1, upgradeable again). A
316
- * ConflictError names the aliases that diverge. `version` omitted adopts the
317
- * latest. Backs `lotics package adopt`. Admin-only.
318
- */
319
- async adoptPackage(app_id, body) {
320
- return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/package-adopt`, body);
321
- }
322
284
  /**
323
285
  * Fleet upgrade — bring every installation of a package across the CALLER'S
324
286
  * org to the target version (latest when omitted) in one call. Hands-off
325
287
  * applies only where the preview is clean; installations with breaking/
326
288
  * drift/modified-core findings are skipped and reported for the normal
327
- * per-installation consent flow. Backs `lotics package fleet-upgrade`.
289
+ * per-installation consent flow. Backs `lotics upgrade <package_id>` (fleet).
328
290
  * Admin-only; org-scoped (no workspace header needed).
329
291
  */
330
292
  /**
@@ -338,16 +300,10 @@ export class LoticsClient {
338
300
  async fleetUpgradePackage(package_id, body) {
339
301
  return this.request("POST", `/v1/packages/${encodeURIComponent(package_id)}/fleet-upgrade`, body);
340
302
  }
341
- // --- App packages (registry authoring + dev harness) ---
342
- /**
343
- * Create a registry app package the Lotics-owned, workspace-agnostic
344
- * blueprint. `lotics package publish` calls this on first publish (when the
345
- * local manifest has no `package_id`), then publishes version 1 against the
346
- * returned id. Admin-only.
347
- */
348
- async createPackage(body) {
349
- return this.request("POST", "/v1/packages", body);
350
- }
303
+ // --- Packages (registry reads + installations) ---
304
+ // Authoring is server-side: apps via `POST /v1/apps/{id}/package-publish|release`,
305
+ // content packages via the `publish_content`/`release_content` tools. There is no
306
+ // client-side create-package / upload-bundle path.
351
307
  /**
352
308
  * Fetch a registry app package's metadata (incl. `kind`, `latest_version` and
353
309
  * the Lotics-backed `is_official` trust badge). Admin-only; cross-tenant by id.
@@ -359,31 +315,6 @@ export class LoticsClient {
359
315
  async listPackageVersions(package_id) {
360
316
  return this.request("GET", `/v1/packages/${encodeURIComponent(package_id)}/versions`);
361
317
  }
362
- /**
363
- * Publish a new immutable package version — multipart upload of the alias-keyed
364
- * contract (JSON) + the prebuilt code bundle (a gzipped tarball carrying
365
- * `source.tar.gz` + `dist.tar.gz` members). The server validates the contract +
366
- * bundle, then allocates the next monotonic version. The `contract` is opaque
367
- * JSON to the transport (the server is the validating authority). Admin-only.
368
- */
369
- async publishPackageVersion(package_id, args) {
370
- const formData = new FormData();
371
- formData.append("contract", JSON.stringify(args.contract));
372
- formData.append("bundle", new Blob([new Uint8Array(args.bundle)], { type: "application/gzip" }), "bundle.tar.gz");
373
- if (args.changelog)
374
- formData.append("changelog", args.changelog);
375
- if (args.channel)
376
- formData.append("channel", args.channel);
377
- const url = `${this.baseUrl}/v1/packages/${encodeURIComponent(package_id)}/versions`;
378
- const response = await fetch(url, {
379
- method: "POST",
380
- headers: this.buildHeaders(), // no Content-Type — fetch sets the multipart boundary
381
- body: formData,
382
- });
383
- if (!response.ok)
384
- await this.throwResponseError(response);
385
- return response.json();
386
- }
387
318
  /**
388
319
  * Upgrade a package installation to a newer published version — extends the
389
320
  * binding additively, re-materializes the target version's
@@ -403,14 +334,6 @@ export class LoticsClient {
403
334
  const query = opts.version !== undefined ? `?version=${opts.version}` : "";
404
335
  return this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}/package-upgrade${query}`);
405
336
  }
406
- /**
407
- * Re-bind a package role to a different workspace group (the current group
408
- * still exists). Re-materializes at the pinned version; refuses over
409
- * modified-core findings. Admin-only.
410
- */
411
- async rebindPackageRole(app_id, body) {
412
- return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/package-rebind-role`, body);
413
- }
414
337
  /**
415
338
  * Workspace-wide dangling-reference sweep — active app/workflow artifacts
416
339
  * whose prefixed schema ids no longer resolve. Backs
@@ -428,13 +351,67 @@ export class LoticsClient {
428
351
  return this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}/package-health`);
429
352
  }
430
353
  /**
431
- * Reset a package installation in a DEV workspace drop the package-owned
432
- * scaffolded tables and re-scaffold clean. Hard-gated server-side to dev
433
- * workspaces (a non-dev workspace is refused). Returns the resulting app.
434
- * Admin-only.
354
+ * Preview a release the dry run behind `lotics app release`. Runs the
355
+ * binding-aware extract of the origin (aliases stable through the app's current
356
+ * binding) and reports the next version number, the new + changed aliases, the
357
+ * bundled-knowledge delta, and any extract findings (an `error` blocks the
358
+ * apply). An optional `knowledge` declaration (from the pulled app manifest)
359
+ * re-declares the bundle set — added/dropped/changed docs surface in the delta;
360
+ * omitted, the current corpus is reconstructed from the pin. No writes. Admin,
361
+ * owning-org only.
362
+ */
363
+ async previewPackageRelease(app_id, opts = {}) {
364
+ const params = new URLSearchParams();
365
+ if (opts.knowledge !== undefined && opts.knowledge.length > 0) {
366
+ params.set("knowledge", JSON.stringify(opts.knowledge));
367
+ }
368
+ const query = params.toString();
369
+ return this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}/package-release${query ? `?${query}` : ""}`);
370
+ }
371
+ /**
372
+ * Release — snapshot the origin app into the next registry version. The server
373
+ * binding-aware-extracts it, repackages its deployed source + dist as the
374
+ * bundle, publishes the next `release`-channel version with the changelog, and
375
+ * re-pins the origin. An optional `knowledge` declaration re-declares the
376
+ * bundled-knowledge set (added/dropped/re-snapshotted docs; omitted preserves
377
+ * the current corpus). Error findings from extract surface as a 409; a
378
+ * missing/archived declared doc is a 400, a foreign-package doc a 409. Admin,
379
+ * owning-org only. Backs `lotics app release --yes`.
380
+ */
381
+ async releasePackage(app_id, body) {
382
+ return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/package-release`, body);
383
+ }
384
+ /**
385
+ * Dry-run preview of a first-release — the `GET` behind `lotics app publish`
386
+ * (no `--yes`), the publish-side analogue of `previewPackageRelease`. The
387
+ * server runs the same fresh-alias extract + `src/` scan the apply runs
388
+ * (through any `renames`) and returns the package name it would mint, the
389
+ * auto-minted RENAMABLE aliases (the exact `--rename` keys), and the extract
390
+ * findings (an `error` blocks the apply). No writes. Admin-only.
391
+ */
392
+ async previewPublishAppPackage(app_id, opts = {}) {
393
+ const params = new URLSearchParams();
394
+ if (opts.knowledge !== undefined && opts.knowledge.length > 0) {
395
+ params.set("knowledge", JSON.stringify(opts.knowledge));
396
+ }
397
+ if (opts.renames !== undefined && opts.renames.length > 0) {
398
+ params.set("renames", JSON.stringify(opts.renames));
399
+ }
400
+ const query = params.toString();
401
+ return this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}/package-publish${query ? `?${query}` : ""}`);
402
+ }
403
+ /**
404
+ * First-release apply — mint a package from a BESPOKE app and publish v1 in one
405
+ * call (the `POST` behind `lotics app publish --yes`). The server extracts an
406
+ * alias-keyed contract from the app (fresh aliases; `renames` fixes them before
407
+ * v1 freezes), creates the registry package (name/description from the app),
408
+ * publishes v1 from the app's deployed source + dist, and pins the origin as
409
+ * installation #1. Error findings from extract surface as a 409. An
410
+ * already-linked app must use `releasePackage` instead. Admin-only. Backs
411
+ * `lotics app publish <app_id>`.
435
412
  */
436
- async resetPackage(app_id) {
437
- return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/package-reset`);
413
+ async publishAppAsPackage(app_id, body) {
414
+ return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/package-publish`, body);
438
415
  }
439
416
  /**
440
417
  * Resolve the display name + fields (incl. select options) of the given tables
@@ -127,7 +127,12 @@ function inputDeclToTsType(decl) {
127
127
  return null;
128
128
  })
129
129
  .filter((v) => v !== null);
130
- const inner = literals.length > 0 ? literals.join(" | ") : "string";
130
+ // The literal union keeps autocomplete for hand-typed ids, but a package
131
+ // app addresses options through the runtime-resolved OPT map (typed
132
+ // string — consumer workspaces bind DIFFERENT concrete ids), so the
133
+ // input must also accept string. `(string & {})` widens without
134
+ // collapsing the union in intellisense.
135
+ const inner = literals.length > 0 ? `${literals.join(" | ")} | (string & {})` : "string";
131
136
  return decl.multi === true ? `ReadonlyArray<${inner}>` : inner;
132
137
  }
133
138
  case "date_range":
@@ -193,7 +198,11 @@ function outputDeclToTsType(decl) {
193
198
  ? JSON.stringify(o.value)
194
199
  : null)
195
200
  .filter((v) => v !== null);
196
- const inner = literals.length > 0 ? literals.join(" | ") : "string";
201
+ // Same widening on the READ side: a package app's runtime output value is
202
+ // the CONSUMER workspace's concrete opt_ id, not the origin's literal —
203
+ // exhaustive narrowing over origin literals would be unsound. `(string & {})`
204
+ // keeps autocomplete without lying about the value space.
205
+ const inner = literals.length > 0 ? `${literals.join(" | ")} | (string & {})` : "string";
197
206
  return decl.multi === true ? `ReadonlyArray<${inner}>` : inner;
198
207
  }
199
208
  case "object": {
@@ -77,8 +77,8 @@ describe("generateAppWorkflowsDts", () => {
77
77
  });
78
78
  expect(dts).toContain("record_id: string;"); // required → NOT nullable
79
79
  expect(dts).toContain("due?: string | null;"); // optional scalar → clearable
80
- expect(dts).toContain('status?: "opt_done" | null;');
81
- expect(dts).toContain('tags?: ReadonlyArray<"opt_a"> | null;'); // optional multi
80
+ expect(dts).toContain('status?: "opt_done" | (string & {}) | null;');
81
+ expect(dts).toContain('tags?: ReadonlyArray<"opt_a" | (string & {})> | null;'); // optional multi
82
82
  // The object itself is a top-level optional → nullable; its NESTED optional
83
83
  // field mirrors the backend's plain `.optional()` (no `| null`).
84
84
  expect(dts).toContain("note?: string;");
@@ -94,7 +94,7 @@ describe("generateAppWorkflowsDts", () => {
94
94
  },
95
95
  },
96
96
  });
97
- expect(dts).toContain('owner: string; tags: ReadonlyArray<"a" | "b">');
97
+ expect(dts).toContain('owner: string; tags: ReadonlyArray<"a" | "b" | (string & {})>');
98
98
  });
99
99
  it("types a multi file input as ReadonlyArray<string>, single file as string", () => {
100
100
  const dts = generateAppWorkflowsDts({
@@ -1,48 +1,49 @@
1
1
  /**
2
- * Codegen: emit a RUNTIME `.lotics/app_fields.ts` for a PACKAGE project from
3
- * its `contract.json`.
2
+ * Codegen: emit a RUNTIME `.lotics/app_fields.ts` for a PUBLISHED / linked app
3
+ * (a package installation — its own published origin, or an install of another
4
+ * org's package) from that installation's LIVE binding.
4
5
  *
5
- * The bespoke twin (`generate_app_fields.ts`) bakes this workspace's concrete
6
- * `fld_…`/`opt_…` ids at authoring time — correct when source and workspace
7
- * co-live, fundamentally wrong for a package (one bundle, many workspaces,
8
- * different ids in each). A package project instead resolves the SAME `F` /
9
- * `OPT` surface at MODULE LOAD from the installation's binding via the SDK's
10
- * `getAppBinding()` (the `binding` RPC op), using top-level await — the ESM
11
- * graph waits for the binding before any importer evaluates, so
12
- * `F.TASKS.title` is a plain string everywhere, including module-top-level
13
- * constants. Extracted bespoke sources therefore compile unchanged: same
14
- * import path, same shapes, same alias naming (contract aliases derive from
15
- * the same display names the bespoke codegen slugifies).
6
+ * The bespoke twin (`generate_app_fields.ts`) bakes THIS workspace's concrete
7
+ * `fld_…`/`opt_…` ids at codegen time — correct when source and workspace
8
+ * co-live, fundamentally wrong for a linked app (the source is portable and
9
+ * installs into many workspaces, each with different ids). A linked app instead
10
+ * resolves the SAME `F` / `OPT` / `ROLE` surface at MODULE LOAD from the
11
+ * installation's binding via the SDK's `getAppBinding()` (the `binding` RPC op),
12
+ * using top-level await — the ESM graph waits for the binding before any
13
+ * importer evaluates, so `F.TASKS.title` is a plain string everywhere, including
14
+ * module-top-level constants. A published origin's source is portable by
15
+ * construction: same import path, same `F`/`OPT` shapes the bespoke codegen
16
+ * emitted (contract aliases derive from the same display names it slugifies), so
17
+ * the deployed source compiles unchanged, resolved per-install.
16
18
  *
17
- * A missing alias fails LOUD at boot (the binding is adopt/install-verified
18
- * complete, so a miss means the generated file is stale relative to the
19
- * installed contract version) never a silent undefined key in a write.
19
+ * The generator sources the alias SET from the app's LIVE binding (the `binding`
20
+ * RPC / `appBinding` client method), NOT a contract file: every bound field
21
+ * (`entity.field`), option (`entity.field:opt`), and role becomes an `F` / `OPT`
22
+ * / `ROLE` entry keyed by its stable alias.
23
+ *
24
+ * A missing alias fails LOUD at boot (the binding is install-verified complete,
25
+ * so a miss means the generated file is stale relative to the installed contract
26
+ * version) — never a silent undefined key in a write.
20
27
  *
21
28
  * Requires the app build to target es2022+ (the starter's `build.target`) —
22
29
  * top-level await does not exist below it.
23
30
  */
24
- /** The subset of a contract this generator reads (structural, not zod-validated —
25
- * publish is the validating boundary; codegen must work on drafts too). */
26
- export interface PackageContractShape {
27
- entities?: Array<{
28
- alias: string;
29
- fields?: Array<{
30
- alias: string;
31
- options?: Array<{
32
- alias: string;
33
- }>;
34
- }>;
35
- }>;
36
- roles?: Array<{
37
- alias: string;
38
- }>;
31
+ /**
32
+ * A materialized app installation's binding the exact shape the `binding` RPC
33
+ * / `client.appBinding()` returns (a subset of the full package binding: only
34
+ * the maps the client-side `F`/`OPT`/`ROLE` surface resolves through). Keys are
35
+ * fully-qualified contract aliases: `fields` keyed `entity.field`, `options`
36
+ * keyed `entity.field:opt`, `roles` keyed by role alias.
37
+ */
38
+ export interface AppFieldsBinding {
39
+ fields: Record<string, string>;
40
+ options: Record<string, string>;
41
+ roles: Record<string, string>;
39
42
  }
40
43
  /**
41
- * Generate the full package `.lotics/app_fields.ts` source. Valid contract
42
- * aliases are slug-grammar (`^[a-z][a-z0-9_]*$`) and emit as bare identifiers
43
- * but this also runs on unvalidated DRAFTS (dev/sync pre-publish), so every
44
- * key goes through `propKey`, which quotes anything that isn't a valid
45
- * identifier instead of emitting broken TypeScript into a "DO NOT EDIT" file.
46
- * Pure; same contract → same bytes.
44
+ * Generate the full binding-resolved `.lotics/app_fields.ts` source from an
45
+ * installation's live binding. Pure same binding same bytes. Idempotent.
46
+ * An empty binding yields valid, empty `F`/`OPT`/`ROLE` maps so the file always
47
+ * compiles and imports resolve.
47
48
  */
48
- export declare function generatePackageAppFields(contract: PackageContractShape): string;
49
+ export declare function generatePackageAppFields(binding: AppFieldsBinding): string;
@@ -1,34 +1,41 @@
1
1
  /**
2
- * Codegen: emit a RUNTIME `.lotics/app_fields.ts` for a PACKAGE project from
3
- * its `contract.json`.
2
+ * Codegen: emit a RUNTIME `.lotics/app_fields.ts` for a PUBLISHED / linked app
3
+ * (a package installation — its own published origin, or an install of another
4
+ * org's package) from that installation's LIVE binding.
4
5
  *
5
- * The bespoke twin (`generate_app_fields.ts`) bakes this workspace's concrete
6
- * `fld_…`/`opt_…` ids at authoring time — correct when source and workspace
7
- * co-live, fundamentally wrong for a package (one bundle, many workspaces,
8
- * different ids in each). A package project instead resolves the SAME `F` /
9
- * `OPT` surface at MODULE LOAD from the installation's binding via the SDK's
10
- * `getAppBinding()` (the `binding` RPC op), using top-level await — the ESM
11
- * graph waits for the binding before any importer evaluates, so
12
- * `F.TASKS.title` is a plain string everywhere, including module-top-level
13
- * constants. Extracted bespoke sources therefore compile unchanged: same
14
- * import path, same shapes, same alias naming (contract aliases derive from
15
- * the same display names the bespoke codegen slugifies).
6
+ * The bespoke twin (`generate_app_fields.ts`) bakes THIS workspace's concrete
7
+ * `fld_…`/`opt_…` ids at codegen time — correct when source and workspace
8
+ * co-live, fundamentally wrong for a linked app (the source is portable and
9
+ * installs into many workspaces, each with different ids). A linked app instead
10
+ * resolves the SAME `F` / `OPT` / `ROLE` surface at MODULE LOAD from the
11
+ * installation's binding via the SDK's `getAppBinding()` (the `binding` RPC op),
12
+ * using top-level await — the ESM graph waits for the binding before any
13
+ * importer evaluates, so `F.TASKS.title` is a plain string everywhere, including
14
+ * module-top-level constants. A published origin's source is portable by
15
+ * construction: same import path, same `F`/`OPT` shapes the bespoke codegen
16
+ * emitted (contract aliases derive from the same display names it slugifies), so
17
+ * the deployed source compiles unchanged, resolved per-install.
16
18
  *
17
- * A missing alias fails LOUD at boot (the binding is adopt/install-verified
18
- * complete, so a miss means the generated file is stale relative to the
19
- * installed contract version) never a silent undefined key in a write.
19
+ * The generator sources the alias SET from the app's LIVE binding (the `binding`
20
+ * RPC / `appBinding` client method), NOT a contract file: every bound field
21
+ * (`entity.field`), option (`entity.field:opt`), and role becomes an `F` / `OPT`
22
+ * / `ROLE` entry keyed by its stable alias.
23
+ *
24
+ * A missing alias fails LOUD at boot (the binding is install-verified complete,
25
+ * so a miss means the generated file is stale relative to the installed contract
26
+ * version) — never a silent undefined key in a write.
20
27
  *
21
28
  * Requires the app build to target es2022+ (the starter's `build.target`) —
22
29
  * top-level await does not exist below it.
23
30
  */
24
31
  import { propKey } from "./generate_app_fields.js";
25
- const HEADER = `// Auto-generated by 'lotics package new/extract/dev/sync' from contract.json.
26
- // DO NOT EDIT — regenerated whenever the contract changes.
32
+ const HEADER = `// Auto-generated by 'lotics app codegen' (linked/published app; also app pull/dev/deploy).
33
+ // DO NOT EDIT — regenerated from the installation's live binding.
27
34
  //
28
- // Package apps resolve F/OPT/ROLE at MODULE LOAD from the installation's
29
- // binding (contract alias → THIS workspace's concrete id) via the SDK's
30
- // \`binding\` RPC. Top-level await: the module graph waits for the binding
31
- // before any importer evaluates, so every entry is a plain string.
35
+ // A package installation resolves F/OPT/ROLE at MODULE LOAD from its binding
36
+ // (contract alias → THIS workspace's concrete id) via the SDK's \`binding\` RPC.
37
+ // Top-level await: the module graph waits for the binding before any importer
38
+ // evaluates, so every entry is a plain string.
32
39
  import { getAppBinding } from "@lotics/app-sdk";
33
40
 
34
41
  const binding = await getAppBinding();
@@ -45,54 +52,100 @@ function bound(map: Record<string, string>, key: string, kind: string): string {
45
52
  }
46
53
  `;
47
54
  /**
48
- * Generate the full package `.lotics/app_fields.ts` source. Valid contract
49
- * aliases are slug-grammar (`^[a-z][a-z0-9_]*$`) and emit as bare identifiers
50
- * but this also runs on unvalidated DRAFTS (dev/sync pre-publish), so every
51
- * key goes through `propKey`, which quotes anything that isn't a valid
52
- * identifier instead of emitting broken TypeScript into a "DO NOT EDIT" file.
53
- * Pure; same contract same bytes.
55
+ * Reconstruct the entity field option nesting (plus role list) from the flat
56
+ * binding maps. Entity/field/option aliases are slug-grammar (`[a-z][a-z0-9_]*`,
57
+ * no `.` or `:`), so a field key splits on its single `.` and an option key on
58
+ * its single `:`. First-seen order is preserved (the binding is written in
59
+ * contract order), so the output is deterministic for a given binding. An option
60
+ * whose owning field is absent from `fields` is skipped defensively — the binding
61
+ * is the authority, and a select field is always present among the fields.
54
62
  */
55
- export function generatePackageAppFields(contract) {
56
- const entities = contract.entities ?? [];
57
- const roles = contract.roles ?? [];
58
- const fieldBlocks = [];
59
- for (const entity of entities) {
60
- const lines = (entity.fields ?? []).map((field) => ` ${propKey(field.alias)}: bound(binding.fields, ${JSON.stringify(`${entity.alias}.${field.alias}`)}, "field"),`);
61
- if (lines.length === 0)
63
+ function parseBinding(binding) {
64
+ const entities = [];
65
+ const entityByAlias = new Map();
66
+ const fieldByKey = new Map();
67
+ for (const key of Object.keys(binding.fields)) {
68
+ const dot = key.indexOf(".");
69
+ if (dot <= 0 || dot >= key.length - 1)
70
+ continue;
71
+ const entityAlias = key.slice(0, dot);
72
+ const fieldAlias = key.slice(dot + 1);
73
+ let entity = entityByAlias.get(entityAlias);
74
+ if (entity === undefined) {
75
+ entity = { alias: entityAlias, fields: [] };
76
+ entityByAlias.set(entityAlias, entity);
77
+ entities.push(entity);
78
+ }
79
+ const field = { alias: fieldAlias, key, options: [] };
80
+ entity.fields.push(field);
81
+ fieldByKey.set(key, field);
82
+ }
83
+ for (const key of Object.keys(binding.options)) {
84
+ const colon = key.indexOf(":");
85
+ if (colon <= 0 || colon >= key.length - 1)
86
+ continue;
87
+ const fieldKey = key.slice(0, colon);
88
+ const optionAlias = key.slice(colon + 1);
89
+ const field = fieldByKey.get(fieldKey);
90
+ if (field === undefined)
62
91
  continue;
63
- fieldBlocks.push(` ${propKey(entity.alias.toUpperCase())}: {\n${lines.join("\n")}\n },`);
92
+ field.options.push({ alias: optionAlias, key });
64
93
  }
65
- const optionBlocks = [];
94
+ return { entities, roles: Object.keys(binding.roles) };
95
+ }
96
+ /** Emit the `F` map: `{ ENTITY: { field: bound(binding.fields, "entity.field", "field") } }`. */
97
+ function emitFieldMap(entities) {
98
+ if (entities.length === 0)
99
+ return `export const F = {} as const;`;
100
+ const blocks = entities.map((entity) => {
101
+ const lines = entity.fields.map((field) => ` ${propKey(field.alias)}: bound(binding.fields, ${JSON.stringify(field.key)}, "field"),`);
102
+ return ` ${propKey(entity.alias.toUpperCase())}: {\n${lines.join("\n")}\n },`;
103
+ });
104
+ return `export const F = {\n${blocks.join("\n")}\n} as const;`;
105
+ }
106
+ /**
107
+ * Emit the `OPT` map: `{ ENTITY: { field: { opt: bound(binding.options, …) } } }`.
108
+ * Only fields carrying options appear (an option-less field/entity is omitted).
109
+ */
110
+ function emitOptionMap(entities) {
111
+ const entityBlocks = [];
66
112
  for (const entity of entities) {
67
- const perField = [];
68
- for (const field of entity.fields ?? []) {
69
- const options = field.options ?? [];
70
- if (options.length === 0)
113
+ const fieldBlocks = [];
114
+ for (const field of entity.fields) {
115
+ if (field.options.length === 0)
71
116
  continue;
72
- const lines = options.map((option) => ` ${propKey(option.alias)}: bound(binding.options, ${JSON.stringify(`${entity.alias}.${field.alias}:${option.alias}`)}, "option"),`);
73
- perField.push(` ${propKey(field.alias)}: {\n${lines.join("\n")}\n },`);
74
- }
75
- if (perField.length > 0) {
76
- optionBlocks.push(` ${propKey(entity.alias.toUpperCase())}: {\n${perField.join("\n")}\n },`);
117
+ const lines = field.options.map((option) => ` ${propKey(option.alias)}: bound(binding.options, ${JSON.stringify(option.key)}, "option"),`);
118
+ fieldBlocks.push(` ${propKey(field.alias)}: {\n${lines.join("\n")}\n },`);
77
119
  }
120
+ if (fieldBlocks.length === 0)
121
+ continue;
122
+ entityBlocks.push(` ${propKey(entity.alias.toUpperCase())}: {\n${fieldBlocks.join("\n")}\n },`);
78
123
  }
79
- const fMap = fieldBlocks.length > 0
80
- ? `export const F = {\n${fieldBlocks.join("\n")}\n} as const;`
81
- : `export const F = {} as const;`;
82
- const optMap = optionBlocks.length > 0
83
- ? `export const OPT = {\n${optionBlocks.join("\n")}\n} as const;`
84
- : `export const OPT = {} as const;`;
85
- const roleMap = roles.length > 0
86
- ? `export const ROLE = {\n${roles
87
- .map((role) => ` ${propKey(role.alias)}: bound(binding.roles, ${JSON.stringify(role.alias)}, "role"),`)
88
- .join("\n")}\n} as const;`
89
- : `export const ROLE = {} as const;`;
124
+ if (entityBlocks.length === 0)
125
+ return `export const OPT = {} as const;`;
126
+ return `export const OPT = {\n${entityBlocks.join("\n")}\n} as const;`;
127
+ }
128
+ /** Emit the `ROLE` map: `{ role: bound(binding.roles, "role", "role") }`. */
129
+ function emitRoleMap(roles) {
130
+ if (roles.length === 0)
131
+ return `export const ROLE = {} as const;`;
132
+ const lines = roles.map((role) => ` ${propKey(role)}: bound(binding.roles, ${JSON.stringify(role)}, "role"),`);
133
+ return `export const ROLE = {\n${lines.join("\n")}\n} as const;`;
134
+ }
135
+ /**
136
+ * Generate the full binding-resolved `.lotics/app_fields.ts` source from an
137
+ * installation's live binding. Pure — same binding → same bytes. Idempotent.
138
+ * An empty binding yields valid, empty `F`/`OPT`/`ROLE` maps so the file always
139
+ * compiles and imports resolve.
140
+ */
141
+ export function generatePackageAppFields(binding) {
142
+ const { entities, roles } = parseBinding(binding);
90
143
  return `${HEADER}
91
- ${fMap}
144
+ ${emitFieldMap(entities)}
92
145
 
93
- ${optMap}
146
+ ${emitOptionMap(entities)}
94
147
 
95
- ${roleMap}
148
+ ${emitRoleMap(roles)}
96
149
 
97
150
  /** Field-id alias map: \`F[<ENTITY>][<field>]\` is this installation's \`fld_…\` id. */
98
151
  export type AppFields = typeof F;