@_linked/cli 1.12.0 → 1.14.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 (68) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +81 -38
  3. package/defaults/app-react-native/README.md +62 -22
  4. package/defaults/app-react-native/apps/mobile/App.tsx +6 -3
  5. package/defaults/app-react-native/apps/mobile/__tests__/env.test.ts +39 -0
  6. package/defaults/app-react-native/apps/mobile/__tests__/integration/apiEnv.ts +49 -0
  7. package/defaults/app-react-native/apps/mobile/__tests__/integration/globalSetup.ts +156 -0
  8. package/defaults/app-react-native/apps/mobile/__tests__/integration/globalTeardown.ts +20 -0
  9. package/defaults/app-react-native/apps/mobile/__tests__/integration/nodeFetchEnvironment.js +13 -0
  10. package/defaults/app-react-native/apps/mobile/__tests__/integration/personOverview.test.tsx +51 -0
  11. package/defaults/app-react-native/apps/mobile/__tests__/integration/restoreNodeFetch.ts +8 -0
  12. package/defaults/app-react-native/apps/mobile/__tests__/{linkedDefaults.test.tsx → nativeDefaults.test.tsx} +5 -7
  13. package/defaults/app-react-native/apps/mobile/__tests__/storage.test.ts +24 -0
  14. package/defaults/app-react-native/apps/mobile/__tests__/stripJsonImportAttributes.test.ts +45 -0
  15. package/defaults/app-react-native/apps/mobile/app.config.ts +21 -0
  16. package/defaults/app-react-native/apps/mobile/babel/stripJsonImportAttributes.js +41 -0
  17. package/defaults/app-react-native/apps/mobile/babel.config.js +13 -0
  18. package/defaults/app-react-native/apps/mobile/jest.integration.config.js +16 -0
  19. package/defaults/app-react-native/apps/mobile/package.json +11 -3
  20. package/defaults/app-react-native/apps/mobile/src/components/PersonOverview.tsx +123 -0
  21. package/defaults/app-react-native/apps/mobile/src/components/PersonOverviewContext.tsx +12 -0
  22. package/defaults/app-react-native/apps/mobile/src/components/PersonPreview.tsx +135 -0
  23. package/defaults/app-react-native/apps/mobile/src/shell/apiPort.json +1 -0
  24. package/defaults/app-react-native/apps/mobile/src/shell/env.ts +30 -0
  25. package/defaults/app-react-native/apps/mobile/src/shell/storage.ts +9 -0
  26. package/defaults/app-react-native/docker-compose.yml +22 -0
  27. package/defaults/app-react-native/eslint.config.js +47 -0
  28. package/defaults/app-react-native/gitignore.template +5 -0
  29. package/defaults/app-react-native/package.json +14 -4
  30. package/defaults/app-react-native/packages/app-shapes/package.json +20 -3
  31. package/defaults/app-react-native/packages/app-shapes/tsconfig-esm.json +12 -0
  32. package/defaults/app-react-native/scripts/check-react.mjs +55 -0
  33. package/defaults/app-react-native/services/api/README.md +50 -2
  34. package/defaults/app-react-native/services/api/env.example.template +11 -0
  35. package/defaults/app-react-native/services/api/linked.backend.datasets.json +15 -0
  36. package/defaults/app-react-native/services/api/linked.backend.storage.ts +35 -0
  37. package/defaults/app-react-native/services/api/package.json +27 -1
  38. package/defaults/app-react-native/services/api/scripts/wait-for-fuseki.mjs +26 -0
  39. package/defaults/app-react-native/services/api/src/backend.ts +9 -0
  40. package/defaults/app-react-native/services/api/src/fileStoreEnv.ts +14 -0
  41. package/defaults/app-react-native/services/api/test/hasS3Env.test.mjs +28 -0
  42. package/defaults/app-react-native/services/api/test/waitForFuseki.test.mjs +27 -0
  43. package/defaults/app-react-native/services/api/tsconfig.json +15 -0
  44. package/lib/esm/cli-methods.d.ts +11 -1
  45. package/lib/esm/cli-methods.d.ts.map +1 -1
  46. package/lib/esm/cli-methods.js +122 -58
  47. package/lib/esm/cli-methods.js.map +1 -1
  48. package/lib/esm/cli.js +10 -6
  49. package/lib/esm/cli.js.map +1 -1
  50. package/lib/esm/commands/start.d.ts +33 -0
  51. package/lib/esm/commands/start.d.ts.map +1 -1
  52. package/lib/esm/commands/start.js +96 -54
  53. package/lib/esm/commands/start.js.map +1 -1
  54. package/lib/esm/interfaces.d.ts +7 -0
  55. package/lib/esm/interfaces.d.ts.map +1 -1
  56. package/lib/esm/utils/esmSpecifiers.d.ts +9 -0
  57. package/lib/esm/utils/esmSpecifiers.d.ts.map +1 -0
  58. package/lib/esm/utils/esmSpecifiers.js +104 -0
  59. package/lib/esm/utils/esmSpecifiers.js.map +1 -0
  60. package/lib/esm/utils/shippedDotfiles.d.ts.map +1 -1
  61. package/lib/esm/utils/shippedDotfiles.js +3 -1
  62. package/lib/esm/utils/shippedDotfiles.js.map +1 -1
  63. package/lib/esm/vite-config.d.ts +42 -0
  64. package/lib/esm/vite-config.d.ts.map +1 -1
  65. package/lib/esm/vite-config.js +185 -38
  66. package/lib/esm/vite-config.js.map +1 -1
  67. package/package.json +1 -1
  68. package/defaults/app-react-native/apps/mobile/src/shell/linkedDefaults.tsx +0 -10
package/CHANGELOG.md CHANGED
@@ -1,5 +1,49 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.14.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#81](https://github.com/linked-cm/cli/pull/81) [`d1c0729`](https://github.com/linked-cm/cli/commit/d1c07296a9470ed3c995ce83a9ade22c0632395c) Thanks [@flyon](https://github.com/flyon)! - `create-app --template react-native` now scaffolds the Linked backend next to the app.
8
+
9
+ - `services/api`: an API-only Linked backend (`linked start --api-only`) on `@_linked/server`, with Fuseki through `linked.backend.datasets.json`, a local file store (or `S3FileStore` when the S3 variables are set), a Fuseki reachability check and `node --test` unit tests.
10
+ - A root `docker-compose.yml` for Fuseki (`secoresearch/fuseki:5.5.0`, host port `FUSEKI_PORT`), root scripts `fuseki:up`, `fuseki:down`, `api`, `check:react`, `lint`, `typecheck`, `test` and `test:integration`, a root ESLint flat config (with a rule that keeps `apps/mobile` on `@_linked/server` subpath imports) and a "Before every PR" checklist in the README (the template ships no CI workflow).
11
+ - Pins `@_linked/server` 2.3.0, `@_linked/server-utils` 1.2.0 and `@_linked/cli` 1.13.0.
12
+ - `apps/mobile/babel.config.js` registers a Babel plugin that strips JSON import attributes (`import('x.json', { with: { type: 'json' } })`), so Metro bundles Linked ontology packages.
13
+ - `resolveApiUrl` ignores a non-string `extra.apiUrl`: the dev-client manifest delivers `null` as `{}`, which crashed the app with "LincdServerProxy requires a root URL".
14
+ - `apps/mobile` imports `@_linked/react/native` (1.5.0) instead of its own render defaults, resolves the API URL in `app.config.ts` and `src/shell/env.ts`, and sends queries to the API through `BackendAPIStore`. The root `overrides` entry is gone.
15
+ - An example add/edit/delete screen (`PersonOverview`, `PersonPreview`), ported from the web app-template to React Native with `@_linked/schema`'s `Person`, with a Jest integration test against the running API and Fuseki.
16
+ - The shapes package sets `"linked": {"extensionlessImports": true}`, so `linked build` emits Node-loadable `lib/esm`.
17
+ - With `linked.extensionlessImports`, `linked build` fails when `lib/esm` was not emitted (no `tsconfig-esm.json`), finishes "with warnings" listing every relative specifier it could not resolve, and handles `'.'`, `'..'` and `'./dir/'`. `linked build` now exits non-zero when the build fails.
18
+ - README: documents `linked start --api-only` / `server.apiOnly`, the `extensionlessImports` flag, and the react-native template as it is now.
19
+ - Integration tests only reset a Fuseki that is this repo's Compose `fuseki` on localhost (`docker compose port fuseki 3030`) with a `-test` dataset. They read `services/api/.env` (the shell wins), pass every `FUSEKI_*` variable to the API explicitly, blank the S3 variables (removing `AWS_REGION`, which `@_linked/s3` rejects when empty), and kill the API's process group on timeout, early exit, SIGINT, SIGTERM and exit. `INTEGRATION_API_BIN` and `INTEGRATION_API_TIMEOUT_MS` exist for testing that cleanup.
20
+ - Load order is enforced by imports: `storage.ts` imports `env.ts`, and the example linked components import `storage.ts`. The default API port is shared from `apps/mobile/src/shell/apiPort.json`.
21
+ - `wait-for-fuseki.mjs` honours `WAIT_FOR_FUSEKI_TIMEOUT_MS`. New tests: `storage.test.ts` (load order) and `waitForFuseki.test.mjs`. Local uploads go to `services/api/data/uploads/`, which is gitignored.
22
+ - `linked call` (a direct `callBackendMethod`) now prints the error and exits 1 when the method is unmatched or fails, instead of an unhandled rejection.
23
+ - The Fuseki dataset names (`<prefix>-dev`, `<prefix>-test`) are stamped from the app prefix.
24
+ - Shipped dotfiles are restored at any depth, and `env.example.template` becomes `.env.example`.
25
+
26
+ ## 1.13.0
27
+
28
+ ### Minor Changes
29
+
30
+ - [#79](https://github.com/linked-cm/cli/pull/79) [`fd27a8d`](https://github.com/linked-cm/cli/commit/fd27a8d827b591c2d6db6bd2a7db1aa1de94b71d) Thanks [@flyon](https://github.com/flyon)! - Add `linked start --api-only` for a Linked backend without a web frontend.
31
+
32
+ - No `vite.config.*` is required: without one, Vite runs with the `createViteConfig()` defaults inline, so TypeScript, decorators and extensionless imports in source-only shape packages still work.
33
+ - No `src/App.tsx` or `src/routes.tsx` is loaded. The CLI sets `server.apiOnly` on the LinkedServer config instead of the page rendering hooks, so `/call/...` and `/api/...` routes are served and page requests get a 404 (needs a `@_linked/server` release that honours `server.apiOnly`).
34
+ - The mode is explicit and never inferred from missing files. `server.apiOnly: true` in `linked.config.js` turns it on too.
35
+ - `LinkedServerConfig` gains the `apiOnly` field.
36
+
37
+ Add `"linked": {"extensionlessImports": true}` to a package's `package.json` for packages whose source uses extensionless relative imports (for example, shapes shared with React Native, where Metro does not map `.js` specifiers to `.ts` source).
38
+
39
+ - `linked build` skips the "Checking imports" step for that package and logs that it did.
40
+ - After compiling ESM, it appends `.js` (or `/index.js` for a directory) to extensionless `./` and `../` specifiers of static imports, exports and `import()` calls in `lib/esm/**/*.js`, so the output loads under Node. Specifiers it cannot resolve are left unchanged with a warning. `.d.ts` files are not rewritten.
41
+ - Packages without the field build exactly as before.
42
+
43
+ Fix linked-package discovery in `linked start` for apps inside an npm/yarn workspaces monorepo: dependencies are now resolved the way Node resolves them, walking up parent `node_modules` directories to the workspace root. A linked package hoisted to the root `node_modules` is found, so the app no longer falls into standalone mode (which dropped the `development` export condition).
44
+
45
+ In workspace mode, `linked start` now bundles only the discovered source workspaces through Vite SSR (`ssr.noExternal`). Published framework packages installed in `node_modules` (such as `@_linked/core`) stay external and load through Node. Before, every `@_linked/*` package was force-bundled, so Vite ran its own `@_linked/core` while a store loaded by core's `loadStores` through a native `import()` (for example `@_linked/fuseki/shapes/FusekiStore`) pulled in a second, Node-loaded core. That split the shape registry ("Cannot resolve an rdf:type for shape"). An installed package that depends (directly or transitively, through `dependencies` or `peerDependencies`) on a discovered workspace is bundled too. For example, when `@_linked/core` is itself a source workspace, a published `@_linked/fuseki` goes through Vite and uses the same core. Only the dependency closure of the app and its workspaces is scanned.
46
+
3
47
  ## 1.12.0
4
48
 
5
49
  ### Minor Changes
package/README.md CHANGED
@@ -62,6 +62,7 @@ linked setup-publish --scope community # use NPM_AUTH_TOKEN_CM instead of N
62
62
 
63
63
  ```bash
64
64
  linked start # run the dev server (app)
65
+ linked start --api-only # run only the backend API (no page rendering, no vite.config needed)
65
66
  linked dev # file-watch rebuild (package)
66
67
  linked yarn <args> # safe-yarn: run yarn at root while preserving nested yarn.lock files
67
68
  ```
@@ -88,6 +89,45 @@ The CLI recognizes two flags in `package.json`:
88
89
 
89
90
  The legacy `lincd: true` / `lincdApp: true` flags are no longer read. Migrate to `linkedPackage` / `linkedApp`.
90
91
 
92
+ ### `"linked": {"extensionlessImports": true}`
93
+
94
+ ```json
95
+ {
96
+ "linkedPackage": true,
97
+ "type": "module",
98
+ "linked": {"extensionlessImports": true}
99
+ }
100
+ ```
101
+
102
+ By default `linked build` rejects relative imports without an extension (`./shapes/Example`), because Node's ESM
103
+ loader cannot resolve them. Set this flag when the package source must use extensionless imports, for example a
104
+ shapes package consumed as TypeScript source by Metro (which does not map `.js` specifiers to `.ts` files).
105
+ With the flag, `linked build`:
106
+
107
+ - skips the import check;
108
+ - after compiling ESM, rewrites every relative specifier in `lib/esm` (static imports and exports, `export * as`,
109
+ side-effect imports and `import()` with a literal) to `./x.js` or `./dir/index.js`, so Node can load the output;
110
+ - fails if `lib/esm` was not emitted;
111
+ - finishes "with warnings" and lists each specifier it could not resolve (left unchanged).
112
+
113
+ Requirements: `"linkedPackage": true`, `"type": "module"` and a `tsconfig-esm.json` in the package.
114
+
115
+ ## API-only backend
116
+
117
+ `linked start --api-only`, or `server.apiOnly: true` in `linked.config.js`, serves only the backend API
118
+ (`/call/...`, `/api/...` and provider routes). There is no server-side page rendering, page requests get a 404,
119
+ and no `vite.config.*` is required. Use it for a backend whose frontend lives elsewhere, such as a React Native
120
+ app:
121
+
122
+ ```js
123
+ // linked.config.js
124
+ export default {
125
+ server: {apiOnly: true},
126
+ };
127
+ ```
128
+
129
+ API-only mode is never inferred: a web app without a `vite.config.*` and without the flag still fails at startup.
130
+
91
131
  ## Development
92
132
 
93
133
  ```bash
@@ -110,68 +150,71 @@ Templates live in `defaults/`:
110
150
 
111
151
  `linked create-app <name>` copies `defaults/app-with-backend/` to the new app's folder, substitutes `${name}` / `${hyphen_name}` / `${app_prefix}` / `${app_domain}` placeholders in selected files, and copies `linked.backend.datasets.example.json` → `linked.backend.datasets.json` so first boot works zero-config.
112
152
 
113
- Storage configuration follows the two-layer pattern from [backlog 016](https://github.com/create-now/docs/blob/main/docs/backlog/016-ejection-export-flow.md) (canonical spec) + the symmetric backend/frontend split:
114
-
115
- | File | Side | Role | Git |
116
- |---|---|---|---|
117
- | `linked.backend.storage.ts` | backend | Shape→alias routing. Uses `parseDatasetsConfig` + `loadStores` from `@_linked/core`; calls `LinkedStorage.setDefaultDataset(...)` / `setDatasetForShapes(...)`. | committed |
118
- | `linked.backend.datasets.json` | backend | Alias → `{ store, config }`. `store` is an npm import path; `config` is the store class's constructor arg. `${VAR:-default}` placeholders resolved at boot. | **gitignored** |
119
- | `linked.backend.datasets.example.json` | backend | Template / seed for the gitignored file. Has placeholders matching env vars. | committed |
120
- | `src/linked.frontend.storage.ts` | frontend | Same shape→alias model; imports store classes explicitly (webpack-bundle-safe), constructs per alias. | committed |
121
- | `src/linked.frontend.datasets.json` | frontend | Frontend alias → `{ store, config }`. Public values only — never put secrets here, it ships in the browser bundle. | committed |
122
-
123
- Conventions:
124
- - Aliases on the two sides are **independent**. The framework re-routes by shape on each side; matching alias names between FE and BE is convention, not a framework requirement.
125
- - The backend dispatcher is registry-free (dynamic `await import(entry.store)`). The frontend hardcodes each `new StoreClass(config)` because webpack can't bundle dynamic imports of arbitrary npm specifiers.
126
- - Each store class accepts a single config-object constructor argument — `new FusekiStore({ endpoint, credentials? })`, `new BackendAPIStore({ name?, id? })`.
127
-
128
- ### `linked create-app --template react-native`
153
+ Storage configuration follows the two-layer pattern from [backlog 016](https://github.com/c### `linked create-app --template react-native`
129
154
 
130
155
  `linked create-app <name> --template react-native` (with the usual `--app-name`, `--app-prefix`, `--app-domain`,
131
156
  `--skip-install`) copies `defaults/app-react-native/` instead of cloning the web template. The result is an npm
132
- workspaces monorepo for Expo SDK 57 / React Native 0.86 / React 19.2:
157
+ workspaces monorepo for Expo SDK 57 / React Native 0.86 / React 19.2 with an API-only Linked backend on Fuseki:
133
158
 
134
159
  ```
135
160
  <name>/
136
- package.json workspaces (enumerated) + overrides pinning @_linked/react's react to 19.2.3
137
- .gitignore node_modules/, packages/*, !packages/<prefix>-shapes/
138
- apps/mobile/ Expo app: app.json, App.tsx, index.ts, metro.config.js, jest config in package.json
139
- .gitignore includes /ios and /android
140
- src/shell/linkedDefaults.tsx React Native loader/error defaults for @_linked/react
141
- __tests__/ shapes.test.ts, linkedDefaults.test.tsx
142
- packages/<prefix>-shapes/ Linked shapes package, TypeScript source, extensionless imports, no build step
143
- services/api/ backend stub
161
+ package.json enumerated workspaces; scripts fuseki:up/down, api, check:react, lint, typecheck,
162
+ test, test:integration
163
+ docker-compose.yml local Fuseki (secoresearch/fuseki, host port FUSEKI_PORT, default 3030)
164
+ eslint.config.js flat config; keeps apps/mobile on @_linked/server subpath imports
165
+ apps/mobile/ Expo app (ios/ and android/ are generated, not committed)
166
+ App.tsx imports ./src/shell/env, @_linked/react/native, ./src/shell/storage
167
+ babel.config.js registers babel/stripJsonImportAttributes.js
168
+ src/shell/ env.ts (API URL), storage.ts (BackendAPIStore)
169
+ src/components/ PersonOverview / PersonPreview: example add/edit/delete screen
170
+ __tests__/ unit tests; __tests__/integration/ runs against the API and Fuseki
171
+ packages/<prefix>-shapes/ Linked shapes package: TypeScript source, extensionless imports,
172
+ "linked": {"extensionlessImports": true} so `linked build` emits Node-loadable lib/esm
173
+ services/api/ API-only backend (`linked start --api-only`) on @_linked/server, Fuseki through
174
+ linked.backend.datasets.json, node --test unit tests
144
175
  ```
145
176
 
146
177
  What the scaffold does:
147
178
 
148
179
  - Refuses, before writing anything, a prefix that does not match `^[a-z][a-z0-9-]*$`, and a target folder that
149
180
  exists and is not empty.
150
- - Renames `gitignore.template` files to `.gitignore`. npm strips real `.gitignore` files from the CLI tarball.
181
+ - Restores shipped dotfiles at any depth (`gitignore.template` becomes `.gitignore`, `env.example.template`
182
+ becomes `.env.example`). npm strips real dotfiles like `.gitignore` from the CLI tarball.
151
183
  - Renames `packages/app-shapes` to `packages/<prefix>-shapes`, and replaces the literal `app-shapes` token in
152
184
  every text file.
153
185
  - Sets the root `name` to `<hyphen-name>-monorepo`, and `app.json` `expo.name`, `expo.slug` and
154
- `expo.ios.bundleIdentifier`. The bundle ID is the reversed domain plus the prefix, e.g.
155
- `com.formaestudios.formae`.
186
+ `expo.ios.bundleIdentifier`. The bundle ID is the reversed domain plus the prefix, e.g. `com.example.demo`.
187
+ - Stamps the Fuseki dataset names `<prefix>-dev` and `<prefix>-test` from the prefix.
156
188
  - Never runs the `${…}` placeholder substitution, so template literals in the sources are left alone.
157
189
  - Installs with `npm install` (never Yarn) unless `--skip-install` is given. If the install fails, the files
158
190
  stay and the command exits non-zero; run `npm install` in the folder to retry.
159
191
 
160
- `ios/` (and `android/`) are **generated, not committed**: `npx expo run:ios` (or `npx expo prebuild`) creates
161
- them from `app.json`, and `apps/mobile/.gitignore` ignores them.
192
+ The generated app:
162
193
 
163
- Quick commands in the generated app:
194
+ - **Render defaults** come from `@_linked/react/native`, which replaces `@_linked/react`'s `<svg>` loader and
195
+ error elements (they crash on React Native).
196
+ - **Person example.** `PersonOverview` and `PersonPreview` list, add, edit and delete `@_linked/schema`'s
197
+ `Person` through `BackendAPIStore` → API → Fuseki. A Jest integration test covers it.
198
+ - **Babel plugin.** Metro rejects `import('x.json', { with: { type: 'json' } })`, which Linked ontology packages
199
+ use; `apps/mobile/babel/stripJsonImportAttributes.js` drops that options argument.
200
+ - **Before every PR.** The template ships no CI workflow. Its README has a checklist to run locally and paste
201
+ into the PR:
164
202
 
165
203
  ```bash
166
- npm ls react # exactly one react@19.2.3
167
- npm test -w apps/mobile # Jest: shape registration + render defaults
168
- cd apps/mobile && npx expo export --platform ios --output-dir /tmp/export # Metro bundle check
169
- cd apps/mobile && npx expo run:ios # build the dev client and run on the iOS Simulator
204
+ npm run check:react
205
+ npm run lint
206
+ npm run typecheck
207
+ npm test
208
+ npm run fuseki:up && npm run test:integration
209
+ cd apps/mobile && npx expo export --platform ios --output-dir /tmp/export
170
210
  ```
171
211
 
172
- Tests for this template in this repo: `yarn test:unit` covers the scaffold offline. It scaffolds the fixture in
173
- `tests/fixtures/app-react-native-min` and the real template. `npm run test:template` builds nothing itself. It
174
- runs the built CLI with install, then the three checks above. It needs network and takes several minutes.
212
+ To run it: `npm run fuseki:up`, `npm run api`, then `cd apps/mobile && npx expo run:ios`.
213
+
214
+ Tests for this template in this repo: `yarn test:unit` covers the scaffold offline, using the fixture in
215
+ `tests/fixtures/app-react-native-min` and the real template. `npm run test:template` runs the built CLI with
216
+ install, then checks the generated files, `check:react`, lint, typecheck, `npm test`, the shapes build and a
217
+ Metro iOS bundle. It builds nothing itself, needs network and takes several minutes.
175
218
 
176
219
  ## Repository
177
220
 
@@ -1,42 +1,67 @@
1
1
  # Linked React Native monorepo
2
2
 
3
3
  An npm-workspaces monorepo for a [Linked](https://github.com/linked-cm) app on Expo SDK 57 / React Native 0.86 /
4
- React 19.2.
4
+ React 19.2, with an API-only Linked backend on Fuseki.
5
5
 
6
6
  ```
7
7
  apps/mobile/ Expo app (continuous native generation: ios/ is generated, not committed)
8
- src/shell/ app shell, including the React Native render defaults for @_linked/react
9
- __tests__/ shape registration and render-default tests
10
- packages/app-shapes/ Linked shapes package, consumed as TypeScript source (no build step)
11
- services/api/ backend stub
8
+ src/shell/ env (API URL) and storage (BackendAPIStore) wiring
9
+ src/components/ PersonOverview / PersonPreview: example add/edit/delete screen, safe to replace
10
+ __tests__/ unit tests; __tests__/integration/ runs against a real API and Fuseki
11
+ packages/app-shapes/ Linked shapes package, consumed as TypeScript source; `linked build` for Node
12
+ services/api/ Linked backend (`linked start --api-only`), see services/api/README.md
13
+ docker-compose.yml local Fuseki
12
14
  ```
13
15
 
14
16
  ## Run
15
17
 
18
+ Requires Node `^22.13 || >=24` and Docker. iOS builds need macOS 26.2+ and Xcode 26.4+ with the iOS platform
19
+ component installed.
20
+
16
21
  ```bash
17
22
  npm install
18
- npm test -w apps/mobile # Jest
19
- cd apps/mobile && npx expo run:ios # generates ios/, builds a development build with expo-dev-client, starts Metro
23
+ cp services/api/.env.example services/api/.env # once
24
+ npm run fuseki:up # Fuseki on :3030 (Docker Compose)
25
+ npm run api # API-only backend on :4000
26
+ cd apps/mobile && npx expo run:ios # generates ios/, builds a dev client, starts Metro
20
27
  ```
21
28
 
22
29
  `expo-dev-client` is a dependency of `apps/mobile`, so `expo run:ios` produces a custom dev client (not Expo Go),
23
- which is what native modules need.
30
+ which is what native modules need. The app finds the API on the dev machine's host (see "API URL" below).
24
31
 
25
- Requires Node `^22.13 || >=24`. iOS builds need macOS 26.2+ and Xcode 26.4+ with the iOS platform component
26
- installed.
32
+ If port 3030 is taken, see "Port 3030 taken" in `services/api/README.md`: set `FUSEKI_PORT` in a root `.env`
33
+ and `FUSEKI_BASE_URL` in `services/api/.env`.
27
34
 
28
- ## Quick checks
35
+ ## Checks
29
36
 
30
37
  | Command | What it proves |
31
38
  |---|---|
32
- | `npm ls react` (root) | exactly one React 19.2.3 in the tree |
33
- | `npm test -w apps/mobile` | shapes register at runtime; Linked components render React Native elements while loading and on error |
39
+ | `npm run check:react` | `apps/mobile`, `@_linked/react` and `react-native` resolve one React 19.2.3 |
40
+ | `npm run typecheck` | `tsc --noEmit` in every workspace |
41
+ | `npm run lint` | ESLint over every workspace, including the `@_linked/server` subpath rule for `apps/mobile` |
42
+ | `npm test` | app Jest (shapes, render defaults, API URL) and the API's `node --test` unit tests |
43
+ | `npm run fuseki:up && npm run test:integration` | the example screen lists, adds and deletes through `BackendAPIStore` → API → Fuseki (starts its own API on :4100 with the in-memory `app-test` dataset) |
44
+ | `npm run build -w packages/app-shapes` | `linked build` emits `lib/esm` with `.js` specifiers, loadable by Node |
34
45
  | `cd apps/mobile && npx expo export --platform ios --output-dir /tmp/export` | Metro resolves `@_linked/*` (including subpaths) and the workspace packages |
35
46
 
47
+ ## Before every PR
48
+
49
+ There is no CI workflow. Run every check locally before opening or updating a PR, and paste the results into the
50
+ PR description:
51
+
52
+ ```bash
53
+ npm run check:react
54
+ npm run lint
55
+ npm run typecheck
56
+ npm test
57
+ npm run fuseki:up && npm run test:integration
58
+ cd apps/mobile && npx expo export --platform ios --output-dir /tmp/export
59
+ ```
60
+
36
61
  ## React Native specifics
37
62
 
38
- - **One React.** `@_linked/react` still declares a React 18 peer, so the root `package.json` `overrides` pins its
39
- `react` to 19.2.3. Remove the override once the peer range is widened upstream. Do not use
63
+ - **One React.** `npm ls react` lists extra React copies nested under `@_linked/server` and
64
+ `@_linked/server-utils`; the app never loads them, which `npm run check:react` verifies. Do not use
40
65
  `--legacy-peer-deps`: it would hide a duplicate React.
41
66
  - **Workspaces are enumerated**, never globbed, so foreign repositories checked out under `packages/` are not
42
67
  adopted. `.gitignore` uses `packages/*` plus a negation per workspace package (`packages/` cannot be negated).
@@ -46,11 +71,26 @@ installed.
46
71
  subpaths.
47
72
  - **Jest** maps `@_linked/*` to its ESM build with a scoped `moduleNameMapper`. A global
48
73
  `customExportConditions` would break `@babel/runtime`. Add every workspace package to
49
- `transformIgnorePatterns`.
50
- - **Render defaults.** `@_linked/react`'s built-in loader and error elements are `<svg>`, which crash on React
51
- Native. `apps/mobile/src/shell/linkedDefaults.tsx` replaces them app-wide and must be the first import of
52
- `App.tsx`. `__tests__/linkedDefaults.test.tsx` fails if the defaults are not applied.
74
+ `transformIgnorePatterns`. The integration config restores Node's `fetch`, which jest-expo replaces with a
75
+ stub that never sends a request.
76
+ - **Render defaults.** `@_linked/react`'s root loader and error elements are `<svg>`, which crash on React
77
+ Native. Importing `@_linked/react/native` installs React Native defaults; `__tests__/nativeDefaults.test.tsx`
78
+ guards it.
79
+ - **API URL.** `app.config.ts` passes `EXPO_PUBLIC_API_URL` (if set) and the API port (`defaultApiPort` in
80
+ `src/shell/apiPort.json`, shared with `env.ts`) to `src/shell/env.ts`,
81
+ which falls back to the dev machine's host from Expo's `hostUri`, then `localhost`, and sets
82
+ `process.env.SITE_ROOT` and `DATA_ROOT` for `BackendAPIStore`. The app identity (name, slug, bundle id) stays in
83
+ `app.json`.
84
+ - **Load order is enforced by imports:** `App.tsx` imports `@_linked/react/native` first, and every module that
85
+ needs the API store imports `src/shell/storage`, which imports `src/shell/env`.
86
+ - **`@_linked/server` by subpath only** in `apps/mobile` (for example
87
+ `@_linked/server/shapes/quadstores/BackendAPIStore`): the root barrel pulls express, webpack and react-dom.
88
+ ESLint `no-restricted-imports` enforces it.
53
89
  - **Shapes package imports are extensionless** (`./shapes/Example`, not `./shapes/Example.js`): Metro does not
54
- map `.js` specifiers to `.ts` source. For the same reason the package is never built with `linked build`,
55
- whose import check rejects extensionless imports.
56
- - **Decorators** work with the stock `babel-preset-expo`; no extra Babel plugin is needed.
90
+ map `.js` specifiers to `.ts` source. `"linked": {"extensionlessImports": true}` in its `package.json` makes
91
+ `linked build` skip the import check and add `.js` to the emitted `lib/esm` specifiers. The app, Jest and the
92
+ API in development consume `src/` through the `react-native` and `development` export conditions.
93
+ - **Decorators** work with the stock `babel-preset-expo`. The one custom Babel plugin,
94
+ `apps/mobile/babel/stripJsonImportAttributes.js`, strips `with { type: 'json' }` from dynamic `import()`, which
95
+ Metro rejects and Linked ontology packages use; `__tests__/stripJsonImportAttributes.test.ts` covers it. After
96
+ editing `babel.config.js` or the plugin, restart with `npx expo start --clear`.
@@ -1,9 +1,11 @@
1
- // Must stay the first import: replaces @_linked/react's <svg> loader and error defaults app-wide.
2
- import './src/shell/linkedDefaults';
1
+ // @_linked/react/native first: it installs the React Native render defaults before any component renders. Modules
2
+ // that need the API store import ./src/shell/storage themselves, which imports ./src/shell/env.
3
+ import '@_linked/react/native';
4
+ import { Example } from 'app-shapes';
3
5
 
4
6
  import { StyleSheet, Text, View } from 'react-native';
5
7
  import { getShapeClass } from '@_linked/core/utils/ShapeClass';
6
- import { Example } from 'app-shapes';
8
+ import { PersonOverview } from './src/components/PersonOverview';
7
9
 
8
10
  export default function App() {
9
11
  const registered = getShapeClass(Example.shape.id) === Example;
@@ -12,6 +14,7 @@ export default function App() {
12
14
  {/* @linkedShape adds static packageName at runtime; it is not on the class type. */}
13
15
  <Text>{(Example as any).packageName}</Text>
14
16
  <Text>{registered ? 'shapes registered' : 'shapes not registered'}</Text>
17
+ <PersonOverview />
15
18
  </View>
16
19
  );
17
20
  }
@@ -0,0 +1,39 @@
1
+ import { apiUrl, resolveApiUrl } from '../src/shell/env';
2
+
3
+ // Explicit, so the module-level resolution does not depend on jest-expo's expo-constants mock.
4
+ jest.mock('expo-constants', () => ({
5
+ __esModule: true,
6
+ default: { expoConfig: { extra: { apiUrl: null, apiPort: 4000 }, hostUri: '192.168.1.5:8081' } },
7
+ }));
8
+
9
+ describe('resolveApiUrl', () => {
10
+ test('explicit url wins', () => {
11
+ expect(
12
+ resolveApiUrl({ extra: { apiUrl: 'http://api.test:9000', apiPort: 4000 }, hostUri: '192.168.1.5:8081' }),
13
+ ).toBe('http://api.test:9000');
14
+ });
15
+
16
+ test('derives from hostUri', () => {
17
+ expect(resolveApiUrl({ extra: { apiUrl: null, apiPort: 4000 }, hostUri: '192.168.1.5:8081' })).toBe(
18
+ 'http://192.168.1.5:4000',
19
+ );
20
+ });
21
+
22
+ test('ignores a non-string apiUrl (the dev-client manifest turns null into {})', () => {
23
+ expect(resolveApiUrl({ extra: { apiUrl: {}, apiPort: 4000 }, hostUri: '192.168.1.5:8081' })).toBe(
24
+ 'http://192.168.1.5:4000',
25
+ );
26
+ });
27
+
28
+ test('falls back to localhost', () => {
29
+ expect(resolveApiUrl({ extra: { apiUrl: null, apiPort: 4000 }, hostUri: undefined })).toBe(
30
+ 'http://localhost:4000',
31
+ );
32
+ });
33
+
34
+ test('sets process.env', () => {
35
+ expect(apiUrl).toBe('http://192.168.1.5:4000');
36
+ expect(process.env.SITE_ROOT).toBe(apiUrl);
37
+ expect(process.env.DATA_ROOT).toBe(`${apiUrl}/data`);
38
+ });
39
+ });
@@ -0,0 +1,49 @@
1
+ /// <reference types="node" />
2
+ // Shared by globalSetup, globalTeardown and the tests: where the test API and Fuseki live.
3
+ import { existsSync, readFileSync } from 'node:fs';
4
+ import { resolve } from 'node:path';
5
+
6
+ export const API_PORT = 4100;
7
+ export const API_URL = `http://localhost:${API_PORT}`;
8
+ // globalSetup deletes this dataset, so it must end in `-test` (checked there).
9
+ export const FUSEKI_DATASET = 'app-test';
10
+
11
+ export const repoRoot = resolve(__dirname, '../../../..');
12
+ export const apiDir = resolve(repoRoot, 'services/api');
13
+
14
+ /** Minimal KEY=VALUE parser for services/api/.env: no expansion, no multi-line values. */
15
+ export function parseEnvFile(text: string): Record<string, string> {
16
+ const vars: Record<string, string> = {};
17
+ for (const line of text.split(/\r?\n/)) {
18
+ const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/.exec(line);
19
+ if (!match) continue;
20
+ vars[match[1]] = match[2].replace(/^(['"])(.*)\1$/, '$2');
21
+ }
22
+ return vars;
23
+ }
24
+
25
+ /** services/api/.env as key/value pairs; empty when the file does not exist. */
26
+ export function readApiEnvFile(): Record<string, string> {
27
+ const envFile = resolve(apiDir, '.env');
28
+ return existsSync(envFile) ? parseEnvFile(readFileSync(envFile, 'utf8')) : {};
29
+ }
30
+
31
+ export type FusekiEnv = { baseUrl: string; user: string; password: string };
32
+
33
+ /**
34
+ * The Fuseki the tests talk to. services/api/.env is the source, and the shell wins: FUSEKI_BASE_URL, or
35
+ * FUSEKI_PORT (the Compose host port) as a shorthand for localhost.
36
+ */
37
+ export function resolveFusekiEnv(env: NodeJS.ProcessEnv = process.env): FusekiEnv {
38
+ const file = readApiEnvFile();
39
+ const baseUrl =
40
+ env.FUSEKI_BASE_URL ||
41
+ (env.FUSEKI_PORT ? `http://localhost:${env.FUSEKI_PORT}` : '') ||
42
+ file.FUSEKI_BASE_URL ||
43
+ 'http://localhost:3030';
44
+ return {
45
+ baseUrl: baseUrl.replace(/\/$/, ''),
46
+ user: env.FUSEKI_USER || file.FUSEKI_USER || 'admin',
47
+ password: env.FUSEKI_PASSWORD || file.FUSEKI_PASSWORD || 'admin',
48
+ };
49
+ }
@@ -0,0 +1,156 @@
1
+ /// <reference types="node" />
2
+ // Before the integration tests: check that the target is this repo's Compose Fuseki, reset the in-memory test
3
+ // dataset, start the API-only backend.
4
+ import { type ChildProcess, spawn, spawnSync } from 'node:child_process';
5
+ import { resolve } from 'node:path';
6
+
7
+ import { API_PORT, API_URL, FUSEKI_DATASET, apiDir, readApiEnvFile, repoRoot, resolveFusekiEnv } from './apiEnv';
8
+
9
+ // The variables that select S3FileStore (services/api/src/fileStoreEnv.ts); blanked even when unset anywhere.
10
+ const S3_ENV_KEYS = ['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_REGION', 'S3_BUCKET_ENDPOINT', 'S3_FILES_BUCKET_NAME'];
11
+
12
+ // Test hooks: INTEGRATION_API_BIN replaces `linked` (e.g. with a process that never answers) and
13
+ // INTEGRATION_API_TIMEOUT_MS shortens the wait, to exercise the failure cleanup.
14
+ const apiBin = process.env.INTEGRATION_API_BIN || resolve(repoRoot, 'node_modules/.bin/linked');
15
+ const startTimeoutMs = Number(process.env.INTEGRATION_API_TIMEOUT_MS) || 60_000;
16
+
17
+ async function answers(url: string): Promise<boolean> {
18
+ try {
19
+ await fetch(url, { signal: AbortSignal.timeout(1000) });
20
+ return true;
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Refuses unless the target is safe to wipe: the dataset name ends in `-test`, and the Fuseki URL is on localhost at
28
+ * the host port Docker Compose publishes for this repo's `fuseki` service (so never another project's Fuseki).
29
+ */
30
+ function assertSafeTarget(baseUrl: string) {
31
+ const target = `${baseUrl}/$/datasets/${FUSEKI_DATASET}`;
32
+ const refuse = (reason: string): never => {
33
+ throw new Error(`Refusing to reset ${target}: ${reason}`);
34
+ };
35
+ if (!FUSEKI_DATASET.endsWith('-test')) refuse('the dataset name does not end in "-test".');
36
+
37
+ const url = new URL(baseUrl);
38
+ if (!['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) refuse(`${url.hostname} is not localhost.`);
39
+ const urlPort = url.port || (url.protocol === 'https:' ? '443' : '80');
40
+
41
+ const compose = spawnSync('docker', ['compose', 'port', 'fuseki', '3030'], { cwd: repoRoot, encoding: 'utf8' });
42
+ const composePort = compose.status === 0 ? /:(\d+)\s*$/.exec(compose.stdout.trim())?.[1] : undefined;
43
+ if (!composePort) {
44
+ refuse(
45
+ `this repo's Compose Fuseki is not running (\`docker compose port fuseki 3030\`: ` +
46
+ `${(compose.stderr || compose.error?.message || compose.stdout || '').trim()}). Run \`npm run fuseki:up\`.`,
47
+ );
48
+ }
49
+ if (composePort !== urlPort) {
50
+ refuse(`port ${urlPort} is not the Compose Fuseki port ${composePort}. Set FUSEKI_PORT=${composePort}.`);
51
+ }
52
+ }
53
+
54
+ /** Kills the API's whole process group; safe to call repeatedly and after it exited. */
55
+ function killGroup(api: ChildProcess, signal: NodeJS.Signals = 'SIGTERM') {
56
+ if (!api.pid) return;
57
+ try {
58
+ process.kill(-api.pid, signal);
59
+ } catch {
60
+ // Already gone.
61
+ }
62
+ }
63
+
64
+ export default async function globalSetup() {
65
+ const fuseki = resolveFusekiEnv();
66
+
67
+ // 1. Refuse a foreign target before anything is deleted.
68
+ assertSafeTarget(fuseki.baseUrl);
69
+
70
+ // 2. Fail fast when Fuseki is down. FUSEKI_BASE_URL is passed so the script does not read services/api/.env.
71
+ const wait = spawnSync(process.execPath, [resolve(apiDir, 'scripts/wait-for-fuseki.mjs')], {
72
+ env: { ...process.env, FUSEKI_BASE_URL: fuseki.baseUrl },
73
+ encoding: 'utf8',
74
+ });
75
+ if (wait.status !== 0) throw new Error(wait.stderr.trim() || `wait-for-fuseki exited ${wait.status}`);
76
+
77
+ if (await answers(API_URL)) {
78
+ throw new Error(`Port ${API_PORT} is already in use; stop the process on it before running integration tests.`);
79
+ }
80
+
81
+ // 3. Reset: drop the dataset; the API's ensureDatasetExists() recreates it empty on boot.
82
+ const auth = `Basic ${Buffer.from(`${fuseki.user}:${fuseki.password}`).toString('base64')}`;
83
+ const del = await fetch(`${fuseki.baseUrl}/$/datasets/${FUSEKI_DATASET}`, {
84
+ method: 'DELETE',
85
+ headers: { Authorization: auth },
86
+ });
87
+ if (!del.ok && del.status !== 404) {
88
+ throw new Error(`Deleting Fuseki dataset ${FUSEKI_DATASET} failed: ${del.status} ${await del.text()}`);
89
+ }
90
+
91
+ // 4. Start the API. Every Fuseki variable is explicit, and the S3 ones are blanked, so nothing is taken from
92
+ // services/api/.env (`linked start` does not override variables that are already set, even when empty).
93
+ const env: NodeJS.ProcessEnv = { ...process.env };
94
+ const s3Keys = [...Object.keys(process.env), ...Object.keys(readApiEnvFile()), ...S3_ENV_KEYS];
95
+ for (const key of s3Keys) if (/^(AWS|S3)_/.test(key)) env[key] = '';
96
+ // Except AWS_REGION: @_linked/s3 builds a client at import with `AWS_REGION ?? 'us-east-1'`, and an empty region
97
+ // throws "Region is missing". Removed instead; the blank S3_FILES_BUCKET_NAME already rules out S3FileStore.
98
+ delete env.AWS_REGION;
99
+ Object.assign(env, {
100
+ NODE_ENV: 'development',
101
+ PORT: String(API_PORT),
102
+ SITE_ROOT: API_URL,
103
+ DATA_ROOT: `${API_URL}/data`,
104
+ FUSEKI_BASE_URL: fuseki.baseUrl,
105
+ FUSEKI_USER: fuseki.user,
106
+ FUSEKI_PASSWORD: fuseki.password,
107
+ FUSEKI_DATASET,
108
+ FUSEKI_DB_TYPE: 'mem',
109
+ });
110
+
111
+ let output = '';
112
+ const api = spawn(apiBin, ['start', '--api-only'], {
113
+ cwd: apiDir,
114
+ env,
115
+ // `linked start` spawns its own children (Vite, the server). `detached` gives the CLI its own process group, so
116
+ // one `kill(-pid)` stops all of them; killing only the CLI's PID would leave the server listening on the port.
117
+ detached: true,
118
+ stdio: ['ignore', 'pipe', 'pipe'],
119
+ });
120
+ api.stdout?.on('data', (d) => (output += d));
121
+ api.stderr?.on('data', (d) => (output += d));
122
+ let exited: number | null = null;
123
+ api.on('exit', (code) => (exited = code ?? -1));
124
+
125
+ // A detached group outlives Jest unless it is killed: on exit, Ctrl+C and SIGTERM too, not only in teardown.
126
+ const onExit = () => killGroup(api, 'SIGKILL');
127
+ const onSignal = (signal: NodeJS.Signals) => {
128
+ killGroup(api);
129
+ // Re-raise when no one else handles it, so the default (terminate) still happens.
130
+ if (process.listenerCount(signal) === 0) process.kill(process.pid, signal);
131
+ };
132
+ process.once('exit', onExit);
133
+ process.once('SIGINT', onSignal);
134
+ process.once('SIGTERM', onSignal);
135
+ const stop = () => {
136
+ process.off('exit', onExit);
137
+ process.off('SIGINT', onSignal);
138
+ process.off('SIGTERM', onSignal);
139
+ killGroup(api);
140
+ };
141
+ (globalThis as any).__LINKED_API__ = { api, stop, killGroup: (signal: NodeJS.Signals) => killGroup(api, signal) };
142
+
143
+ const fail = (message: string): never => {
144
+ stop();
145
+ throw new Error(message);
146
+ };
147
+
148
+ // 5. Wait until it answers HTTP (page routes 404, which is fine).
149
+ const deadline = Date.now() + startTimeoutMs;
150
+ while (Date.now() < deadline) {
151
+ if (exited !== null) fail(`The API exited with ${exited} before it answered:\n${output}`);
152
+ if (await answers(API_URL)) return;
153
+ await new Promise((r) => setTimeout(r, 500));
154
+ }
155
+ fail(`The API did not answer on ${API_URL} within ${startTimeoutMs / 1000} s:\n${output}`);
156
+ }
@@ -0,0 +1,20 @@
1
+ /// <reference types="node" />
2
+ // Stops the API started by globalSetup (its whole process group) and removes its exit and signal handlers.
3
+ import type { ChildProcess } from 'node:child_process';
4
+
5
+ type StartedApi = { api: ChildProcess; stop: () => void; killGroup: (signal: NodeJS.Signals) => void };
6
+
7
+ export default async function globalTeardown() {
8
+ const started: StartedApi | undefined = (globalThis as any).__LINKED_API__;
9
+ if (!started) return;
10
+ const { api, stop, killGroup } = started;
11
+ if (!api.pid || api.exitCode !== null || api.signalCode !== null) {
12
+ stop();
13
+ return;
14
+ }
15
+ const exited = new Promise((r) => api.once('exit', r));
16
+ stop();
17
+ const timer = setTimeout(() => killGroup('SIGKILL'), 5000);
18
+ await exited;
19
+ clearTimeout(timer);
20
+ }