@live-change/frontend-template 0.9.201 → 0.9.204

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 (54) hide show
  1. package/.claude/rules/live-change-backend-actions-views-triggers.md +49 -1
  2. package/.claude/rules/live-change-backend-architecture.md +5 -5
  3. package/.claude/rules/live-change-backend-event-sourcing.md +2 -2
  4. package/.claude/rules/live-change-backend-models-and-relations.md +65 -8
  5. package/.claude/rules/live-change-frontend-e2e-lifecycle.md +42 -0
  6. package/.claude/rules/live-change-frontend-i18n-locales.md +25 -0
  7. package/.claude/rules/live-change-frontend-vue-primevue.md +100 -4
  8. package/.claude/rules/live-change-node-toolchain-fnm.md +39 -0
  9. package/.claude/rules/live-change-service-structure.md +2 -2
  10. package/.claude/settings.json +3 -1
  11. package/.claude/skills/create-skills-and-rules/SKILL.md +23 -0
  12. package/.claude/skills/live-change-backend-change-triggers/SKILL.md +15 -0
  13. package/.claude/skills/live-change-dao-protocol/SKILL.md +46 -0
  14. package/.claude/skills/live-change-design-actions-views-triggers/SKILL.md +116 -0
  15. package/.claude/skills/live-change-design-models-relations/SKILL.md +63 -5
  16. package/.claude/skills/live-change-frontend-command-forms/SKILL.md +6 -5
  17. package/.claude/skills/live-change-frontend-data-views/SKILL.md +75 -4
  18. package/.claude/skills/live-change-frontend-e2e-lifecycle/SKILL.md +82 -0
  19. package/.claude/skills/live-change-frontend-range-list/SKILL.md +90 -0
  20. package/.claude/skills/live-change-frontend-synchronized/SKILL.md +101 -0
  21. package/.claude/skills/live-change-node-toolchain-fnm/SKILL.md +44 -0
  22. package/.cursor/rules/live-change-backend-actions-views-triggers.mdc +58 -4
  23. package/.cursor/rules/live-change-backend-architecture.mdc +1 -1
  24. package/.cursor/rules/live-change-backend-event-sourcing.mdc +1 -1
  25. package/.cursor/rules/live-change-backend-models-and-relations.mdc +69 -7
  26. package/.cursor/rules/live-change-backend-views-vs-triggers-for-reads-writes.mdc +28 -0
  27. package/.cursor/rules/live-change-dao-protocol.mdc +47 -0
  28. package/.cursor/rules/live-change-frontend-e2e-lifecycle.mdc +15 -0
  29. package/.cursor/rules/live-change-frontend-i18n-locales.mdc +26 -0
  30. package/.cursor/rules/live-change-frontend-views-not-commands-for-reads.mdc +30 -0
  31. package/.cursor/rules/live-change-frontend-vue-primevue.mdc +91 -0
  32. package/.cursor/rules/live-change-node-toolchain-fnm.mdc +40 -0
  33. package/.cursor/rules/live-change-service-structure.mdc +1 -1
  34. package/.cursor/skills/create-skills-and-rules.md +23 -0
  35. package/.cursor/skills/live-change-backend-change-triggers.md +15 -0
  36. package/.cursor/skills/live-change-design-actions-views-triggers.md +57 -0
  37. package/.cursor/skills/live-change-design-models-relations.md +23 -5
  38. package/.cursor/skills/live-change-frontend-command-forms.md +6 -5
  39. package/.cursor/skills/live-change-frontend-data-views.md +17 -0
  40. package/.cursor/skills/live-change-frontend-e2e-lifecycle.md +82 -0
  41. package/.cursor/skills/live-change-frontend-range-list.md +45 -0
  42. package/.cursor/skills/live-change-frontend-synchronized.md +101 -0
  43. package/.cursor/skills/live-change-node-toolchain-fnm.md +44 -0
  44. package/.node-version +1 -0
  45. package/.nvmrc +1 -0
  46. package/README.md +18 -0
  47. package/e2e/client-session.test.ts +17 -0
  48. package/e2e/e2eSuite.ts +12 -0
  49. package/e2e/env.ts +130 -0
  50. package/e2e/homepage.test.ts +17 -0
  51. package/e2e/steps.ts +3 -0
  52. package/e2e/withBrowser.ts +18 -0
  53. package/opencode.json +4 -1
  54. package/package.json +55 -53
@@ -112,6 +112,21 @@ definition.trigger({
112
112
 
113
113
  This means: when a user creates a Schedule via the UI or API, the timer is automatically set up. When they update it, the old timer is canceled and a new one created. When they delete it, the timer is canceled.
114
114
 
115
+ ## Cron-service — planning and admin UI guardrails
116
+
117
+ When the domain needs **wall-clock schedules** or **fixed repeating intervals** that run a **trigger**, default to **`@live-change/cron-service`** (models **Schedule** / **Interval**, internal **timer** + **changeCron_*** lifecycle), not ad-hoc timers only.
118
+
119
+ **Backend:** define the **target `definition.trigger`** in your service; put **Schedule** / **Interval** rows in **cron** with **`trigger: { name, service, properties, returnTask }`**. Rely on **`changeCron_Schedule`** / **`changeCron_Interval`** for timer repair (already implemented in cron-service).
120
+
121
+ **Admin / task-frontend-style UI:** use the same integration as the reference pages:
122
+
123
+ - **Create:** `ActionForm` with `service="cron"` and `action="setSchedule"` or `action="setInterval"` (relations-driven forms).
124
+ - **List:** `RangeViewer` + `path.cron.schedules` / `path.cron.intervals` with **`reverseRange(range)`** as needed.
125
+ - **Per row:** `.with()` → `scheduleInfo` / `intervalInfo`, `runState` (`jobType` **`cron_Schedule`** or **`cron_Interval`**, **`job`** = id), and `task.tasksByCauseAndCreatedAt` for recent runs.
126
+ - **Delete:** `api.actions.cron.deleteSchedule` / `deleteInterval`.
127
+
128
+ See **server doc** `15-cron-and-intervals.md` → section **“API used by task-frontend”** for path examples and **Schedule** field semantics (**`NaN`** = “every” for that field).
129
+
115
130
  ## Step 4 – Specific lifecycle triggers (alternative)
116
131
 
117
132
  If you only care about one lifecycle event, use the specific variant:
@@ -7,6 +7,12 @@ description: Design actions, views, triggers with indexes and batch processing p
7
7
 
8
8
  Use this skill to design **actions, views, and triggers** in LiveChange services while making good use of indexes and avoiding full-table scans.
9
9
 
10
+ ## Reads vs writes (CQRS-like)
11
+
12
+ **Frontend (Vue):** load data with `usePath` + `live` / `useFetch` on **views**. Do **not** use `api.command` or `useActions()` only to fetch, preview, or compute display-only values — add a `definition.view` on the server and read it on the client.
13
+
14
+ **Backend (services):** **`definition.view`** is the read surface (including computed or preview data). From triggers/actions use **`app.viewGet`** / **`app.serviceViewGet`** when you need the same view layer as the client, or direct model/index reads where appropriate. **Actions and triggers** change state via **`emit`** / **`trigger`** / **`triggerService`**, not via fake “read-only actions.”
15
+
10
16
  ## When to use
11
17
 
12
18
  - You add or change actions on existing models.
@@ -73,6 +79,57 @@ definition.action({
73
79
  - use model paths (`Model.path`, `Model.rangePath`, `Model.sortedIndexRangePath`, `Model.indexObjectPath`)
74
80
  - use `...App.rangeProperties` + `App.extractRange(props)` for range views
75
81
 
82
+ ### Step 2a – RangeViewer/rangeBuckets compatibility
83
+
84
+ When a view is consumed by `RangeViewer` or `rangeBuckets`:
85
+
86
+ - prefer `Model.sortedIndexRangePath(...)` for index-backed list views,
87
+ - keep `App.extractRange(props)` as pagination cursor input,
88
+ - do not reinterpret `gt/gte/lt/lte` as domain filters.
89
+
90
+ Anti-patterns:
91
+
92
+ - using `indexRangePath` for frontend bucket pagination flow,
93
+ - injecting custom month/year bounds into cursor fields in frontend,
94
+ - rewriting cursor values in backend with unrelated filter semantics.
95
+
96
+ Preferred filtering strategy:
97
+
98
+ 1. design index prefix for frequent filters,
99
+ 2. use `App.utils.prefixRange` only as backend fallback,
100
+ 3. keep string min/max hacks as last resort.
101
+
102
+ ### Step 2b – Standalone indexes for union/equal sources
103
+
104
+ When index rows are built from multiple equal tables (union-like flow), do not force the index into one model definition.
105
+
106
+ Use `definition.index(...)` at service level (typically `indexes.js`) when:
107
+
108
+ - index combines rows from two or more source tables,
109
+ - source tables are peer entities (no natural single owner model),
110
+ - index is a projection layer for cross-table reads.
111
+
112
+ Example:
113
+
114
+ ```js
115
+ definition.index({
116
+ name: 'Urls',
117
+ function: async (input, output) => {
118
+ await input.table('url_Redirect').onChange((obj, oldObj) =>
119
+ output.change(obj && mapRedirect(obj), oldObj && mapRedirect(oldObj))
120
+ )
121
+ await input.table('url_Canonical').onChange((obj, oldObj) =>
122
+ output.change(obj && mapCanonical(obj), oldObj && mapCanonical(oldObj))
123
+ )
124
+ }
125
+ })
126
+ ```
127
+
128
+ Decision rule:
129
+
130
+ - model-local index -> `definition.model({ indexes: ... })`,
131
+ - union/peer-source index -> standalone `definition.index(...)` in `indexes.js`.
132
+
76
133
  ### Example: `daoPath` (preferred, DAO-backed)
77
134
 
78
135
  ```js
@@ -62,6 +62,25 @@ properties: {
62
62
  }
63
63
  ```
64
64
 
65
+ ## Step 2b – Relation arity rules (critical)
66
+
67
+ Treat arity on two levels:
68
+
69
+ - **Annotation arity**: can the annotation itself be a list of configs?
70
+ - **Parent tuple arity**: can one config point to multiple parents/dimensions?
71
+
72
+ | Relation | Annotation arity | Parent tuple arity |
73
+ |---|---|---|
74
+ | `propertyOf`, `itemOf`, `boundTo` | single config only | `what` can be one model or `[A, B, ...]` |
75
+ | `relatedTo` | single config or config list | each config uses `what` with one model or `[A, B, ...]` |
76
+ | `propertyOfAny`, `itemOfAny`, `boundToAny` | single config only | `to` can contain one or many names |
77
+ | `relatedToAny` | single config or config list | each config uses `to` with one or many names |
78
+
79
+ Guardrail:
80
+
81
+ - valid: `propertyOf: { what: [A, B] }`
82
+ - invalid: `propertyOf: [configA, configB]`
83
+
65
84
  ## Step 3 – Configure the relation
66
85
 
67
86
  ### `userItem`
@@ -110,7 +129,7 @@ so the relations/CRUD generator can treat it as a relation rather than a plain `
110
129
 
111
130
  Notes:
112
131
 
113
- - Usually you’ll have 1–2 parents, but the `propertyOf` list may contain **any number** of parent models (including 3+).
132
+ - Usually you’ll have 1–2 parents, but `what` may contain **any number** of parent models (including 3+).
114
133
  - If the entity is a relation, avoid adding manual `...Id` fields in `properties` just to represent the link — CRUD generators won’t treat it as a relation.
115
134
 
116
135
  Example:
@@ -124,10 +143,9 @@ definition.model({
124
143
  properties: {
125
144
  // optional extra fields
126
145
  },
127
- propertyOf: [
128
- { what: CostInvoice },
129
- { what: Contractor }
130
- ]
146
+ propertyOf: {
147
+ what: [CostInvoice, Contractor]
148
+ }
131
149
  })
132
150
  ```
133
151
 
@@ -19,15 +19,16 @@ Before using this skill, pick the right approach:
19
19
  |---|---|
20
20
  | `editorData` | **Editing model records** (create/update). Drafts, validation, `AutoField`. See `live-change-frontend-editor-form` skill. |
21
21
  | `actionData` | **One-shot action forms** (not CRUD). Submit once → done. See `live-change-frontend-action-form` skill. |
22
- | `api.command` | **Single button or programmatic calls** (no form fields). This skill, Step 1. |
22
+ | `api.command` | **Mutations only** — single button or programmatic calls that **change persisted state** (no form fields). This skill, Step 1. Not for loading or previewing data (use views + `live` / `useFetch`; see `live-change-frontend-data-views`). |
23
23
  | `<command-form>` | **Avoid.** Legacy. Only for trivial prototypes without drafts or `AutoField`. This skill, Step 2. |
24
24
 
25
25
  **Decision flow:**
26
26
 
27
- 1. Does the user fill in form fields? → **No**: use `api.command` (this skill).
28
- 2. Is it editing a model record? → **Yes**: use `editorData`.
29
- 3. Is it a one-shot action? → **Yes**: use `actionData`.
30
- 4. Only use `<command-form>` for the simplest throwaway cases.
27
+ 1. Do you only need to **read** data (including previews)? → **Use views** (`live` / `useFetch`), not this skill.
28
+ 2. Does the user fill in form fields? → **No**: use `api.command` (this skill) **only if** the operation **mutates** state.
29
+ 3. Is it editing a model record? → **Yes**: use `editorData`.
30
+ 4. Is it a one-shot action? **Yes**: use `actionData`.
31
+ 5. Only use `<command-form>` for the simplest throwaway cases.
31
32
 
32
33
  ## Step 1 – Use `api.command` directly
33
34
 
@@ -14,6 +14,8 @@ Use this skill when you build **reactive data views** using `usePath`, `live`, `
14
14
  - You need to load related objects alongside the main data.
15
15
  - You need to restrict data loading for unauthenticated users.
16
16
 
17
+ **Do not use `api.command` / `useActions()` to load data.** Commands are for **mutations**. For every read (lists, details, previews, “next number”), use **views** with `live` or `useFetch` as in this skill.
18
+
17
19
  ## Step 1 – Basic data loading with computed paths
18
20
 
19
21
  When paths depend on reactive values (route params, props), wrap them in `computed()`:
@@ -36,6 +38,21 @@ const [article, comments] = await Promise.all([
36
38
  ])
37
39
  ```
38
40
 
41
+ - Call `usePath()` **once** at the top of `setup` (synchronously). Inside `computed`, use only the returned `path` object to build paths — **never** call `usePath()` or the legacy `path()` inside the getter (there is often no active component instance, which breaks `getCurrentInstance()` / `appContext`).
42
+
43
+ Wrong:
44
+
45
+ ```javascript
46
+ computed(() => usePath().blog.article({ article: id }))
47
+ ```
48
+
49
+ Right:
50
+
51
+ ```javascript
52
+ const path = usePath()
53
+ const articlePath = computed(() => path.blog.article({ article: id }))
54
+ ```
55
+
39
56
  In templates access `.value`:
40
57
 
41
58
  ```vue
@@ -0,0 +1,82 @@
1
+ ---
2
+ name: live-change-frontend-e2e-lifecycle
3
+ description: Set up stable node:test E2E lifecycle with env, withBrowser and e2eSuite
4
+ ---
5
+
6
+ # Skill: live-change-frontend-e2e-lifecycle
7
+
8
+ Use this skill when creating or refactoring LiveChange frontend E2E tests.
9
+
10
+ ## Goal
11
+
12
+ Avoid flaky teardown where only the first test file runs because shared env teardown calls `process.exit`.
13
+
14
+ ## Step 1 - Build shared env helper
15
+
16
+ Create `e2e/env.ts` with:
17
+
18
+ - `getTestEnv()` that starts `TestServer` once and memoizes with `envPromise`
19
+ - `disposeTestEnv()` that resets memoized state and disposes server
20
+ - fallback cleanup:
21
+
22
+ ```ts
23
+ process.on('beforeExit', () => {
24
+ void disposeTestEnv()
25
+ })
26
+ ```
27
+
28
+ Keep startup failure `process.exit(1)` inside `getTestEnv()` catch.
29
+
30
+ Do not add `after(...process.exit(...))` to this file.
31
+
32
+ ## Step 2 - Isolate browser per test
33
+
34
+ Create `e2e/withBrowser.ts` with Playwright setup/teardown in `try/finally`:
35
+
36
+ - call `getTestEnv()` once per test execution
37
+ - open browser/context/page
38
+ - close context and browser in `finally`
39
+
40
+ ## Step 3 - Add suite-level teardown
41
+
42
+ Create `e2e/e2eSuite.ts`:
43
+
44
+ ```ts
45
+ import { after, describe } from 'node:test'
46
+ import { disposeTestEnv } from './env.js'
47
+
48
+ export function e2eSuite(name: string, define: () => void): void {
49
+ describe(name, () => {
50
+ after(async () => {
51
+ await disposeTestEnv()
52
+ process.exit(0)
53
+ })
54
+ define()
55
+ })
56
+ }
57
+ ```
58
+
59
+ ## Step 4 - Wrap tests
60
+
61
+ Each `e2e/*.test.ts` file should use one wrapper:
62
+
63
+ ```ts
64
+ import test from 'node:test'
65
+ import { e2eSuite } from './e2eSuite.js'
66
+
67
+ e2eSuite('example-suite', () => {
68
+ test('renders page', async () => {
69
+ // test body
70
+ })
71
+ })
72
+ ```
73
+
74
+ ## Step 5 - Verify
75
+
76
+ Run from project root with `fnm exec`:
77
+
78
+ ```bash
79
+ fnm exec -- npm run e2e
80
+ ```
81
+
82
+ Confirm multiple test files execute and process exits only after full suite teardown.
@@ -27,6 +27,19 @@ function articlesPathRange(range) {
27
27
  }
28
28
  ```
29
29
 
30
+ ## Step 1a – Hard rules for index-backed ranges
31
+
32
+ For lists loaded with `RangeViewer` / `rangeBuckets`:
33
+
34
+ - backend views should use `sortedIndexRangePath`, not `indexRangePath`,
35
+ - keep `range.gt/gte/lt/lte` for pagination cursor only,
36
+ - never override `gt/lt` in frontend `pathFunction` with ad-hoc filters.
37
+
38
+ Why:
39
+
40
+ - RangeViewer computes next buckets from previous cursor boundaries,
41
+ - replacing cursor fields causes repeated slices and broken infinite loading.
42
+
30
43
  ## Step 2 – Attach related objects with `.with()`
31
44
 
32
45
  Chain `.with()` calls to load related data for each item:
@@ -127,3 +140,35 @@ Iterate in the template:
127
140
  </div>
128
141
  </template>
129
142
  ```
143
+
144
+ ## Step 5 – Reactive filters and safe reloads
145
+
146
+ When your `pathFunction` depends on changing filters (month, status, company, search), prefer `ReactiveRangeViewer`.
147
+
148
+ Why:
149
+
150
+ - reactivity in `pathFunction` can be subtle and lead to stale bucket state
151
+ - ad-hoc `:key` resets spread fragile logic in pages
152
+ - `ReactiveRangeViewer` centralizes reload logic and can preserve list height while reloading
153
+
154
+ ```vue
155
+ <ReactiveRangeViewer
156
+ :pathFunction="transactionsPathRange"
157
+ :sourceKey="JSON.stringify({ accountId, month: filterByMonth ? month : null })"
158
+ :preserveHeightOnReload="true"
159
+ :canLoadTop="false"
160
+ canDropBottom
161
+ >
162
+ <template #default="{ item }">
163
+ <BankTransactionListItem :transaction="item" />
164
+ </template>
165
+ </ReactiveRangeViewer>
166
+ ```
167
+
168
+ ## Checklist – range pagination safety
169
+
170
+ - [ ] backend index view is based on `sortedIndexRangePath`
171
+ - [ ] frontend `pathFunction` forwards `range` unchanged (`...range` or `...reverseRange(range)`)
172
+ - [ ] domain filters (`month`, `year`, `status`) are separate view params
173
+ - [ ] no manual cursor overrides (`gt/gte/lt/lte`) in frontend code
174
+ - [ ] if narrowing is needed, backend uses index prefix design first, `prefixRange` only as fallback
@@ -0,0 +1,101 @@
1
+ ---
2
+ name: live-change-frontend-synchronized
3
+ description: Use synchronized and synchronizedList for autosave editing in LiveChange Vue frontends, including object vs list decision flow, identifiers mapping, and save/delete patterns
4
+ ---
5
+
6
+ # Skill: live-change-frontend-synchronized (Cursor)
7
+
8
+ Use this skill when implementing or refactoring frontend editing flows that should keep local form state synchronized with backend data.
9
+
10
+ ## When to use
11
+
12
+ - Editing one object loaded from `live(...)` with autosave or manual save.
13
+ - Editing list items inline (for example access roles) with per-row identifiers.
14
+ - Replacing custom `watch + api.command` autosave logic with a standard helper.
15
+ - Choosing between `synchronized`, `synchronizedList`, and `editorData`.
16
+
17
+ ## Decision flow
18
+
19
+ 1. **One editable object** (`profile`, `note`, `settings`) -> use `synchronized`.
20
+ 2. **Editable list rows** (`accesses`, `invitations`, `requests`) -> use `synchronizedList`.
21
+ 3. **Definition-driven CRUD form with validation UI** -> use `editorData` from auto-form stack.
22
+
23
+ ## What counts as "editable list rows"
24
+
25
+ Treat the feature as a list flow (`synchronizedList`) when most of these are true:
26
+
27
+ - The UI renders rows with `v-for` and each row has editable fields.
28
+ - Users can edit many rows inline in one screen (table/config/admin list).
29
+ - Autosave should happen per row while the list stays reactive.
30
+ - Each row needs its own action payload keys in addition to shared list context.
31
+
32
+ In this case, wire one `synchronizedList(...)` and edit fields directly on `syncList.value`.
33
+ Do not build a parallel `id -> synchronized(...)` map for rows.
34
+
35
+ ## `synchronized` pattern (object)
36
+
37
+ ```javascript
38
+ import { synchronized } from '@live-change/vue3-components'
39
+
40
+ const sync = synchronized({
41
+ source: sourceRef,
42
+ update: actions.service.updateThing,
43
+ identifiers: computed(() => ({ thing: thingId.value })),
44
+ recursive: true,
45
+ autoSave: true,
46
+ debounce: 600
47
+ })
48
+
49
+ const { value: editable, changed, saving, save } = sync
50
+ ```
51
+
52
+ ### Rules
53
+
54
+ - `source` should come from `live(...)` or a computed source.
55
+ - Keep identifiers stable and pass them through `identifiers` (plain object or `computed`).
56
+ - Use `updateDataProperty: 'data'` for draft-like payloads where backend expects nested data.
57
+ - Use `autoSave: false` when save must happen only after explicit confirmation.
58
+
59
+ ## `synchronizedList` pattern (list)
60
+
61
+ ```javascript
62
+ import { synchronizedList } from '@live-change/vue3-components'
63
+
64
+ const syncList = synchronizedList({
65
+ source: accesses,
66
+ update: actions.accessControl.updateSessionOrUserAndObjectOwnedAccess,
67
+ delete: actions.accessControl.resetSessionOrUserAndObjectOwnedAccess,
68
+ identifiers: { object, objectType },
69
+ objectIdentifiers: ({ to, sessionOrUser, sessionOrUserType }) => ({
70
+ access: to, sessionOrUser, sessionOrUserType, object, objectType
71
+ }),
72
+ recursive: true
73
+ })
74
+
75
+ const editableRows = syncList.value
76
+ await syncList.delete(editableRows.value[0])
77
+ ```
78
+
79
+ ### Rules
80
+
81
+ - `source` should be a list from `live(...)`.
82
+ - Every item should have stable `id`.
83
+ - Put shared context in `identifiers`, and row-specific keys in `objectIdentifiers`.
84
+ - Edit rows through `syncList.value`; call `syncList.delete(...)` / `syncList.insert(...)` on the helper object.
85
+ - Prefer this pattern for configuration lists, permission tables, dictionaries, and other multi-row settings editors.
86
+
87
+ ## Project examples to follow
88
+
89
+ - `speed-dating/front/src/components/notes/NoteEditor.vue` (`synchronized` with autosave)
90
+ - `speed-dating/front/src/components/profile/ProfileSettings.vue` (`synchronized` + `updateDataProperty: 'data'`)
91
+ - `family-tree/front/src/components/AgreementDialog.vue` (`synchronized` with manual save)
92
+ - `rcstreamer/front/src/configuration/AccessRequests.vue` (`synchronizedList`)
93
+ - `rcstreamer/front/src/configuration/AccessInvitations.vue` (`synchronizedList`)
94
+ - `rcstreamer/front/src/configuration/AccessList.vue` (`synchronizedList`)
95
+
96
+ ## Common mistakes
97
+
98
+ - Using `synchronized` for a list of rows instead of `synchronizedList`.
99
+ - Forgetting `objectIdentifiers` when backend update/delete actions need per-row keys.
100
+ - Mutating raw `live(...)` list data instead of `synchronizedList(...).value`.
101
+ - Mixing autosave helper patterns with unrelated one-shot action form patterns.
@@ -0,0 +1,44 @@
1
+ ---
2
+ name: live-change-node-toolchain-fnm
3
+ description: Run node, npm, npx, tsx and framework CLI with fnm exec so .node-version and .nvmrc are respected
4
+ ---
5
+
6
+ # Skill: Node toolchain with fnm
7
+
8
+ Use this skill whenever you run **Node**, **npm**, **npx**, **tsx**, or **corepack** in this repo (tests, `describe`, dev servers, scripts). Agents must not use the default sandbox Node; use **fnm exec** so the version from dotfiles applies.
9
+
10
+ ## When to use
11
+
12
+ - Running `npm test`, `npm run …`, linters, builds
13
+ - `node server/start.js describe` or any framework entry
14
+ - `tsx` for TypeScript scripts
15
+ - Any subprocess that would invoke `node` or `npm` for a project with `.node-version` / `.nvmrc`
16
+
17
+ ## Step 1 – Find the right directory
18
+
19
+ Locate the nearest project root that has `.node-version` or `.nvmrc` for the task (often the app folder, e.g. `auto-firma/app/`).
20
+
21
+ `cd` to that directory before running commands so fnm reads the correct file.
22
+
23
+ ## Step 2 – Prefix with fnm exec
24
+
25
+ ```bash
26
+ fnm exec -- node server/start.js describe --service myService --output yaml
27
+ fnm exec -- npm test
28
+ fnm exec -- npx vitest
29
+ fnm exec -- tsx ./tools/something.ts
30
+ ```
31
+
32
+ The part after `--` is the same command you would run manually with the correct Node active.
33
+
34
+ ## Step 3 – Nested monorepo paths
35
+
36
+ If you are in the repo root but the dotfile is only under `some-app/`:
37
+
38
+ ```bash
39
+ cd some-app && fnm exec -- npm test
40
+ ```
41
+
42
+ ## If fnm is missing
43
+
44
+ Do not fall back to bare `node` / `npm` for framework work. Report that fnm is required (or document a one-off alternative the user approved).
package/.node-version ADDED
@@ -0,0 +1 @@
1
+ 20.19.2
package/.nvmrc ADDED
@@ -0,0 +1 @@
1
+ 20.19.2
package/README.md CHANGED
@@ -69,3 +69,21 @@ Typowy flow tworzenia nowej aplikacji:
69
69
 
70
70
  Dokładny „manual” tego flow znajdzie się w sekcji `frontend/01-getting-started.md` dokumentacji.
71
71
 
72
+ ### E2E starter (node:test + Playwright)
73
+
74
+ Szablon zawiera przykładowy zestaw E2E w katalogu `e2e/`:
75
+
76
+ - `env.ts` - lazy start `TestServer` i `disposeTestEnv()`
77
+ - `withBrowser.ts` - lifecycle przeglądarki per test
78
+ - `e2eSuite.ts` - wrapper `describe` + `after` z finalnym teardown
79
+ - `homepage.test.ts`, `client-session.test.ts` - przykładowe testy
80
+
81
+ Uruchamianie:
82
+
83
+ ```bash
84
+ fnm exec -- npm run e2e
85
+ fnm exec -- npm run e2e:headed
86
+ ```
87
+
88
+ Ważne: nie rejestruj `after(...process.exit(...))` w `e2e/env.ts`. Finalny `process.exit(0)` powinien być tylko w `e2eSuite.ts`.
89
+
@@ -0,0 +1,17 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert'
3
+ import { withBrowser } from './withBrowser.js'
4
+ import { e2eSuite } from './e2eSuite.js'
5
+
6
+ e2eSuite('client-session', () => {
7
+ test('frontend initializes api client session', async () => {
8
+ await withBrowser(async (page, env) => {
9
+ await page.goto(env.url + '/', { waitUntil: 'networkidle' })
10
+ const session = await page.evaluate(() => {
11
+ const root = window as unknown as { api?: { client?: { value?: { session?: string } } } }
12
+ return root.api?.client?.value?.session
13
+ })
14
+ assert.ok(typeof session === 'string' && session.length > 0, 'session id is present')
15
+ })
16
+ })
17
+ })
@@ -0,0 +1,12 @@
1
+ import { after, describe } from 'node:test'
2
+ import { disposeTestEnv } from './env.js'
3
+
4
+ export function e2eSuite(name: string, define: () => void): void {
5
+ describe(name, () => {
6
+ after(async () => {
7
+ await disposeTestEnv()
8
+ process.exit(0)
9
+ })
10
+ define()
11
+ })
12
+ }
package/e2e/env.ts ADDED
@@ -0,0 +1,130 @@
1
+ import path from 'path'
2
+ import { fileURLToPath } from 'url'
3
+ import { TestServer } from '@live-change/server'
4
+ import appConfig from '../server/app.config.js'
5
+ import * as services from '../server/services.list.js'
6
+
7
+ const READY_TIMEOUT_MS = 60000
8
+ const READY_POLL_MS = 2000
9
+
10
+ async function waitForServerReady(url: string): Promise<void> {
11
+ const deadline = Date.now() + READY_TIMEOUT_MS
12
+ while (Date.now() < deadline) {
13
+ try {
14
+ const res = await fetch(url)
15
+ if (res.ok) return
16
+ } catch {
17
+ // not ready yet
18
+ }
19
+ await new Promise((r) => setTimeout(r, READY_POLL_MS))
20
+ }
21
+ throw new Error(`Server at ${url} did not become ready within ${READY_TIMEOUT_MS}ms`)
22
+ }
23
+
24
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
25
+ const serverDir = path.join(__dirname, '..', 'server')
26
+ const frontDir = path.join(__dirname, '..', 'front')
27
+
28
+ for (const serviceConfig of appConfig.services) {
29
+ const name = (serviceConfig as { name: string }).name
30
+ ;(serviceConfig as { module?: unknown }).module = (services as Record<string, unknown>)[name]
31
+ }
32
+ ;(appConfig as { init?: (s: unknown) => Promise<void> }).init = (services as { init: (s: unknown) => Promise<void> }).init
33
+
34
+ export type TestEnv = {
35
+ server: InstanceType<typeof TestServer>
36
+ url: string
37
+ haveService: (name: string) => { name: string; models: Record<string, { get: (id: string) => Promise<unknown> }>; views: Record<string, unknown>; actions: Record<string, unknown>; triggers: Record<string, unknown> }
38
+ haveModel: (serviceName: string, modelName: string) => { get: (id: string) => Promise<unknown>; create: (data: unknown) => Promise<unknown>; update: (id: string, data: unknown) => Promise<unknown>; delete: (id: string) => Promise<unknown>; indexObjectGet: (index: string, key: unknown, opts?: unknown) => Promise<unknown>; indexRangeGet: (index: string, key: unknown) => Promise<unknown[]>; definition: { properties: Record<string, { preFilter: (v: unknown) => unknown }> } }
39
+ haveView: (serviceName: string, viewName: string) => unknown
40
+ haveAction: (serviceName: string, actionName: string) => unknown
41
+ haveTrigger: (serviceName: string, triggerName: string) => unknown
42
+ grabObject: (serviceName: string, modelName: string, id: string) => Promise<unknown>
43
+ }
44
+
45
+ type TestServerInstance = InstanceType<typeof TestServer>
46
+
47
+ let envPromise: Promise<TestEnv> | null = null
48
+ let testServer: TestServerInstance | null = null
49
+
50
+ export async function disposeTestEnv(): Promise<void> {
51
+ const s = testServer
52
+ testServer = null
53
+ envPromise = null
54
+ if (s) await s.dispose()
55
+ }
56
+
57
+ function haveService(server: TestServerInstance, name: string) {
58
+ const service = server.apiServer.services.services.find((s: { name: string }) => s.name === name)
59
+ if (!service) throw new Error('service ' + name + ' not found')
60
+ return service
61
+ }
62
+
63
+ function haveModel(server: TestServerInstance, serviceName: string, modelName: string) {
64
+ const service = haveService(server, serviceName)
65
+ const model = service.models[modelName]
66
+ if (!model) throw new Error('model ' + modelName + ' not found')
67
+ return model
68
+ }
69
+
70
+ export async function getTestEnv(): Promise<TestEnv> {
71
+ if (envPromise) return envPromise
72
+ envPromise = (async () => {
73
+ const server = new TestServer({
74
+ dev: true,
75
+ enableSessions: true,
76
+ port: 0,
77
+ ssrRoot: frontDir,
78
+ initScript: path.join(serverDir, 'init.js'),
79
+ services: appConfig
80
+ })
81
+ console.log('starting test server')
82
+ try {
83
+ await server.start()
84
+ } catch (error) {
85
+ console.error((error as Error).stack)
86
+ process.exit(1)
87
+ }
88
+ console.log('Test server started at', server.url)
89
+ console.log('waiting for front to be ready...')
90
+ await waitForServerReady(server.url!)
91
+ console.log('front ready')
92
+
93
+ testServer = server
94
+
95
+ process.on('beforeExit', () => {
96
+ void disposeTestEnv()
97
+ })
98
+
99
+ const url = server.url!
100
+ return {
101
+ server,
102
+ url,
103
+ haveService: (name: string) => haveService(server, name),
104
+ haveModel: (serviceName: string, modelName: string) => haveModel(server, serviceName, modelName),
105
+ haveView: (serviceName: string, viewName: string) => {
106
+ const service = haveService(server, serviceName)
107
+ const view = service.views[viewName]
108
+ if (!view) throw new Error('view ' + viewName + ' not found')
109
+ return view
110
+ },
111
+ haveAction: (serviceName: string, actionName: string) => {
112
+ const service = haveService(server, serviceName)
113
+ const action = service.actions[actionName]
114
+ if (!action) throw new Error('action ' + actionName + ' not found')
115
+ return action
116
+ },
117
+ haveTrigger: (serviceName: string, triggerName: string) => {
118
+ const service = haveService(server, serviceName)
119
+ const trigger = service.triggers[triggerName]
120
+ if (!trigger) throw new Error('trigger ' + triggerName + ' not found')
121
+ return trigger
122
+ },
123
+ grabObject: async (serviceName: string, modelName: string, id: string) => {
124
+ const model = haveModel(server, serviceName, modelName)
125
+ return await model.get(id)
126
+ }
127
+ }
128
+ })()
129
+ return envPromise
130
+ }