@live-change/frontend-template 0.9.203 → 0.9.205

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 (30) hide show
  1. package/.claude/rules/live-change-backend-actions-views-triggers.md +49 -1
  2. package/.claude/rules/live-change-backend-models-and-relations.md +24 -6
  3. package/.claude/rules/live-change-service-structure.md +2 -2
  4. package/.claude/settings.json +3 -1
  5. package/.claude/skills/live-change-backend-change-triggers/SKILL.md +15 -0
  6. package/.claude/skills/live-change-dao-protocol/SKILL.md +46 -0
  7. package/.claude/skills/live-change-design-actions-views-triggers/SKILL.md +110 -0
  8. package/.claude/skills/live-change-design-models-relations/SKILL.md +63 -5
  9. package/.claude/skills/live-change-frontend-data-views/SKILL.md +73 -4
  10. package/.claude/skills/live-change-frontend-range-list/SKILL.md +90 -0
  11. package/.claude/skills/live-change-frontend-synchronized/SKILL.md +101 -0
  12. package/.cursor/rules/live-change-backend-actions-views-triggers.mdc +23 -4
  13. package/.cursor/rules/live-change-backend-architecture.mdc +1 -1
  14. package/.cursor/rules/live-change-backend-event-sourcing.mdc +1 -1
  15. package/.cursor/rules/live-change-backend-models-and-relations.mdc +36 -7
  16. package/.cursor/rules/live-change-backend-views-vs-triggers-for-reads-writes.mdc +28 -0
  17. package/.cursor/rules/live-change-dao-protocol.mdc +47 -0
  18. package/.cursor/rules/live-change-frontend-views-not-commands-for-reads.mdc +30 -0
  19. package/.cursor/rules/live-change-frontend-vue-primevue.mdc +70 -4
  20. package/.cursor/rules/live-change-service-structure.mdc +1 -1
  21. package/.cursor/skills/live-change-backend-change-triggers.md +15 -0
  22. package/.cursor/skills/live-change-design-actions-views-triggers.md +51 -0
  23. package/.cursor/skills/live-change-design-models-relations.md +23 -5
  24. package/.cursor/skills/live-change-frontend-data-views.md +15 -0
  25. package/.cursor/skills/live-change-frontend-range-list.md +21 -0
  26. package/.cursor/skills/live-change-frontend-synchronized.md +101 -0
  27. package/.node-version +1 -1
  28. package/.nvmrc +1 -1
  29. package/front/src/pages/index.vue +1 -1
  30. package/package.json +55 -55
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  description: Rules for implementing actions, views, and triggers in LiveChange services
3
- globs: **/services/**/*.js
3
+ globs: **/services/**/*.js, **/server/**/*.js, server/**/*.js
4
4
  ---
5
5
 
6
6
  # LiveChange backend – actions, views, triggers (Claude Code)
@@ -52,6 +52,48 @@ definition.action({
52
52
  - Views should be simple query endpoints over models.
53
53
  - Prefer `indexObjectGet` / `indexRangeGet` instead of scanning whole tables.
54
54
 
55
+ ### Range view guardrails (for RangeViewer/rangeBuckets consumers)
56
+
57
+ - For index-backed paginated lists, prefer `Model.sortedIndexRangePath(indexName, keyPrefix, App.extractRange(props))`.
58
+ - Do not use `indexRangePath` semantics for views consumed by bucket-based range UI.
59
+ - Keep `gt/gte/lt/lte` as cursor pagination fields, not domain filter fields.
60
+ - If you need filtering by month/year/status, design index prefix for it first.
61
+ - Use `App.utils.prefixRange` as backend fallback only when index redesign is not feasible.
62
+
63
+ ### Standalone index guardrail
64
+
65
+ - If an index combines peer data streams (union of multiple tables), define it as service-level `definition.index(...)` (prefer separate `indexes.js`), not as `model.indexes` inside one arbitrary model.
66
+ - Use model `indexes` only when one model is the clear owner of index semantics.
67
+
68
+ ### Index function serialization constraint
69
+
70
+ Index functions (`definition.index({ function })` and model-level `indexes: { name: { function } }`) are **serialized via `toString()`** and executed on a remote server. They **cannot reference anything outside their own function body** — no outer variables, no imported functions, no module-scope helpers.
71
+
72
+ ```js
73
+ // ❌ BROKEN — helper is outside the function, undefined at runtime
74
+ function mapRow(obj) { return { id: obj.name + '_' + obj.id, to: obj.id } }
75
+
76
+ definition.index({
77
+ name: 'myIndex',
78
+ function: async (input, output, { tableName }) => {
79
+ const table = await input.table(tableName)
80
+ await table.map(mapRow).to(output) // mapRow is undefined!
81
+ },
82
+ parameters: { tableName: definition.name + '_MyModel' }
83
+ })
84
+
85
+ // ✅ CORRECT — helper is inside the function body
86
+ definition.index({
87
+ name: 'myIndex',
88
+ function: async (input, output, { tableName }) => {
89
+ const mapRow = obj => ({ id: obj.name + '_' + obj.id, to: obj.id })
90
+ const table = await input.table(tableName)
91
+ await table.map(mapRow).to(output)
92
+ },
93
+ parameters: { tableName: definition.name + '_MyModel' }
94
+ })
95
+ ```
96
+
55
97
  Example of a range view:
56
98
 
57
99
  ```js
@@ -159,6 +201,12 @@ definition.trigger({
159
201
 
160
202
  Check `data`/`oldData`: both present = update, only `data` = create, only `oldData` = delete.
161
203
 
204
+ ## Cron-service — schedules, intervals, and admin UI
205
+
206
+ - For **cron-like** or **repeating-interval** execution of a **trigger**, use **`@live-change/cron-service`** (**Schedule** / **Interval**) plus **task-service** triggers — do not sketch “only a timer” without considering cron models and **`changeCron_Schedule`** / **`changeCron_Interval`** timer lifecycle.
207
+ - Reference admin flow (see **task-frontend**): **`setSchedule`** / **`setInterval`** via **`ActionForm`**, lists via **`path.cron.schedules`** / **`path.cron.intervals`**, enrich rows with **`.with()`** for **`scheduleInfo`** / **`intervalInfo`**, **`runState`** (`jobType` **`cron_Schedule`** or **`cron_Interval`**), and **`task.tasksByCauseAndCreatedAt`**; delete with **`deleteSchedule`** / **`deleteInterval`**.
208
+ - **Schedule** time fields (**minute**, **hour**, **day**, **dayOfWeek**, **month**): use **`NaN`** for “every” at that granularity; see **`15-cron-and-intervals.md`** (section **API used by task-frontend**).
209
+
162
210
  ## Granting access on object creation
163
211
 
164
212
  When a model uses `entity` with `writeAccessControl` / `readAccessControl`, the auto-generated CRUD checks roles but does **not** grant them automatically. The creator must be explicitly granted roles — typically `'owner'` — otherwise they cannot access their own object.
@@ -32,6 +32,25 @@ properties: {
32
32
  }
33
33
  ```
34
34
 
35
+ ## Relation arity (critical)
36
+
37
+ Always separate:
38
+
39
+ - **annotation arity** — can the annotation be a list of configs
40
+ - **parent tuple arity** — can one config include multiple parents/dimensions
41
+
42
+ | Relation | Annotation arity | Parent tuple arity |
43
+ |---|---|---|
44
+ | `propertyOf`, `itemOf`, `boundTo` | single config only | `what` can be one model or `[A, B, ...]` |
45
+ | `relatedTo` | single config or config list | each config uses `what` with one model or `[A, B, ...]` |
46
+ | `propertyOfAny`, `itemOfAny`, `boundToAny` | single config only | `to` can contain one or many names |
47
+ | `relatedToAny` | single config or config list | each config uses `to` with one or many names |
48
+
49
+ Guardrail:
50
+
51
+ - valid: `propertyOf: { what: [A, B] }`
52
+ - invalid: `propertyOf: [configA, configB]`
53
+
35
54
  ## `userItem` – belongs to the signed-in user
36
55
 
37
56
  Use when the model is owned by the currently signed-in user.
@@ -113,7 +132,7 @@ Effects:
113
132
  ## `propertyOf` with multiple parents (1:1 link to each)
114
133
 
115
134
  Sometimes a model is a dedicated 1:1 link between entities (for example: invoice ↔ contractor in a specific role).
116
- Most commonly this is 1–2 parents, but `propertyOf` can point to **any number** of parent models (including 3+), if that matches the domain semantics.
135
+ Most commonly this is 1–2 parents, but `what` can point to **any number** of parent models (including 3+), if that matches the domain semantics.
117
136
 
118
137
  In that case:
119
138
 
@@ -132,10 +151,9 @@ definition.model({
132
151
  properties: {
133
152
  // optional extra fields
134
153
  },
135
- propertyOf: [
136
- { what: CostInvoice },
137
- { what: Contractor }
138
- ]
154
+ propertyOf: {
155
+ what: [CostInvoice, Contractor]
156
+ }
139
157
  })
140
158
  ```
141
159
 
@@ -180,7 +198,7 @@ Relations automatically add **identifier fields** and **indexes** to the model.
180
198
  | `propertyOfAny: { to: ['owner'] }` | `ownerType`, `owner` | `byOwner` (hash) |
181
199
  | `boundTo: { what: Device }` | `device` | `byDevice` (hash) |
182
200
 
183
- For multi-parent relations (e.g. `propertyOf: [{ what: A }, { what: B }]`), all index combinations are created (`byA`, `byB`, `byAAndB`).
201
+ For multi-parent relations (e.g. `propertyOf: { what: [A, B] }`), all index combinations are created (`byA`, `byB`, `byAAndB`).
184
202
 
185
203
  ```js
186
204
  // ✅ Correct — only define YOUR fields
@@ -1,13 +1,13 @@
1
1
  ---
2
2
  description: Rules for LiveChange service directory structure and file organization
3
- globs: **/services/**/*.js
3
+ globs: **/services/**/*.js, **/server/**/*.js, server/**/*.js
4
4
  ---
5
5
 
6
6
  # LiveChange Service Structure
7
7
 
8
8
  Every LiveChange service **must** be a directory, not a single file.
9
9
 
10
- ## Required structure
10
+ ## Required structureW
11
11
 
12
12
  ```
13
13
  server/services/<serviceName>/
@@ -22,7 +22,9 @@
22
22
  "Bash(cp /home/m8/IdeaProjects/live-change/.claude/rules/live-change-backend-event-sourcing.md /home/m8/IdeaProjects/live-change/automation/.claude/rules/live-change-backend-event-sourcing.md)",
23
23
  "Bash(find /home/m8/IdeaProjects/live-change/live-change-stack/framework -type f \\\\\\(-name *.ts -o -name *.js \\\\\\))",
24
24
  "Bash(find /home/m8/IdeaProjects/live-change/live-change-stack/services -type f -name *.js)",
25
- "Bash(grep -r \"userItem\\\\|userProperty\" /home/m8/IdeaProjects/live-change/live-change-stack/services/user-service --include=*.js)"
25
+ "Bash(grep -r \"userItem\\\\|userProperty\" /home/m8/IdeaProjects/live-change/live-change-stack/services/user-service --include=*.js)",
26
+ "Bash(find /home/m8/IdeaProjects/live-change/auto-firma/app -maxdepth 2 -type f \\\\\\(-name jest.config.* -o -name vitest.config.* -o -name *.test.js -o -name *.test.ts \\\\\\))",
27
+ "Bash(find /home/m8/IdeaProjects/live-change/auto-firma/app -maxdepth 3 -type d -not -path */node_modules* -not -path */dist* -not -path */backups* -not -path */dev_docker_home* -not -path */storage* -not -path */tmp.db*)"
26
28
  ],
27
29
  "additionalDirectories": [
28
30
  "/home/m8/IdeaProjects/live-change/.claude/skills/create-skills-and-rules",
@@ -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:
@@ -0,0 +1,46 @@
1
+ ---
2
+ description: Jak poprawnie wywoływać akcje i widoki frameworka LiveChange przez surowy protokół DAO.
3
+ ---
4
+
5
+ # LiveChange DAO Protocol Arguments
6
+
7
+ Kiedy komunikujesz się z frameworkiem LiveChange używając surowego protokołu `@live-change/dao` (np. z C++, Pythona, Rusta, Go lub dowolnego innego klienta nie-JS), MUSISZ ZAWSZE przekazywać argumenty jako **tablicę**.
8
+
9
+ Framework traktuje argumenty żądań (request) i obserwabli (observable) DAO jak argumenty funkcji i używa operatora spread (`...args`), aby przekazać je do bazowych funkcji akcji lub widoków. Jeśli przekażesz obiekt zamiast tablicy, serwer rzuci błąd `TypeError: Spread syntax requires ...iterable[Symbol.iterator] to be a function`.
10
+
11
+ Nawet jeśli akcja lub widok oczekuje pojedynczego obiektu jako parametru, ten obiekt MUSI być opakowany w jednoelementową tablicę.
12
+
13
+ ## Przykład w C++ (używając nlohmann/json)
14
+
15
+ ### Niepoprawnie ❌
16
+ ```cpp
17
+ nlohmann::json args = {
18
+ {"pairingKey", "123"},
19
+ {"connectionType", "device"}
20
+ };
21
+ connection->request({"serviceName", "actionName"}, args, settings);
22
+ ```
23
+
24
+ ### Poprawnie ✅
25
+ ```cpp
26
+ // Wrap the object in an array
27
+ auto args = {
28
+ nlohmann::json::object({
29
+ {"pairingKey", "123"},
30
+ {"connectionType", "device"}
31
+ })
32
+ };
33
+ connection->request({"serviceName", "actionName"}, args, settings);
34
+ ```
35
+
36
+ Lub jawnie:
37
+ ```cpp
38
+ nlohmann::json args = nlohmann::json::array({
39
+ nlohmann::json::object({
40
+ {"pairingKey", "123"},
41
+ {"connectionType", "device"}
42
+ })
43
+ });
44
+ ```
45
+
46
+ Zawsze upewnij się, że Twój payload `args` jest tablicą przed wysłaniem go przez połączenie DAO.
@@ -79,6 +79,116 @@ definition.action({
79
79
  - use model paths (`Model.path`, `Model.rangePath`, `Model.sortedIndexRangePath`, `Model.indexObjectPath`)
80
80
  - use `...App.rangeProperties` + `App.extractRange(props)` for range views
81
81
 
82
+ ### Step 2a – Prefix-aware range filtering
83
+
84
+ When you query an index with `Model.sortedIndexRangePath(indexName, keyPrefix, range)`, remember:
85
+
86
+ - `keyPrefix` is matched first (for example `[bankAccount]` or `[bankAccount, month]`).
87
+ - `range.gt/gte/lt/lte` is applied to full serialized index keys, not to a single field.
88
+ - If you need optional narrowing, pass a dedicated filter parameter (`month`, `state`, etc.) and keep range for cursor pagination.
89
+
90
+ ```js
91
+ definition.view({
92
+ name: 'bankTransactionsByBankAccountAndDate',
93
+ properties: {
94
+ bankAccount: { type: String },
95
+ month: { type: String },
96
+ ...App.rangeProperties
97
+ },
98
+ returns: { type: Array, of: { type: Object } },
99
+ async daoPath({ bankAccount, month, ...props }) {
100
+ const range = App.extractRange(props)
101
+ if(month) {
102
+ const prefix = [bankAccount, month].map(v => JSON.stringify(v)).join(':')
103
+ return BankTransaction.rangePath(App.utils.prefixRange(range, prefix, prefix + ':'))
104
+ }
105
+ return BankTransaction.sortedIndexRangePath('byBankAccountAndDate', [bankAccount], range)
106
+ }
107
+ })
108
+ ```
109
+
110
+ If filtering by month is a frequent query, prefer a dedicated index like `byBankAccountAndMonthAndDate` and query it with:
111
+
112
+ ```js
113
+ BankTransaction.sortedIndexRangePath('byBankAccountAndMonthAndDate', [bankAccount, month], range)
114
+ ```
115
+
116
+ For that index, prefer this function-index style:
117
+
118
+ ```js
119
+ function: async (input, output, { tableName }) => {
120
+ const table = await input.table(tableName)
121
+ const mapper = obj => ({
122
+ id: [obj.bankAccount, obj.date?.slice(0, 7), obj.date]
123
+ .map(v => JSON.stringify(v)).join(':') + '_' + obj.id,
124
+ to: obj.id
125
+ })
126
+ await table.map(mapper).to(output)
127
+ }
128
+ ```
129
+
130
+ `map()` automatically filters out `null`, so you can keep mapper logic concise.
131
+
132
+ ### Step 2b – RangeViewer/rangeBuckets compatibility
133
+
134
+ When a view is consumed by `RangeViewer` or `rangeBuckets`:
135
+
136
+ - prefer `Model.sortedIndexRangePath(...)` for index-backed list views,
137
+ - keep `App.extractRange(props)` as pagination cursor input,
138
+ - do not reinterpret `gt/gte/lt/lte` as domain filters.
139
+
140
+ Anti-patterns:
141
+
142
+ - using `indexRangePath` for frontend bucket pagination flow,
143
+ - injecting custom month/year bounds into cursor fields in frontend,
144
+ - rewriting cursor values in backend with unrelated filter semantics.
145
+
146
+ Preferred filtering strategy:
147
+
148
+ 1. design index prefix for frequent filters,
149
+ 2. use `App.utils.prefixRange` only as backend fallback,
150
+ 3. keep string min/max hacks as last resort.
151
+
152
+ ### Step 2c – Standalone indexes for union/equal sources
153
+
154
+ When index rows are built from multiple equal tables (union-like flow), do not force the index into one model definition.
155
+
156
+ Use `definition.index(...)` at service level (typically `indexes.js`) when:
157
+
158
+ - index combines rows from two or more source tables,
159
+ - source tables are peer entities (no natural single owner model),
160
+ - index is a projection layer for cross-table reads.
161
+
162
+ > **IMPORTANT — serialization constraint:** Index functions are serialized via `toString()` and executed remotely. All helpers, mappers, and variables **must be defined inside the function body**. References to outer scope (module-level functions, imports) will be `undefined` at runtime.
163
+
164
+ Example:
165
+
166
+ ```js
167
+ definition.index({
168
+ name: 'Urls',
169
+ function: async (input, output) => {
170
+ const mapRedirect = obj => obj && ({
171
+ id: /* composed key */, to: obj.target
172
+ })
173
+ const mapCanonical = obj => obj && ({
174
+ id: /* composed key */, to: obj.target
175
+ })
176
+
177
+ await input.table('url_Redirect').onChange((obj, oldObj) =>
178
+ output.change(mapRedirect(obj), mapRedirect(oldObj))
179
+ )
180
+ await input.table('url_Canonical').onChange((obj, oldObj) =>
181
+ output.change(mapCanonical(obj), mapCanonical(oldObj))
182
+ )
183
+ }
184
+ })
185
+ ```
186
+
187
+ Decision rule:
188
+
189
+ - model-local index -> `definition.model({ indexes: ... })`,
190
+ - union/peer-source index -> standalone `definition.index(...)` in `indexes.js`.
191
+
82
192
  ### Example: `daoPath` (preferred, DAO-backed)
83
193
 
84
194
  ```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
 
@@ -168,6 +186,46 @@ indexes: {
168
186
 
169
187
  3. Use these indexes in views/actions, via `indexObjectGet` / `indexRangeGet`.
170
188
 
189
+ ### Step 5b – Use `function` indexes for derived keys
190
+
191
+ Use a `function` index when key parts are not stored directly as properties (for example `yearMonth` derived from `date`).
192
+
193
+ Key rules:
194
+
195
+ - Keep index entries stable and deterministic.
196
+ - Build composite keys as serialized parts joined with `:` and append `_' + id`.
197
+ - Emit `{ id, to }` objects so `to` points to the source model id.
198
+ - Prefer `table.map(mapper).to(output)` over manual `onChange(...output.change...)`.
199
+ - `map()` drops `null` results automatically, so mapper can stay clean.
200
+
201
+ Example:
202
+
203
+ ```js
204
+ indexes: {
205
+ byBankAccountAndMonthAndDate: {
206
+ function: async (input, output, { tableName }) => {
207
+ const table = await input.table(tableName)
208
+ const mapper = obj => ({
209
+ id: [
210
+ obj.bankAccount,
211
+ obj.date?.slice(0, 7), // YYYY-MM month bucket
212
+ obj.date
213
+ ].map(v => JSON.stringify(v)).join(':') + '_' + obj.id,
214
+ to: obj.id
215
+ })
216
+ await table.map(mapper).to(output)
217
+ },
218
+ parameters: {
219
+ tableName: definition.name + '_BankTransaction'
220
+ }
221
+ }
222
+ }
223
+ ```
224
+
225
+ This format matches how property indexes are serialized internally and works well with range-prefix filtering in views.
226
+
227
+ > **IMPORTANT — serialization constraint:** Function indexes are serialized via `toString()` and executed remotely. The mapper and all helpers **must be defined inside the function body** — not in module scope. References to outer variables or imports will be `undefined` at runtime.
228
+
171
229
  ## Step 6 – Set access control on relations
172
230
 
173
231
  1. For `userItem`, `itemOf`, and `propertyOf`, always define:
@@ -38,6 +38,21 @@ const [article, comments] = await Promise.all([
38
38
  ])
39
39
  ```
40
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
+
41
56
  In templates access `.value`:
42
57
 
43
58
  ```vue
@@ -47,7 +62,26 @@ In templates access `.value`:
47
62
  </div>
48
63
  ```
49
64
 
50
- ## Step 2 – Load related objects with `.with()`
65
+ ## Step 2 – One-time fetches with `useFetch`
66
+
67
+ When you need data once (e.g. after an upload, in an event handler), use `useFetch` instead of `live`:
68
+
69
+ ```javascript
70
+ import { usePath, useFetch } from '@live-change/vue3-ssr'
71
+
72
+ const path = usePath()
73
+ const data = await useFetch(path.paperInvoice.invoiceFileInfo({ invoiceFile: fileId }))
74
+ ```
75
+
76
+ **Do NOT use `api.get()` with Path objects.** `path.service.view()` returns a Path object (with `.what`, `.more`, `.to` properties), not a raw array. `api.get()` only accepts raw arrays like `['service', 'view', { params }]`.
77
+
78
+ | Method | Input | Returns | Use when |
79
+ |---|---|---|---|
80
+ | `live(path)` | Path or array | Reactive Ref | Live-updating data |
81
+ | `useFetch(path)` | Path or array | Promise | One-time fetch |
82
+ | `api.get([...])` | Raw array only | Promise | Low-level, avoid in app code |
83
+
84
+ ## Step 3 – Load related objects with `.with()`
51
85
 
52
86
  Chain `.with()` to attach related data to each item:
53
87
 
@@ -64,6 +98,40 @@ const articlesPath = computed(() =>
64
98
  const [articles] = await Promise.all([live(articlesPath)])
65
99
  ```
66
100
 
101
+ ### `.with()` callback guardrails
102
+
103
+ Treat `.with(item => ...)` as a declarative Path DSL builder:
104
+
105
+ - the callback receives a proxy, not a hydrated runtime record
106
+ - do not place side effects or command calls inside `.with(...)`
107
+ - do not use imperative branching like `if(item.type === '...')` in the callback
108
+
109
+ Use `$switch` for conditional path branching:
110
+
111
+ ```javascript
112
+ const settlementsPath = computed(() =>
113
+ path.accounting.settlementsByTransaction({
114
+ transactionType: 'bankAccount_BankTransaction',
115
+ transaction: transactionId,
116
+ range: { limit: 256 }
117
+ }).with(settlement => settlement.subjectType.$switch({
118
+ invoice_CostInvoice: path.invoice.costInvoice({ costInvoice: settlement.subject }),
119
+ invoice_IncomeInvoice: path.invoice.incomeInvoice({ incomeInvoice: settlement.subject }),
120
+ hr_CivilContract: path.hr.civilContract({ civilContract: settlement.subject })
121
+ }).$bind('subjectDoc'))
122
+ )
123
+ ```
124
+
125
+ Production-style pattern reference:
126
+
127
+ ```javascript
128
+ p.url.urlsByTargetAndPath({ targetType, domain, path: urlPath })
129
+ .with(url => url.type.$switch({
130
+ canonical: null,
131
+ redirect: p.url.canonical({ targetType, target: url.target })
132
+ }).$bind('canonical'))
133
+ ```
134
+
67
135
  Access in template:
68
136
 
69
137
  ```vue
@@ -88,7 +156,7 @@ const eventPath = computed(() =>
88
156
  )
89
157
  ```
90
158
 
91
- ## Step 3 – Conditional loading with `useClient`
159
+ ## Step 4 – Conditional loading with `useClient`
92
160
 
93
161
  Use `useClient()` to check authentication state and conditionally build paths:
94
162
 
@@ -134,7 +202,7 @@ When the path is `null` / `false` / `undefined`, `live()` returns a ref with `nu
134
202
  </template>
135
203
  ```
136
204
 
137
- ## Step 4 – Dependent paths
205
+ ## Step 5 – Dependent paths
138
206
 
139
207
  When one path depends on data from another, load them sequentially:
140
208
 
@@ -154,7 +222,7 @@ const [author] = await Promise.all([live(authorPath)])
154
222
 
155
223
  Or use `.with()` to combine them in a single query (preferred when possible).
156
224
 
157
- ## Step 5 – Props-based paths in components
225
+ ## Step 6 – Props-based paths in components
158
226
 
159
227
  When building reusable components that receive IDs as props:
160
228
 
@@ -179,6 +247,7 @@ When building reusable components that receive IDs as props:
179
247
  | Pattern | When to use |
180
248
  |---|---|
181
249
  | `computed(() => path.xxx(...))` | Path depends on reactive values |
250
+ | `useFetch(path.xxx(...))` | One-time data fetch (not reactive) |
182
251
  | `.with(item => path.yyy(...).bind('field'))` | Attach related objects |
183
252
  | `client.value.user && path.xxx(...)` | Load only when authenticated |
184
253
  | `client.value.roles.includes('admin')` | Load/show only for specific roles |
@@ -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:
@@ -47,6 +60,22 @@ Each `.with()` call:
47
60
  - builds a path to the related data,
48
61
  - calls `.bind('fieldName')` to attach the result under that field name.
49
62
 
63
+ Important:
64
+ - this proxy is Path DSL input, not a hydrated runtime item
65
+ - do not branch with imperative `if/else` on proxy fields inside `.with(...)`
66
+ - for type-based conditional branches, use `$switch(...).$bind(...)`
67
+
68
+ ```javascript
69
+ function settlementsPathRange(range) {
70
+ return path.accounting.settlementsByTransaction({ ...range })
71
+ .with(settlement => settlement.subjectType.$switch({
72
+ invoice_CostInvoice: path.invoice.costInvoice({ costInvoice: settlement.subject }),
73
+ invoice_IncomeInvoice: path.invoice.incomeInvoice({ incomeInvoice: settlement.subject }),
74
+ hr_CivilContract: path.hr.civilContract({ civilContract: settlement.subject })
75
+ }).$bind('subjectDoc'))
76
+ }
77
+ ```
78
+
50
79
  Nested `.with()` is also supported:
51
80
 
52
81
  ```javascript
@@ -127,3 +156,64 @@ Iterate in the template:
127
156
  </div>
128
157
  </template>
129
158
  ```
159
+
160
+ ## Step 5 – Optional filters without breaking range cursor
161
+
162
+ When your list supports optional filtering (for example `month`), do not push raw field values into `gt/gte/lt/lte` from the frontend.
163
+
164
+ Why:
165
+
166
+ - Range boundaries are compared against full index keys, not a single field.
167
+ - `RangeViewer` controls `gt/lt` for pagination; overriding them breaks infinite scroll behavior.
168
+
169
+ Correct pattern:
170
+
171
+ 1. Keep RangeViewer cursor in `range` (`...reverseRange(range)`).
172
+ 2. Send optional filters as separate params (`month`, `state`, etc.).
173
+ 3. Let backend view apply prefix logic (`sortedIndexRangePath` with longer key prefix or `App.utils.prefixRange`).
174
+
175
+ ```js
176
+ function transactionsPathRange(range) {
177
+ return path.bankAccount.bankTransactionsByBankAccountAndDate({
178
+ bankAccount: accountId,
179
+ month: month.value || undefined,
180
+ ...reverseRange(range)
181
+ })
182
+ }
183
+ ```
184
+
185
+ ## Step 6 – Reactive filter changes (no hidden bucket bugs)
186
+
187
+ If `pathFunction` depends on reactive filters (for example month/company/status), prefer `ReactiveRangeViewer` over mutating `RangeViewer` input directly.
188
+
189
+ Why:
190
+
191
+ - changing filters can recreate bucket state in subtle ways
192
+ - forcing rerender with ad-hoc `:key` works, but spreads fragile logic across pages
193
+ - `ReactiveRangeViewer` centralizes safe reload behavior
194
+
195
+ ```vue
196
+ <ReactiveRangeViewer
197
+ :pathFunction="transactionsPathRange"
198
+ :sourceKey="JSON.stringify({ month: filterByMonth ? month : null, accountId })"
199
+ :preserveHeightOnReload="true"
200
+ :canLoadTop="false"
201
+ canDropBottom
202
+ loadBottomSensorSize="3000px"
203
+ dropBottomSensorSize="8000px"
204
+ >
205
+ <template #default="{ item }">
206
+ <BankTransactionListItem :transaction="item" />
207
+ </template>
208
+ </ReactiveRangeViewer>
209
+ ```
210
+
211
+ Use `sourceKey` as the explicit reload trigger when filter inputs change.
212
+
213
+ ## Checklist – range pagination safety
214
+
215
+ - [ ] backend index view is based on `sortedIndexRangePath`
216
+ - [ ] frontend `pathFunction` forwards `range` unchanged (`...range` or `...reverseRange(range)`)
217
+ - [ ] domain filters (`month`, `year`, `status`) are separate view params
218
+ - [ ] no manual cursor overrides (`gt/gte/lt/lte`) in frontend code
219
+ - [ ] if narrowing is needed, backend uses index prefix design first, `prefixRange` only as fallback