@_linked/cli 1.13.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 (62) hide show
  1. package/CHANGELOG.md +23 -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 +6 -1
  45. package/lib/esm/cli-methods.d.ts.map +1 -1
  46. package/lib/esm/cli-methods.js +46 -12
  47. package/lib/esm/cli-methods.js.map +1 -1
  48. package/lib/esm/cli.js +8 -5
  49. package/lib/esm/cli.js.map +1 -1
  50. package/lib/esm/commands/start.js +7 -7
  51. package/lib/esm/commands/start.js.map +1 -1
  52. package/lib/esm/utils/esmSpecifiers.d.ts +8 -2
  53. package/lib/esm/utils/esmSpecifiers.d.ts.map +1 -1
  54. package/lib/esm/utils/esmSpecifiers.js +18 -9
  55. package/lib/esm/utils/esmSpecifiers.js.map +1 -1
  56. package/lib/esm/utils/shippedDotfiles.d.ts.map +1 -1
  57. package/lib/esm/utils/shippedDotfiles.js +3 -1
  58. package/lib/esm/utils/shippedDotfiles.js.map +1 -1
  59. package/lib/esm/vite-config.js +6 -6
  60. package/lib/esm/vite-config.js.map +1 -1
  61. package/package.json +1 -1
  62. package/defaults/app-react-native/apps/mobile/src/shell/linkedDefaults.tsx +0 -10
@@ -0,0 +1,51 @@
1
+ // The example PersonOverview screen with the real BackendAPIStore and real fetch, against the API started by
2
+ // globalSetup on the reset app-test dataset: list 0, add (1), edit (the name changes and persists), delete (0).
3
+ import '@_linked/react/native';
4
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react-native';
5
+
6
+ import { PersonOverview } from '../../src/components/PersonOverview';
7
+ import { API_URL } from './apiEnv';
8
+
9
+ // env.ts resolves the API URL from Expo's config; point it at the test API. (jest.mock is hoisted above imports.)
10
+ jest.mock('expo-constants', () => ({
11
+ __esModule: true,
12
+ default: { expoConfig: { extra: { apiUrl: 'http://localhost:4100', apiPort: 4000 } } },
13
+ }));
14
+
15
+ const WAIT = { timeout: 10000 };
16
+
17
+ describe('PersonOverview against the API', () => {
18
+ it('uses the test API and a real fetch', () => {
19
+ expect(process.env.SITE_ROOT).toBe(API_URL);
20
+ // Node's fetch, not jest-expo's stubbed expo/fetch (see nodeFetchEnvironment.js).
21
+ expect(globalThis.fetch).toBe((globalThis as any).__NODE_FETCH_GLOBALS__.fetch);
22
+ });
23
+
24
+ it('lists 0, adds one (1), edits it, deletes it (0)', async () => {
25
+ await render(<PersonOverview />);
26
+ await waitFor(() => expect(screen.getByTestId('person-empty')).toBeOnTheScreen(), WAIT);
27
+
28
+ // Add
29
+ await fireEvent.changeText(screen.getByTestId('person-add-given'), 'Ada');
30
+ await fireEvent.changeText(screen.getByTestId('person-add-family'), 'Lovelace');
31
+ await fireEvent.press(screen.getByTestId('person-add'));
32
+ await waitFor(() => expect(screen.getAllByTestId('person-row')).toHaveLength(1), WAIT);
33
+ await waitFor(() => expect(screen.getByTestId('person-name')).toHaveTextContent('Ada Lovelace'), WAIT);
34
+
35
+ // Edit
36
+ await fireEvent.press(screen.getByTestId('person-edit-button'));
37
+ await fireEvent.changeText(screen.getByTestId('person-edit-given'), 'Grace');
38
+ await fireEvent.press(screen.getByTestId('person-save'));
39
+ await waitFor(() => expect(screen.getByTestId('person-name')).toHaveTextContent('Grace Lovelace'), WAIT);
40
+
41
+ // The edit is stored, not only patched locally: a fresh mount reads it back from the API.
42
+ await screen.unmount();
43
+ await render(<PersonOverview />);
44
+ await waitFor(() => expect(screen.getByTestId('person-name')).toHaveTextContent('Grace Lovelace'), WAIT);
45
+
46
+ // Delete
47
+ await fireEvent.press(screen.getByTestId('person-delete'));
48
+ await waitFor(() => expect(screen.getByTestId('person-empty')).toBeOnTheScreen(), WAIT);
49
+ expect(screen.queryAllByTestId('person-row')).toHaveLength(0);
50
+ });
51
+ });
@@ -0,0 +1,8 @@
1
+ // Runs after the setup files: installs expo's winter runtime first (it swaps in a stubbed `expo/fetch`), then
2
+ // restores Node's fetch globals saved by nodeFetchEnvironment.js, so BackendAPIStore reaches the real API.
3
+ require('expo');
4
+
5
+ const saved: Record<string, unknown> = (globalThis as any).__NODE_FETCH_GLOBALS__;
6
+ for (const [name, value] of Object.entries(saved)) {
7
+ Object.defineProperty(globalThis, name, { value, configurable: true, writable: true, enumerable: true });
8
+ }
@@ -1,12 +1,10 @@
1
- // Asserts the cause of the React Native crash in @_linked/react's default loader and error
2
- // elements: they render DOM host elements (<svg>), which have no native view config.
3
- // Removing the import below must make both tests fail with Received: ["svg", ...].
4
- import '../src/shell/linkedDefaults';
5
-
1
+ // Asserts the cause of the React Native crash in @_linked/react's root loader and error elements: they render
2
+ // DOM host elements (<svg>), which have no native view config. @_linked/react/native replaces them on import;
3
+ // importing linkedComponent from the root barrel instead must make both tests fail with Received: ["svg", ...].
6
4
  import { act, render, screen } from '@testing-library/react-native';
7
5
  import { LinkedStorage } from '@_linked/core/utils/LinkedStorage';
8
6
  import { setQueryDispatch } from '@_linked/core/queries/queryDispatch';
9
- import { linkedComponent } from '@_linked/react';
7
+ import { linkedComponent } from '@_linked/react/native';
10
8
  import { Text } from 'react-native';
11
9
  import { Example } from 'app-shapes';
12
10
 
@@ -44,7 +42,7 @@ function lowercaseHostTypes(tree: Node | Node[] | null): string[] {
44
42
 
45
43
  const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
46
44
 
47
- describe('Linked render defaults on React Native', () => {
45
+ describe('@_linked/react/native render defaults', () => {
48
46
  test('a loading component renders only React Native elements', async () => {
49
47
  mode = 'loading';
50
48
  await render(<ExampleLabel of={{ id: 'urn:example:1' }} />);
@@ -0,0 +1,24 @@
1
+ // Importing storage alone must be enough: it pulls ./env, so SITE_ROOT is set before BackendAPIStore is constructed.
2
+ jest.mock('expo-constants', () => ({
3
+ __esModule: true,
4
+ default: { expoConfig: { extra: { apiUrl: 'http://api.test:9000', apiPort: 4000 } } },
5
+ }));
6
+
7
+ const constructedWith: (string | undefined)[] = [];
8
+ jest.mock('@_linked/server/shapes/quadstores/BackendAPIStore', () => ({
9
+ BackendAPIStore: class {
10
+ constructor() {
11
+ constructedWith.push(process.env.SITE_ROOT);
12
+ }
13
+ },
14
+ }));
15
+
16
+ test('importing src/shell/storage sets SITE_ROOT before BackendAPIStore is constructed', () => {
17
+ delete process.env.SITE_ROOT;
18
+ delete process.env.DATA_ROOT;
19
+
20
+ require('../src/shell/storage');
21
+
22
+ expect(constructedWith).toEqual(['http://api.test:9000']);
23
+ expect(process.env.DATA_ROOT).toBe('http://api.test:9000/data');
24
+ });
@@ -0,0 +1,45 @@
1
+ import { transformSync } from '@babel/core';
2
+
3
+ import stripJsonImportAttributes from '../babel/stripJsonImportAttributes';
4
+
5
+ const transform = (code: string, withPlugin = true, parserOpts: Record<string, unknown> = {}) =>
6
+ transformSync(code, {
7
+ babelrc: false,
8
+ configFile: false,
9
+ parserOpts,
10
+ plugins: withPlugin ? [stripJsonImportAttributes] : [],
11
+ })!.code;
12
+
13
+ // The plugin must produce exactly what Babel prints without it.
14
+ const expectUnchanged = (code: string) => expect(transform(code)).toBe(transform(code, false));
15
+
16
+ describe('stripJsonImportAttributes', () => {
17
+ test('drops the options of a dynamic JSON import', () => {
18
+ expect(transform("import('./a.json', { with: { type: 'json' } });")).toBe("import('./a.json');");
19
+ });
20
+
21
+ test('handles a template literal specifier', () => {
22
+ expect(transform("import(`./a.json`, { with: { type: 'json' } });")).toBe('import(`./a.json`);');
23
+ });
24
+
25
+ test('handles ImportExpression nodes (createImportExpressions)', () => {
26
+ const code = "import('./a.json', { with: { type: 'json' } });";
27
+ expect(transform(code, true, { createImportExpressions: true })).toBe("import('./a.json');");
28
+ });
29
+
30
+ test('leaves a non-JSON dynamic import unchanged', () => {
31
+ expectUnchanged("import('./a.js', { x: 1 });");
32
+ expect(transform("import('./a.js', { x: 1 });")).toContain('x: 1');
33
+ });
34
+
35
+ test('leaves a non-literal specifier unchanged', () => {
36
+ expectUnchanged("import(variable, { with: { type: 'json' } });");
37
+ expect(transform("import(variable, { with: { type: 'json' } });")).toContain("type: 'json'");
38
+ });
39
+
40
+ test('leaves a static JSON import with attributes unchanged', () => {
41
+ const code = "import d from './d.json' with { type: 'json' };";
42
+ expectUnchanged(code);
43
+ expect(transform(code)).toContain("with { type: 'json' }");
44
+ });
45
+ });
@@ -0,0 +1,21 @@
1
+ import type { ConfigContext, ExpoConfig } from 'expo/config';
2
+
3
+ // The default API port lives in src/shell/apiPort.json (services/api/.env.example PORT), shared with env.ts. JSON,
4
+ // because Expo's config loader cannot import a TypeScript module.
5
+ import { defaultApiPort } from './src/shell/apiPort.json';
6
+
7
+ // src/shell/env.ts combines the API port with the dev machine's host when EXPO_PUBLIC_API_URL is not set, so the
8
+ // Simulator and a device on the LAN both work.
9
+ const API_PORT = defaultApiPort;
10
+
11
+ // The static app identity (name, slug, bundle identifier) stays in app.json; Expo passes it in as `config`.
12
+ export default ({ config }: ConfigContext): ExpoConfig => ({
13
+ ...config,
14
+ name: config.name ?? 'App',
15
+ slug: config.slug ?? 'app',
16
+ extra: {
17
+ ...config.extra,
18
+ apiUrl: process.env.EXPO_PUBLIC_API_URL ?? null,
19
+ apiPort: API_PORT,
20
+ },
21
+ });
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Babel plugin: drop import attributes from dynamic `import()` of JSON files.
3
+ *
4
+ * Linked ontology packages load their data with
5
+ * import('../data/x.json', { with: { type: 'json' } })
6
+ * Node requires the attribute for JSON, so the packages keep it. Metro's dependency collector rejects any
7
+ * `import()` with more than one argument ("Invalid call at line N"). This plugin removes the options argument
8
+ * when the specifier is a static string ending in `.json`, so Metro bundles it as an ordinary async JSON module.
9
+ * Everything else, including static `import x from './a.json' with { type: 'json' }`, is left untouched.
10
+ */
11
+
12
+ function staticSpecifier(node) {
13
+ if (!node) return null;
14
+ if (node.type === 'StringLiteral') return node.value;
15
+ if (node.type === 'TemplateLiteral' && node.expressions.length === 0) return node.quasis[0].value.cooked;
16
+ return null;
17
+ }
18
+
19
+ const isJson = (specifier) => typeof specifier === 'string' && /\.json$/i.test(specifier);
20
+
21
+ module.exports = function stripJsonImportAttributes() {
22
+ return {
23
+ name: 'strip-json-import-attributes',
24
+ visitor: {
25
+ // Default parser output (what Metro and babel-preset-expo use): `import(...)` is a CallExpression with an
26
+ // `Import` callee.
27
+ CallExpression(path) {
28
+ const { callee, arguments: args } = path.node;
29
+ if (callee.type !== 'Import' || args.length < 2) return;
30
+ if (!isJson(staticSpecifier(args[0]))) return;
31
+ path.node.arguments = [args[0]];
32
+ },
33
+ // With the parser option `createImportExpressions`, it is an ImportExpression with `source` and `options`.
34
+ ImportExpression(path) {
35
+ const { node } = path;
36
+ if (!node.options || !isJson(staticSpecifier(node.source))) return;
37
+ node.options = null;
38
+ },
39
+ },
40
+ };
41
+ };
@@ -0,0 +1,13 @@
1
+ // Metro passes this file as `extends` for every module it transforms, node_modules included (so @_linked/*), and
2
+ // Jest (jest-expo) picks it up too. No root babel.config.js is needed: Metro's Babel root is apps/mobile.
3
+ module.exports = function (api) {
4
+ // Cached for the process lifetime and in Metro's transform cache: after editing this file (or the plugin),
5
+ // restart with `npx expo start --clear`.
6
+ api.cache(true);
7
+ return {
8
+ // babel-preset-expo is installed nested under expo; resolve it from there, as Expo's own default config does.
9
+ presets: [require.resolve('babel-preset-expo', { paths: [require.resolve('expo/package.json')] })],
10
+ // Metro rejects `import('x.json', { with: { type: 'json' } })`, used by every Linked ontology package.
11
+ plugins: [require.resolve('./babel/stripJsonImportAttributes')],
12
+ };
13
+ };
@@ -0,0 +1,16 @@
1
+ // Round-trip tests against a real API and Fuseki: `npm run fuseki:up && npm run test:integration`.
2
+ // Same preset, transforms and module mapping as the unit config in package.json. jest-expo replaces the global
3
+ // fetch with a stubbed `expo/fetch` that never sends a request (it is not a jest mock), so the environment saves
4
+ // Node's fetch and restoreNodeFetch.ts reinstates it.
5
+ const { jest: unitConfig } = require('./package.json');
6
+
7
+ module.exports = {
8
+ ...unitConfig,
9
+ testMatch: ['<rootDir>/__tests__/integration/**/*.test.[jt]s?(x)'],
10
+ testPathIgnorePatterns: ['/node_modules/'],
11
+ testEnvironment: '<rootDir>/__tests__/integration/nodeFetchEnvironment.js',
12
+ setupFilesAfterEnv: ['<rootDir>/__tests__/integration/restoreNodeFetch.ts'],
13
+ globalSetup: '<rootDir>/__tests__/integration/globalSetup.ts',
14
+ globalTeardown: '<rootDir>/__tests__/integration/globalTeardown.ts',
15
+ testTimeout: 30000,
16
+ };
@@ -7,13 +7,17 @@
7
7
  "start": "expo start",
8
8
  "ios": "expo run:ios",
9
9
  "android": "expo run:android",
10
- "test": "jest"
10
+ "test": "jest",
11
+ "test:integration": "jest -c jest.integration.config.js --runInBand"
11
12
  },
12
13
  "dependencies": {
13
- "@_linked/core": "2.18.0",
14
- "@_linked/react": "1.4.2",
14
+ "@_linked/core": "2.18.1",
15
+ "@_linked/react": "1.5.0",
16
+ "@_linked/schema": "1.1.2",
17
+ "@_linked/server": "2.3.0",
15
18
  "app-shapes": "*",
16
19
  "expo": "57.0.22",
20
+ "expo-constants": "57.0.18",
17
21
  "expo-dev-client": "57.0.19",
18
22
  "react": "19.2.3",
19
23
  "react-native": "0.86.3"
@@ -30,6 +34,10 @@
30
34
  },
31
35
  "jest": {
32
36
  "preset": "jest-expo",
37
+ "testPathIgnorePatterns": [
38
+ "/node_modules/",
39
+ "<rootDir>/__tests__/integration/"
40
+ ],
33
41
  "transformIgnorePatterns": [
34
42
  "node_modules/(?!((jest-)?react-native|@react-native(-community)?|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg|@_linked/.*|app-shapes))"
35
43
  ],
@@ -0,0 +1,123 @@
1
+ // Example PersonOverview — demonstrates @_linked/react's data binding:
2
+ // linkedSetComponent for the list (auto-runs the query, injects results and
3
+ // an injected _refresh); linkedComponent for each row (see PersonPreview).
4
+ // After Person.create the form calls _refresh() to re-run the list query.
5
+ // A React Native port of the web app-template's example (linked-cm/app-template).
6
+ //
7
+ // `linkedSetComponent` checks LinkedStorage when this module loads, so storage is imported first.
8
+ // Replace or extend this with your own shapes (see https://linked.cm).
9
+ import '../shell/storage';
10
+
11
+ import React, { useCallback, useRef, useState } from 'react';
12
+ import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
13
+ import { linkedSetComponent } from '@_linked/react';
14
+ import { Person } from '@_linked/schema/shapes/Person';
15
+ import { PersonPreview } from './PersonPreview';
16
+ import { PersonListRefreshProvider } from './PersonOverviewContext';
17
+
18
+ const PersonList = linkedSetComponent(
19
+ Person.select((p) => [p.givenName, p.familyName]),
20
+ ({ linkedData = [], _refresh, refreshRef }: any) => {
21
+ // Forward the list's _refresh up via a ref so the sibling form +
22
+ // child rows can trigger a re-fetch through context.
23
+ if (refreshRef) {
24
+ refreshRef.current = _refresh;
25
+ }
26
+
27
+ if (linkedData.length === 0) {
28
+ return (
29
+ <Text style={styles.empty} testID="person-empty">
30
+ No people yet. Add one below.
31
+ </Text>
32
+ );
33
+ }
34
+
35
+ return (
36
+ <View style={styles.list} testID="person-list">
37
+ {linkedData.map((p: { id: string }) => (
38
+ <PersonPreview key={p.id} of={{ id: p.id }} />
39
+ ))}
40
+ </View>
41
+ );
42
+ },
43
+ );
44
+
45
+ function PersonAddForm({ onAdded }: { onAdded: () => void }) {
46
+ const [given, setGiven] = useState('');
47
+ const [family, setFamily] = useState('');
48
+ const [busy, setBusy] = useState(false);
49
+
50
+ async function submit() {
51
+ if (!given && !family) return;
52
+ setBusy(true);
53
+ try {
54
+ await Person.create({ givenName: given, familyName: family });
55
+ setGiven('');
56
+ setFamily('');
57
+ onAdded();
58
+ } finally {
59
+ setBusy(false);
60
+ }
61
+ }
62
+
63
+ return (
64
+ <View style={styles.form}>
65
+ <TextInput
66
+ style={styles.input}
67
+ value={given}
68
+ placeholder="First name"
69
+ onChangeText={setGiven}
70
+ testID="person-add-given"
71
+ />
72
+ <TextInput
73
+ style={styles.input}
74
+ value={family}
75
+ placeholder="Last name"
76
+ onChangeText={setFamily}
77
+ testID="person-add-family"
78
+ />
79
+ <Pressable style={styles.button} onPress={submit} disabled={busy} testID="person-add">
80
+ <Text style={styles.buttonText}>Add</Text>
81
+ </Pressable>
82
+ </View>
83
+ );
84
+ }
85
+
86
+ export function PersonOverview() {
87
+ const refreshRef = useRef<() => void>(() => {});
88
+ const refresh = useCallback(() => refreshRef.current(), []);
89
+
90
+ return (
91
+ <PersonListRefreshProvider value={refresh}>
92
+ <View style={styles.root} testID="person-overview">
93
+ <Text style={styles.title}>People</Text>
94
+ <Text style={styles.intro}>
95
+ Example code using the @_linked query DSL against your local Fuseki dataset. Edit
96
+ src/components/PersonOverview.tsx to extend it.
97
+ </Text>
98
+ <PersonList refreshRef={refreshRef} />
99
+ <PersonAddForm onAdded={refresh} />
100
+ </View>
101
+ </PersonListRefreshProvider>
102
+ );
103
+ }
104
+
105
+ const styles = StyleSheet.create({
106
+ root: { alignSelf: 'stretch', padding: 16, gap: 12 },
107
+ title: { fontSize: 22, fontWeight: '600' },
108
+ intro: { color: '#555' },
109
+ empty: { color: '#888', fontStyle: 'italic' },
110
+ list: { gap: 8 },
111
+ form: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, alignItems: 'center' },
112
+ input: {
113
+ flexGrow: 1,
114
+ minWidth: 100,
115
+ borderWidth: 1,
116
+ borderColor: '#ccc',
117
+ borderRadius: 6,
118
+ paddingHorizontal: 8,
119
+ paddingVertical: 6,
120
+ },
121
+ button: { backgroundColor: '#222', borderRadius: 6, paddingHorizontal: 14, paddingVertical: 8 },
122
+ buttonText: { color: '#fff', fontWeight: '600' },
123
+ });
@@ -0,0 +1,12 @@
1
+ // Small context that lets PersonPreview rows trigger a list refresh on
2
+ // their parent PersonOverview without prop-drilling through the
3
+ // linkedSetComponent's wrapped renderer.
4
+ import React, { useContext } from 'react';
5
+
6
+ const PersonListRefreshContext = React.createContext<() => void>(() => {});
7
+
8
+ export const PersonListRefreshProvider = PersonListRefreshContext.Provider;
9
+
10
+ export function usePersonListRefresh(): () => void {
11
+ return useContext(PersonListRefreshContext);
12
+ }
@@ -0,0 +1,135 @@
1
+ // Example PersonPreview — single-Person row. Demonstrates @_linked/react's
2
+ // linkedComponent: the wrapper runs the per-row query and injects query
3
+ // result keys + `source` + `_refresh` into the render function. _refresh
4
+ // patches local query state for optimistic UI; on delete, the parent
5
+ // list re-runs via the PersonListRefresh context.
6
+ // A React Native port of the web app-template's example (linked-cm/app-template).
7
+ // Storage is imported first, so the row's queries go to the API store however this module is reached.
8
+ import '../shell/storage';
9
+
10
+ import React, { useState } from 'react';
11
+ import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
12
+ import { linkedComponent } from '@_linked/react';
13
+ import { Person } from '@_linked/schema/shapes/Person';
14
+ import { usePersonListRefresh } from './PersonOverviewContext';
15
+
16
+ export const PersonPreview = linkedComponent(
17
+ Person.select((p) => [p.givenName, p.familyName]),
18
+ ({ givenName, familyName, source, _refresh }) => {
19
+ const refreshList = usePersonListRefresh();
20
+ const [editing, setEditing] = useState(false);
21
+ const [draftGiven, setDraftGiven] = useState(givenName ?? '');
22
+ const [draftFamily, setDraftFamily] = useState(familyName ?? '');
23
+ const [busy, setBusy] = useState(false);
24
+
25
+ async function save() {
26
+ if (!source.id) return;
27
+ setBusy(true);
28
+ try {
29
+ await Person.update({
30
+ givenName: draftGiven,
31
+ familyName: draftFamily,
32
+ }).for({ id: source.id });
33
+ // Optimistic patch — no extra network round-trip needed.
34
+ _refresh({ givenName: draftGiven, familyName: draftFamily });
35
+ setEditing(false);
36
+ } finally {
37
+ setBusy(false);
38
+ }
39
+ }
40
+
41
+ async function remove() {
42
+ if (!source.id) return;
43
+ setBusy(true);
44
+ try {
45
+ await Person.delete({ id: source.id });
46
+ refreshList();
47
+ } finally {
48
+ setBusy(false);
49
+ }
50
+ }
51
+
52
+ function startEdit() {
53
+ setDraftGiven(givenName ?? '');
54
+ setDraftFamily(familyName ?? '');
55
+ setEditing(true);
56
+ }
57
+
58
+ if (editing) {
59
+ return (
60
+ <View style={styles.row} testID="person-edit">
61
+ <TextInput
62
+ style={styles.input}
63
+ value={draftGiven}
64
+ onChangeText={setDraftGiven}
65
+ accessibilityLabel="First name"
66
+ testID="person-edit-given"
67
+ />
68
+ <TextInput
69
+ style={styles.input}
70
+ value={draftFamily}
71
+ onChangeText={setDraftFamily}
72
+ accessibilityLabel="Last name"
73
+ testID="person-edit-family"
74
+ />
75
+ <Pressable style={styles.button} onPress={save} disabled={busy} testID="person-save">
76
+ <Text style={styles.buttonText}>Save</Text>
77
+ </Pressable>
78
+ <Pressable
79
+ style={[styles.button, styles.secondary]}
80
+ onPress={() => setEditing(false)}
81
+ disabled={busy}
82
+ testID="person-cancel"
83
+ >
84
+ <Text>Cancel</Text>
85
+ </Pressable>
86
+ </View>
87
+ );
88
+ }
89
+
90
+ return (
91
+ <View style={styles.row} testID="person-row">
92
+ <Text style={styles.name} testID="person-name">
93
+ {givenName} {familyName}
94
+ </Text>
95
+ <View style={styles.actions}>
96
+ <Pressable
97
+ style={[styles.button, styles.secondary]}
98
+ onPress={startEdit}
99
+ disabled={busy}
100
+ testID="person-edit-button"
101
+ >
102
+ <Text>Edit</Text>
103
+ </Pressable>
104
+ <Pressable
105
+ style={[styles.button, styles.danger]}
106
+ onPress={remove}
107
+ disabled={busy}
108
+ testID="person-delete"
109
+ >
110
+ <Text style={styles.buttonText}>Delete</Text>
111
+ </Pressable>
112
+ </View>
113
+ </View>
114
+ );
115
+ },
116
+ );
117
+
118
+ const styles = StyleSheet.create({
119
+ row: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', gap: 8 },
120
+ name: { flexGrow: 1, fontSize: 16 },
121
+ actions: { flexDirection: 'row', gap: 8 },
122
+ input: {
123
+ flexGrow: 1,
124
+ minWidth: 90,
125
+ borderWidth: 1,
126
+ borderColor: '#ccc',
127
+ borderRadius: 6,
128
+ paddingHorizontal: 8,
129
+ paddingVertical: 6,
130
+ },
131
+ button: { backgroundColor: '#222', borderRadius: 6, paddingHorizontal: 12, paddingVertical: 6 },
132
+ buttonText: { color: '#fff', fontWeight: '600' },
133
+ secondary: { backgroundColor: '#eee' },
134
+ danger: { backgroundColor: '#b3261e' },
135
+ });
@@ -0,0 +1 @@
1
+ { "defaultApiPort": 4000 }
@@ -0,0 +1,30 @@
1
+ // Resolves the API URL and exposes it to @_linked/server's BackendAPIStore, which reads process.env.SITE_ROOT and
2
+ // DATA_ROOT at runtime. ./storage imports this module, so the variables are set before the store is constructed.
3
+ import Constants from 'expo-constants';
4
+
5
+ import { defaultApiPort } from './apiPort.json';
6
+
7
+ export type ApiUrlInput = {
8
+ // `unknown`: the dev-client manifest serializes `apiUrl: null` from app.config.ts as `{}`.
9
+ extra?: { apiUrl?: unknown; apiPort?: number } | null;
10
+ hostUri?: string | null;
11
+ };
12
+
13
+ /**
14
+ * `extra.apiUrl` (EXPO_PUBLIC_API_URL) if it is a non-empty string; otherwise the dev machine's host from Expo's
15
+ * `hostUri` (`192.168.1.5:8081`) with the API port; otherwise localhost.
16
+ */
17
+ export function resolveApiUrl({ extra, hostUri }: ApiUrlInput): string {
18
+ if (typeof extra?.apiUrl === 'string' && extra.apiUrl) return extra.apiUrl;
19
+ const port = extra?.apiPort ?? defaultApiPort;
20
+ const host = hostUri ? hostUri.split(':')[0] : '';
21
+ return `http://${host || 'localhost'}:${port}`;
22
+ }
23
+
24
+ export const apiUrl: string = resolveApiUrl({
25
+ extra: Constants.expoConfig?.extra as ApiUrlInput['extra'],
26
+ hostUri: Constants.expoConfig?.hostUri,
27
+ });
28
+
29
+ process.env.SITE_ROOT = apiUrl;
30
+ process.env.DATA_ROOT = `${apiUrl}/data`;
@@ -0,0 +1,9 @@
1
+ // Sends every Linked query to the API (services/api). Import @_linked/server by subpath only: the root barrel
2
+ // pulls express, webpack and react-dom.
3
+ // ./env comes first: BackendAPIStore's server proxy reads SITE_ROOT once.
4
+ import './env';
5
+
6
+ import { LinkedStorage } from '@_linked/core/utils/LinkedStorage';
7
+ import { BackendAPIStore } from '@_linked/server/shapes/quadstores/BackendAPIStore';
8
+
9
+ LinkedStorage.setDefaultDataset(new BackendAPIStore({ name: 'appData' }));
@@ -0,0 +1,22 @@
1
+ # Local Fuseki for services/api. Same pinned image as @_linked/core.
2
+ # `npm run fuseki:up` starts it and waits for the healthcheck; `npm run fuseki:down` stops it (data persists).
3
+ services:
4
+ fuseki:
5
+ image: secoresearch/fuseki:5.5.0
6
+ ports:
7
+ # Override the host port when 3030 is taken (another project's Fuseki); set FUSEKI_BASE_URL to match.
8
+ - "${FUSEKI_PORT:-3030}:3030"
9
+ environment:
10
+ - ADMIN_PASSWORD=${FUSEKI_PASSWORD:-admin}
11
+ volumes:
12
+ # The whole FUSEKI_BASE: datasets created through the admin API keep their data in databases/ and their
13
+ # definition in configuration/, so both must persist. In-memory datasets (app-test) are not persisted.
14
+ - fuseki-data:/fuseki-base
15
+ healthcheck:
16
+ test: ["CMD-SHELL", "wget -qO- http://localhost:3030/$$/ping || exit 1"]
17
+ interval: 2s
18
+ timeout: 3s
19
+ retries: 30
20
+
21
+ volumes:
22
+ fuseki-data: