# Modules in code

> What a module is in locosdk, how an app installs one (the spread in schema.ts is the install), the recipes behind the portal's one click install, and the catalogue.

A module is a name shared by up to three halves of `locosdk`, plus a manifest. It ships inside the sdk, carries an integer version, and holds a contract that the app's gate runs. The business view of the same modules: [Modules](https://golocomotive.fr/docs/apps/modules.md).

| half | import | holds |
|---|---|---|
| manifest and collections | `locosdk/schema` | the builder: its collections under the app's names, its rule slots, its options, its catalogue entry, its version and steps |
| server | `locosdk/server` | one bind call: hooks, routes, crons, MCP tools |
| screens | `locosdk/ui` | a screens object with its pages, components, its strings in English and French |

The halves stay in their tiers, so a browser bundle never imports a server half.

## The spread is the install

```ts
// schema.ts
import { auth, crm, defineSchema, numbering, quotes, quotesSeries, text } from 'locosdk/schema'

const staff = '@request.auth.id != ""'

export const numbers = numbering({ readRule: staff, timeZone: 'Europe/Paris', series: { ...quotesSeries() } })

export const schema = defineSchema({
    users: auth({ fields: { name: text() } }),
    ...numbers.collections,
    ...crm({ users: 'users', listRule: staff, createRule: staff, deleteRule: null }).collections,
    ...quotes({ users: 'users', listRule: staff, createRule: staff, deleteRule: null, crm: { companies: 'companies', contacts: 'contacts', deals: 'deals' }, numbers }).collections,
})
```

```ts
// server.ts, before app.serve()
mintNumbers(app)
bindQuotes(app)
```

```ts
// src/client.ts and src/main.tsx: the screens, mounted by the app
export const crmUi = new CrmScreens(installOf(schema, 'crm'), { db, session })
// in the pages of the AppRouter
...crmUi.pages({ group: navGroup('customers') })
```

- **Rules are the module's law.** What the app decides arrives through rule slots (`listRule`, `createRule`, `deleteRule`, and the like) filled with the app's own snippets. A slot left `null` is superusers only.
- **Links between modules are typed options.** `quotes({ crm: … })` names crm's collections in this app, `stock({ site })` scopes stock by the `sites` install. A typo in a target fails the typecheck.
- **One numbering per app.** Every numbered module joins its series into the one `numbering(...)` install and takes it as `numbers`.
- **`defineSchema` refuses** a module collection missing or replaced by an app spec, a module installed twice, a key the module did not name, and a field its snippets read that the app lacks. Each refusal names the way out.
- **`serve()` refuses** an app that holds a module with a server half it never bound.
- Each module's collections are compiled against the composed schema at boot, so a bad slot fails the boot, not the first request.

## Customize through the options

A module grows through its extension points: rule slots, `names` (its collections under the app's words), `fields` (app fields on a module collection, typed into the records), the app's own hooks chained around the module's, the screens' props and slots, and string overlays through `uiI18n.configure`. A client need that no point covers becomes a new point in the sdk, so every app keeps getting the module's fixes through its sdk update and its gate.

## Versions and migrations

- A module's shape is its current collections. Its `steps` are deltas between two of its versions, recorded as `<module>@<n>` in `_migrations`.
- A module none of whose collections exists yet (a fresh database, or a module just added to a live app) is built from its current shape, and every step is recorded as applied.
- At boot: each module's unrecorded steps, then the app's migration files, then the sync.
- Rows a new database needs (the default Admin profile of `rights`) are the module's `defaults`, written once, only into an empty collection.

## The golo commands

| command | what |
|---|---|
| `golo modules [--json]` | what the app installs: each module, its version and options, then every collection and its owner |
| `golo modules check` | every installed module's contract, on an in memory copy of the schema. `golo gate` runs it as a step |
| `golo modules install <name> [--set key=value …] [--write]` | the module's recipe on the app's files: the diff, written with `--write` |
| `golo modules uninstall <name> [--write]` | the module taken out of the app's files, its tables dropped by a migration |

## Recipes: the one click install

A module that the portal installs carries a recipe: the deterministic edits its install makes from the owner's answers, never a model. It asks a few typed questions (an auth collection, a rule, some of the app's collections, a choice, another module to link), then edits `schema.ts` (imports, a declared install, the entries, the accounts collection when the module adds fields to it), `server.ts` (the bind call before `app.serve()`), `src/client.ts` and `src/main.tsx` (the screens and their menu entries), and adds a `new:` line under `## Unreleased` of `CHANGELOG.md` in the app's language.

The portal shows the diff, then applies that exact plan: the dev app stops, the files are written, `golo gate` runs, the dev app starts again on its new tables, and the change is committed and pushed. A red gate or a failed boot restores every file and the database as they were. Uninstall is the inverse, found from the recipe's own import names; it is refused while another module requires this one, or while the app's own code still names its screens.

## The catalogue

Each module has its own page in the agent kit, inside the app: `node_modules/locosdk/agent/api/modules/<name>.md`.

| module | schema | server | screens | recipe |
|---|---|---|---|---|
| `crm` | `crm(...)` | none | `CrmScreens` | yes |
| `quotes` | `quotes(...)`, `quotesSeries()` | `bindQuotes(app)` | `QuotesScreens` | yes, requires numbering |
| `assets` | `assets(...)` | `bindAssets(app)` | `AssetsScreens` | yes |
| `stock` | `stock(...)`, `stockSeries()` | `bindStock(app)` | `StockScreens` | yes, requires numbering |
| `workorders` | `workorders(...)`, `workordersSeries()` | `bindWorkorders(app)` | `WorkordersScreens` | yes, requires numbering and stock |
| `purchasing` | `purchasing(...)`, `purchasingSeries()` | `bindPurchasing(app)` | `PurchasingScreens` | yes, requires numbering |
| `planning` | `planning(...)` | `bindPlanning(app)` | `PlanningScreens` | yes |
| `claims` | `claims(...)` | `bindClaims(app)` | `ClaimsScreens` | yes, requires numbering, crm and assets |
| `chase` | `chase(...)` | `bindChase(app)` | `ChaseScreens` | yes |
| `attachments` | `attachments(...)` | `bindAttachments(app)` | `AttachmentsScreens` | yes |
| `rights` | `rights(...)` | `guardRights(app, access)` | `RightsPage` | yes |
| `sites` | `sites(...)` | `guardSites(app, site)` | `SitesPage` | yes |
| `users` | `accounts(...)` | `guardUsers(app, team)` | `UsersPage` | yes |
| `numbering` | `numbering(...)` | `mintNumbers(app)` | none | yes |
| `lists` | `lists(...)` | `guardLists(app)` | `ListsPage` | yes |
| `staged` | `staged(...)` | `stageWrites(app)` | `ReviewQueue`, `ProposalPanel` | yes |
| `inbox` | `inbox(...)` | `readInbox(app, …)` | `InboxScreen` | yes, requires staged; each inbox's reader is code |
| `audit` | `auditLog(...)` | `journal(app, …)` | `AuditJournal`, `RecordHistory` | yes |
| `views` | `savedViews(...)` | none | `SavedViews`, `ViewsMenu`, `PinnedViews` | yes |
| `bugs` | `bugReports(...)` | none | `BugReportButton`, `BugReportsGrid` | yes |
| `documents` | none | none | the A4 and receipt renderer, VAT per rate, French mentions | no: screens only |
| `mail` | none | `letterhead`, `mailRoute`, `mailTransport` | `openMailCompose` | no: server only |
| `ask` | none | `askBox(app, …)` | `AskButton` | no: server only |
| `dashboard` | none | `dashboardRoute` | `KpiTile`, `ChartCard`, `DashboardFrame` | no |
| `kit` | none | none | `LinesEditor`, `StatusMenu`, the timeline | no: screens only |

A module with no recipe is wired by an agent, on a request.
