@fieldia/core 0.21.0 → 0.22.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 (127) hide show
  1. package/README.md +116 -1
  2. package/package.json +1 -1
  3. package/schema/page.schema.json +149 -1
  4. package/src/index.d.mts +2 -2
  5. package/src/index.d.ts +2 -2
  6. package/src/index.js +3 -1
  7. package/src/index.js.map +1 -1
  8. package/src/index.mjs +2 -2
  9. package/src/index.mjs.map +1 -1
  10. package/src/lib/format/check-page.d.mts +3 -2
  11. package/src/lib/format/check-page.d.ts +3 -2
  12. package/src/lib/format/check-page.js +9 -4
  13. package/src/lib/format/check-page.js.map +1 -1
  14. package/src/lib/format/check-page.mjs +10 -5
  15. package/src/lib/format/check-page.mjs.map +1 -1
  16. package/src/lib/format/field.d.mts +85 -0
  17. package/src/lib/format/field.d.ts +85 -0
  18. package/src/lib/format/field.js +14 -1
  19. package/src/lib/format/field.js.map +1 -1
  20. package/src/lib/format/field.mjs +13 -0
  21. package/src/lib/format/field.mjs.map +1 -1
  22. package/src/lib/format/layout.d.mts +142 -17
  23. package/src/lib/format/layout.d.ts +142 -17
  24. package/src/lib/format/layout.js +18 -1
  25. package/src/lib/format/layout.js.map +1 -1
  26. package/src/lib/format/layout.mjs +17 -0
  27. package/src/lib/format/layout.mjs.map +1 -1
  28. package/src/lib/format/layout.types.d.mts +6 -2
  29. package/src/lib/format/layout.types.d.ts +6 -2
  30. package/src/lib/format/page.d.mts +69 -10
  31. package/src/lib/format/page.d.ts +69 -10
  32. package/src/lib/format/page.js +4 -1
  33. package/src/lib/format/page.js.map +1 -1
  34. package/src/lib/format/page.mjs +5 -2
  35. package/src/lib/format/page.mjs.map +1 -1
  36. package/src/lib/format/references.d.mts +7 -0
  37. package/src/lib/format/references.d.ts +7 -0
  38. package/src/lib/format/references.js +46 -0
  39. package/src/lib/format/references.js.map +1 -1
  40. package/src/lib/format/references.mjs +46 -0
  41. package/src/lib/format/references.mjs.map +1 -1
  42. package/src/lib/format/translate.js +3 -2
  43. package/src/lib/format/translate.js.map +1 -1
  44. package/src/lib/format/translate.mjs +3 -2
  45. package/src/lib/format/translate.mjs.map +1 -1
  46. package/src/lib/format/upgrade.d.mts +41 -0
  47. package/src/lib/format/upgrade.d.ts +41 -0
  48. package/src/lib/format/upgrade.js +41 -0
  49. package/src/lib/format/upgrade.js.map +1 -0
  50. package/src/lib/format/upgrade.mjs +37 -0
  51. package/src/lib/format/upgrade.mjs.map +1 -0
  52. package/src/lib/format/validate.d.mts +2 -1
  53. package/src/lib/format/validate.d.ts +2 -1
  54. package/src/lib/format/validate.js +4 -2
  55. package/src/lib/format/validate.js.map +1 -1
  56. package/src/lib/format/validate.mjs +4 -2
  57. package/src/lib/format/validate.mjs.map +1 -1
  58. package/src/lib/format/version.d.mts +16 -1
  59. package/src/lib/format/version.d.ts +16 -1
  60. package/src/lib/format/version.js +39 -2
  61. package/src/lib/format/version.js.map +1 -1
  62. package/src/lib/format/version.mjs +36 -1
  63. package/src/lib/format/version.mjs.map +1 -1
  64. package/src/lib/record/data-source.d.mts +224 -10
  65. package/src/lib/record/data-source.d.ts +224 -10
  66. package/src/lib/record/data-source.js +29 -0
  67. package/src/lib/record/data-source.js.map +1 -1
  68. package/src/lib/record/data-source.mjs +25 -0
  69. package/src/lib/record/data-source.mjs.map +1 -1
  70. package/src/lib/record/form.d.mts +144 -5
  71. package/src/lib/record/form.d.ts +144 -5
  72. package/src/lib/record/form.js +792 -42
  73. package/src/lib/record/form.js.map +1 -1
  74. package/src/lib/record/form.mjs +791 -43
  75. package/src/lib/record/form.mjs.map +1 -1
  76. package/src/lib/record/locales/ar.js +10 -0
  77. package/src/lib/record/locales/ar.js.map +1 -1
  78. package/src/lib/record/locales/ar.mjs +10 -0
  79. package/src/lib/record/locales/ar.mjs.map +1 -1
  80. package/src/lib/record/locales/de.js +10 -0
  81. package/src/lib/record/locales/de.js.map +1 -1
  82. package/src/lib/record/locales/de.mjs +10 -0
  83. package/src/lib/record/locales/de.mjs.map +1 -1
  84. package/src/lib/record/locales/fr.js +10 -0
  85. package/src/lib/record/locales/fr.js.map +1 -1
  86. package/src/lib/record/locales/fr.mjs +10 -0
  87. package/src/lib/record/locales/fr.mjs.map +1 -1
  88. package/src/lib/record/memory-data-source.d.mts +40 -3
  89. package/src/lib/record/memory-data-source.d.ts +40 -3
  90. package/src/lib/record/memory-data-source.js +164 -20
  91. package/src/lib/record/memory-data-source.js.map +1 -1
  92. package/src/lib/record/memory-data-source.mjs +164 -20
  93. package/src/lib/record/memory-data-source.mjs.map +1 -1
  94. package/src/lib/record/messages.d.mts +21 -0
  95. package/src/lib/record/messages.d.ts +21 -0
  96. package/src/lib/record/messages.js +10 -0
  97. package/src/lib/record/messages.js.map +1 -1
  98. package/src/lib/record/messages.mjs +10 -0
  99. package/src/lib/record/messages.mjs.map +1 -1
  100. package/src/lib/record/run.d.mts +50 -7
  101. package/src/lib/record/run.d.ts +50 -7
  102. package/src/lib/record/run.js +67 -20
  103. package/src/lib/record/run.js.map +1 -1
  104. package/src/lib/record/run.mjs +67 -20
  105. package/src/lib/record/run.mjs.map +1 -1
  106. package/src/lib/record/upload-to.d.mts +50 -0
  107. package/src/lib/record/upload-to.d.ts +50 -0
  108. package/src/lib/record/upload-to.js +117 -0
  109. package/src/lib/record/upload-to.js.map +1 -0
  110. package/src/lib/record/upload-to.mjs +114 -0
  111. package/src/lib/record/upload-to.mjs.map +1 -0
  112. package/src/lib/record/uploads.d.mts +152 -0
  113. package/src/lib/record/uploads.d.ts +152 -0
  114. package/src/lib/record/uploads.js +345 -0
  115. package/src/lib/record/uploads.js.map +1 -0
  116. package/src/lib/record/uploads.mjs +337 -0
  117. package/src/lib/record/uploads.mjs.map +1 -0
  118. package/src/lib/record/values.d.mts +6 -0
  119. package/src/lib/record/values.d.ts +6 -0
  120. package/src/lib/record/values.js.map +1 -1
  121. package/src/lib/record/values.mjs.map +1 -1
  122. package/src/runtime.d.mts +10 -6
  123. package/src/runtime.d.ts +10 -6
  124. package/src/runtime.js +19 -2
  125. package/src/runtime.js.map +1 -1
  126. package/src/runtime.mjs +7 -3
  127. package/src/runtime.mjs.map +1 -1
@@ -8,9 +8,18 @@ import type { RunStop } from './run';
8
8
  * and every method is optional so a page uses only what it needs.
9
9
  */
10
10
  export interface DataSource {
11
- /** Read a record. Needed to edit an existing record. */
12
- load?(request: LoadRequest): Promise<Values>;
13
- /** Create (`id: null`) or update a record. Needed to save a record page. */
11
+ /**
12
+ * Read a record. Needed to edit an existing record. Its values, or
13
+ * `loadedRecord(values, { version, can })` to tell also its version — so a
14
+ * save can tell that someone else changed it since — and what this person
15
+ * may do with it. `loadedRecordOf` reads either the same way.
16
+ */
17
+ load?(request: LoadRequest): Promise<Values | LoadedRecord>;
18
+ /**
19
+ * Create (`id: null`) or update a record. Needed to save a record page. A
20
+ * source that keeps versions compares `request.version` with the record's
21
+ * and throws a `ConflictError` when someone else saved it since.
22
+ */
14
23
  save?(request: SaveRequest): Promise<SaveResult>;
15
24
  /** Recalculate after a value changes, the way a backend's onchange does. */
16
25
  onchange?(request: OnchangeRequest): Promise<OnchangeResult>;
@@ -20,7 +29,7 @@ export interface DataSource {
20
29
  create?(request: CreateRequest): Promise<RelatedRecord>;
21
30
  /** Store one response to a `responses` page, such as a survey. */
22
31
  submit?(request: SubmitRequest): Promise<SubmitResult>;
23
- /** Records for a list: filtered, sorted and one page of them, with how many there are in all. */
32
+ /** Records for a list: filtered, sorted and one page of them, with how many there are in all; with `allIds`, the ids of every one that matches. */
24
33
  list?(request: ListRequest): Promise<ListResult>;
25
34
  /** How many records fall under each value of a field, for a list's Group By. */
26
35
  groups?(request: GroupRequest): Promise<Group[]>;
@@ -43,7 +52,76 @@ export interface DataSource {
43
52
  * preview: each a file with its `url` (or its `data`) and its media type.
44
53
  */
45
54
  attachments?(request: RecordRequest): Promise<FileValue[]>;
55
+ /**
56
+ * Totals over the records a search finds — each field's sum, average,
57
+ * least, most or count — over them all and, with `groupBy`, under each
58
+ * value of a field: the row under a list and the sums on its Group By
59
+ * headings. Without it, a list shows no totals.
60
+ */
61
+ totals?(request: TotalsRequest): Promise<TotalsResult>;
62
+ /**
63
+ * Store one file where the app keeps files, as soon as it is picked, for a
64
+ * file or picture field: the record then keeps only its link. Without it,
65
+ * a file travels inside the record as base64, as it always has. To fail,
66
+ * throw: the field offers Retry, except after `saveRefused({ kind: 'rule',
67
+ * message })`, which says the store refuses this file (its kind, its
68
+ * size) and is shown as it is.
69
+ */
70
+ upload?(file: UploadFile, request: UploadRequest): Promise<UploadedFile>;
71
+ }
72
+ /**
73
+ * Which state of a record a page opened, as its source keeps it: an etag, a
74
+ * write date, a revision number — Fieldia only hands `token` back — with who
75
+ * changed the record last and when, for the words a conflict shows ("Mona
76
+ * changed this at 10:42").
77
+ */
78
+ export interface RecordVersion {
79
+ token: string;
80
+ /** Who changed it last, by the name people know them by. */
81
+ by?: string;
82
+ /** When, as an ISO date and time: `2026-10-13T10:42:00Z`. */
83
+ at?: string;
84
+ }
85
+ /**
86
+ * What this person may do with one record. Each is allowed unless it says
87
+ * `false`, so a source that tells nothing keeps today's behaviour. The page
88
+ * hides what is not allowed — never shows it disabled — and says why.
89
+ */
90
+ export interface RecordPermissions {
91
+ /** False: the form is read-only for this person, and Save is hidden. */
92
+ edit?: boolean;
93
+ /** False: Delete is hidden, and the `delete` step's buttons. */
94
+ delete?: boolean;
95
+ /** False: Archive and Unarchive are hidden, and those steps' buttons. */
96
+ archive?: boolean;
97
+ /** False: Duplicate is hidden, and the `duplicate` step's buttons. */
98
+ duplicate?: boolean;
99
+ /** Fields this person may see but not change on this record, by name. */
100
+ readonlyFields?: string[];
101
+ /** Fields this person may not see on this record, by name: hidden, and the source leaves their values out. */
102
+ hiddenFields?: string[];
103
+ /** Why not everything is allowed, in the person's language, for the page to say: "Only Mona, the lead's agent, can change it." */
104
+ reason?: string;
46
105
  }
106
+ /**
107
+ * A record as `load` gives it to tell more than its values. A field cannot
108
+ * be named `$loaded`, so the mark keeps it apart from bare values.
109
+ */
110
+ export interface LoadedRecord {
111
+ $loaded: true;
112
+ values: Values;
113
+ /** The version it was read at: each save carries it back. */
114
+ version?: RecordVersion;
115
+ /** What this person may do with it. */
116
+ can?: RecordPermissions;
117
+ }
118
+ /** What `load` returns to tell a record's version, or what this person may do with it, beside its values. */
119
+ export declare function loadedRecord(values: Values, more?: {
120
+ version?: RecordVersion;
121
+ can?: RecordPermissions;
122
+ }): LoadedRecord;
123
+ /** What a load gave, in one shape: bare values, as every source gave them before, are a record with nothing more to tell. */
124
+ export declare function loadedRecordOf(loaded: Values | LoadedRecord): LoadedRecord;
47
125
  /** One stored record, by its model and id. */
48
126
  export interface RecordRequest {
49
127
  model: string;
@@ -83,11 +161,31 @@ export interface SaveRequest {
83
161
  changes: RecordChanges;
84
162
  /** Every value, for sources that save whole records. */
85
163
  values: Values;
164
+ /**
165
+ * True when the save carries some of the record's fields, not all of it:
166
+ * `values` then holds the fields in `changes` alone and `fields` names
167
+ * them alone — a save from outside a form, which never had the rest, as a
168
+ * calendar's move and its Undo. A source that saves whole records writes
169
+ * these over the record as stored and keeps every other field.
170
+ */
171
+ partial?: boolean;
172
+ /**
173
+ * The version the record was opened at — from its load, or from the last
174
+ * save — for a source that refuses a save over someone else's. None for a
175
+ * new record, nor from a source that tells no versions. "Keep mine" after
176
+ * a conflict saves again with the conflict's `version`: these changes over
177
+ * the record as it is now.
178
+ */
179
+ version?: RecordVersion;
86
180
  }
87
181
  export interface SaveResult {
88
182
  id: RecordId;
89
183
  /** The record as stored, when the source recalculated anything. */
90
184
  values?: Values;
185
+ /** Its version now, for the next save to carry. */
186
+ version?: RecordVersion;
187
+ /** What the person may do with it now, when the save changed that (a stage that locks it). */
188
+ can?: RecordPermissions;
91
189
  }
92
190
  export interface OnchangeRequest {
93
191
  model: string;
@@ -137,14 +235,28 @@ export interface ListRequest {
137
235
  sort: SortOrder[];
138
236
  offset: number;
139
237
  limit: number;
238
+ /**
239
+ * Give also the id of every record that matches, in this order, in
240
+ * `ListResult.allIds`: for "Select all 1,284 that match", export and bulk
241
+ * edit. A source that leaves it out is paged through instead.
242
+ */
243
+ allIds?: boolean;
140
244
  }
141
245
  export interface ListResult {
142
- records: {
143
- id: RecordId;
144
- values: Values;
145
- }[];
246
+ records: ListedRecord[];
146
247
  /** How many records match in all, for the pager. */
147
248
  total: number;
249
+ /** With `allIds`: every matching record's id. Fewer than `total` means the source stopped short, and the page says so. */
250
+ allIds?: RecordId[];
251
+ }
252
+ /** One row of a list. */
253
+ export interface ListedRecord {
254
+ id: RecordId;
255
+ values: Values;
256
+ /** Its version, for a bulk edit that saves it to carry. */
257
+ version?: RecordVersion;
258
+ /** What the person may do with it: its row's buttons and a bulk edit keep to it. */
259
+ can?: RecordPermissions;
148
260
  }
149
261
  export interface GroupRequest {
150
262
  model: string;
@@ -157,6 +269,82 @@ export interface Group {
157
269
  label: string;
158
270
  count: number;
159
271
  }
272
+ /** How a field is totalled: added up, averaged, its least or its most, or how many records hold a value in it. */
273
+ export type TotalKind = 'sum' | 'avg' | 'min' | 'max' | 'count';
274
+ export interface TotalsRequest {
275
+ model: string;
276
+ /** The list's search, as `list` gets it. For the ticked rows alone, add `{ field: 'id', op: 'in', value: ids }`. */
277
+ filter: ResolvedFilter[];
278
+ /** What to total, by field: `{ amount: ['sum'], hours: ['sum', 'avg'] }`. */
279
+ totals: Record<string, TotalKind[]>;
280
+ /** Total under each value of this field as well, grouped as `groups` groups it: a Group By heading's sums. */
281
+ groupBy?: string;
282
+ }
283
+ /**
284
+ * Each field's totals, by field and then by kind: `totals.amount.sum`. The
285
+ * sum and the count of nothing are 0, the others null. A total the source
286
+ * cannot make honestly — amounts in more than one currency — is null, and
287
+ * the page shows none.
288
+ */
289
+ export type Totals = Record<string, Partial<Record<TotalKind, number | null>>>;
290
+ export interface TotalsResult {
291
+ /** How many records match. */
292
+ count: number;
293
+ totals: Totals;
294
+ /** With `groupBy`: each value's totals, in the order `groups` gives them. */
295
+ groups?: TotalsGroup[];
296
+ }
297
+ export interface TotalsGroup extends Group {
298
+ totals: Totals;
299
+ }
300
+ /**
301
+ * A file to store: what an upload reads of a browser's `File`. Core names no
302
+ * DOM types; an adapter may type its parameter `File` and hand it to `fetch`
303
+ * or a storage SDK as it is.
304
+ */
305
+ export interface UploadFile {
306
+ readonly name: string;
307
+ readonly size: number;
308
+ readonly type: string;
309
+ arrayBuffer(): Promise<ArrayBuffer>;
310
+ }
311
+ /** An `AbortSignal`, as far as an upload listens to it. */
312
+ export interface UploadSignal {
313
+ readonly aborted: boolean;
314
+ addEventListener(type: 'abort', listener: () => void): void;
315
+ removeEventListener(type: 'abort', listener: () => void): void;
316
+ }
317
+ export interface UploadRequest {
318
+ /** The page's model: for a saved form placed in a record, the record's. */
319
+ model: string;
320
+ /**
321
+ * The file or picture field the file is for. In a saved form placed in a
322
+ * record, whose answers the record keeps under the part's name, the field's
323
+ * place in them: `consent.doc`, as a line's table is `consent.items`.
324
+ */
325
+ field: string;
326
+ /** The record it is for; null while that record is new — its first save links the file to it. */
327
+ recordId: RecordId | null;
328
+ /** For a field on a line of a table: the table's field, the line's key and, once saved, its id. */
329
+ line?: {
330
+ field: string;
331
+ key: string;
332
+ id?: RecordId;
333
+ };
334
+ /** Aborts when the person takes the file away or leaves before it is stored: stop, and reject with an error named `AbortError`. */
335
+ signal?: UploadSignal;
336
+ /** How far it has got, bytes sent of the bytes in all, for its progress bar; without a call the bar moves with no number. */
337
+ onProgress?: (sent: number, total: number) => void;
338
+ }
339
+ /** A stored file, as the field keeps it — a `FileValue` with its link instead of its bytes. */
340
+ export interface UploadedFile {
341
+ url: string;
342
+ name: string;
343
+ size: number;
344
+ type: string;
345
+ /** Its id, where the source keeps files as records of their own (Flectra's attachments): a save links it by this. */
346
+ fileId?: RecordId;
347
+ }
160
348
  /**
161
349
  * A filter as a search receives it: each `valueFrom` already replaced by its
162
350
  * value. An adapter must keep the groups: `any` is an OR, `all` an AND.
@@ -213,16 +401,42 @@ export type LinkOp = {
213
401
  /**
214
402
  * Why a save was refused, so each kind is shown where it belongs: `fields`
215
403
  * under those fields, a `rule` (a backend's business rule) in a dialog,
216
- * `network` in a banner with Retry, and `other` beside Save with Retry.
404
+ * `network` in a banner with Retry, `other` beside Save with Retry, and a
405
+ * `conflict` with Reload and Keep mine.
217
406
  */
218
407
  export interface SaveProblem {
219
408
  /** `stopped`: a step of the page's `beforeSave` stopped it, with `reason` — no data source was asked. */
220
- kind: 'fields' | 'rule' | 'network' | 'other' | 'stopped';
409
+ kind: 'fields' | 'rule' | 'network' | 'other' | 'stopped' | 'conflict';
221
410
  message: string;
222
411
  /** For `fields`: a message for each field, by name. */
223
412
  fields?: Record<string, string>;
224
413
  /** For `stopped`: why the step stopped. */
225
414
  reason?: RunStop;
415
+ /** For `conflict`: who changed the record, when, and what it holds now. */
416
+ conflict?: SaveConflict;
417
+ }
418
+ /** Someone else saved the record after this page opened it. */
419
+ export interface SaveConflict {
420
+ /** The version the record has now, with who changed it and when. Saving with it is "Keep mine". */
421
+ version: RecordVersion;
422
+ /**
423
+ * The record as the other person left it, when the source has it: what
424
+ * Reload shows without asking again, and what a source that saves whole
425
+ * records keeps of theirs under Keep mine — their values, with this
426
+ * person's changes over them.
427
+ */
428
+ theirs?: Values;
429
+ }
430
+ /**
431
+ * What a source's save throws when the record changed after the page opened
432
+ * it: `saveProblemOf` reads it as a `conflict`, and the page names who
433
+ * changed it and when (`conflict.version.by`, `.at`) and offers Reload or
434
+ * Keep mine.
435
+ */
436
+ export declare class ConflictError extends Error {
437
+ readonly conflict: SaveConflict;
438
+ readonly problem: SaveProblem;
439
+ constructor(conflict: SaveConflict, message?: string);
226
440
  }
227
441
  /** An error for a data source's save to throw when the backend refuses it. */
228
442
  export declare function saveRefused(problem: SaveProblem): Error & {
@@ -1,7 +1,36 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ConflictError = void 0;
4
+ exports.loadedRecord = loadedRecord;
5
+ exports.loadedRecordOf = loadedRecordOf;
3
6
  exports.saveRefused = saveRefused;
4
7
  exports.saveProblemOf = saveProblemOf;
8
+ /** What `load` returns to tell a record's version, or what this person may do with it, beside its values. */
9
+ function loadedRecord(values, more = {}) {
10
+ return { $loaded: true, values, ...(more.version ? { version: more.version } : {}), ...(more.can ? { can: more.can } : {}) };
11
+ }
12
+ /** What a load gave, in one shape: bare values, as every source gave them before, are a record with nothing more to tell. */
13
+ function loadedRecordOf(loaded) {
14
+ const marked = loaded;
15
+ return marked.$loaded === true && marked.values !== null && typeof marked.values === 'object' ? loaded : { $loaded: true, values: loaded };
16
+ }
17
+ /**
18
+ * What a source's save throws when the record changed after the page opened
19
+ * it: `saveProblemOf` reads it as a `conflict`, and the page names who
20
+ * changed it and when (`conflict.version.by`, `.at`) and offers Reload or
21
+ * Keep mine.
22
+ */
23
+ class ConflictError extends Error {
24
+ conflict;
25
+ problem;
26
+ constructor(conflict, message = 'Someone else changed this record after it was opened') {
27
+ super(message);
28
+ this.name = 'ConflictError';
29
+ this.conflict = conflict;
30
+ this.problem = { kind: 'conflict', message, conflict };
31
+ }
32
+ }
33
+ exports.ConflictError = ConflictError;
5
34
  /** An error for a data source's save to throw when the backend refuses it. */
6
35
  function saveRefused(problem) {
7
36
  return Object.assign(new Error(problem.message), { problem });
@@ -1 +1 @@
1
- {"version":3,"file":"data-source.js","sourceRoot":"","sources":["../../../../../../libs/core/src/lib/record/data-source.ts"],"names":[],"mappings":";;AA8NA,kCAEC;AAGD,sCAMC;AAZD,8EAA8E;AAC9E,SAAgB,WAAW,CAAC,OAAoB;IAC9C,OAAO,MAAM,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,EAAE,OAAO,EAAE,CAAC,CAAC;AAChE,CAAC;AAED,wGAAwG;AACxG,SAAgB,aAAa,CAAC,KAAc;IAC1C,MAAM,OAAO,GAAI,KAA0C,EAAE,OAAO,CAAC;IACrE,IAAI,OAAO,IAAI,OAAO,OAAO,CAAC,IAAI,KAAK,QAAQ;QAAE,OAAO,OAAO,CAAC;IAChE,MAAM,OAAO,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IACvE,IAAI,KAAK,YAAY,SAAS,IAAI,4BAA4B,CAAC,IAAI,CAAC,OAAO,CAAC;QAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,OAAO,EAAE,CAAC;IAClH,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC;AACpC,CAAC"}
1
+ {"version":3,"file":"data-source.js","sourceRoot":"","sources":["../../../../../../libs/core/src/lib/record/data-source.ts"],"names":[],"mappings":";;;AAyHA,oCAEC;AAGD,wCAGC;AA0UD,kCAEC;AAGD,sCAMC;AA9VD,6GAA6G;AAC7G,SAAgB,YAAY,CAAC,MAAc,EAAE,OAA6D,EAAE;IAC1G,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,IAAI,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;AAC/H,CAAC;AAED,6HAA6H;AAC7H,SAAgB,cAAc,CAAC,MAA6B;IAC1D,MAAM,MAAM,GAAG,MAA+B,CAAC;IAC/C,OAAO,MAAM,CAAC,OAAO,KAAK,IAAI,IAAI,MAAM,CAAC,MAAM,KAAK,IAAI,IAAI,OAAO,MAAM,CAAC,MAAM,KAAK,QAAQ,CAAC,CAAC,CAAE,MAAuB,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,MAAgB,EAAE,CAAC;AACzK,CAAC;AAwTD;;;;;GAKG;AACH,MAAa,aAAc,SAAQ,KAAK;IAC7B,QAAQ,CAAe;IACvB,OAAO,CAAc;IAC9B,YAAY,QAAsB,EAAE,OAAO,GAAG,sDAAsD;QAClG,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,eAAe,CAAC;QAC5B,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC;QACzB,IAAI,CAAC,OAAO,GAAG,EAAE,IAAI,EAAE,UAAU,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC;IACzD,CAAC;CACF;AATD,sCASC;AAED,8EAA8E;AAC9E,SAAgB,WAAW,CAAC,OAAoB;IAC9C,OAAO,MAAM,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,EAAE,OAAO,EAAE,CAAC,CAAC;AAChE,CAAC;AAED,wGAAwG;AACxG,SAAgB,aAAa,CAAC,KAAc;IAC1C,MAAM,OAAO,GAAI,KAA0C,EAAE,OAAO,CAAC;IACrE,IAAI,OAAO,IAAI,OAAO,OAAO,CAAC,IAAI,KAAK,QAAQ;QAAE,OAAO,OAAO,CAAC;IAChE,MAAM,OAAO,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IACvE,IAAI,KAAK,YAAY,SAAS,IAAI,4BAA4B,CAAC,IAAI,CAAC,OAAO,CAAC;QAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,OAAO,EAAE,CAAC;IAClH,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC;AACpC,CAAC"}
@@ -1,3 +1,28 @@
1
+ /** What `load` returns to tell a record's version, or what this person may do with it, beside its values. */
2
+ export function loadedRecord(values, more = {}) {
3
+ return { $loaded: true, values, ...(more.version ? { version: more.version } : {}), ...(more.can ? { can: more.can } : {}) };
4
+ }
5
+ /** What a load gave, in one shape: bare values, as every source gave them before, are a record with nothing more to tell. */
6
+ export function loadedRecordOf(loaded) {
7
+ const marked = loaded;
8
+ return marked.$loaded === true && marked.values !== null && typeof marked.values === 'object' ? loaded : { $loaded: true, values: loaded };
9
+ }
10
+ /**
11
+ * What a source's save throws when the record changed after the page opened
12
+ * it: `saveProblemOf` reads it as a `conflict`, and the page names who
13
+ * changed it and when (`conflict.version.by`, `.at`) and offers Reload or
14
+ * Keep mine.
15
+ */
16
+ export class ConflictError extends Error {
17
+ conflict;
18
+ problem;
19
+ constructor(conflict, message = 'Someone else changed this record after it was opened') {
20
+ super(message);
21
+ this.name = 'ConflictError';
22
+ this.conflict = conflict;
23
+ this.problem = { kind: 'conflict', message, conflict };
24
+ }
25
+ }
1
26
  /** An error for a data source's save to throw when the backend refuses it. */
2
27
  export function saveRefused(problem) {
3
28
  return Object.assign(new Error(problem.message), { problem });
@@ -1 +1 @@
1
- {"version":3,"file":"data-source.mjs","sourceRoot":"","sources":["../../../../../../libs/core/src/lib/record/data-source.ts"],"names":[],"mappings":"AA6NA,8EAA8E;AAC9E,MAAM,UAAU,WAAW,CAAC,OAAoB;IAC9C,OAAO,MAAM,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,EAAE,OAAO,EAAE,CAAC,CAAC;AAChE,CAAC;AAED,wGAAwG;AACxG,MAAM,UAAU,aAAa,CAAC,KAAc;IAC1C,MAAM,OAAO,GAAI,KAA0C,EAAE,OAAO,CAAC;IACrE,IAAI,OAAO,IAAI,OAAO,OAAO,CAAC,IAAI,KAAK,QAAQ;QAAE,OAAO,OAAO,CAAC;IAChE,MAAM,OAAO,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IACvE,IAAI,KAAK,YAAY,SAAS,IAAI,4BAA4B,CAAC,IAAI,CAAC,OAAO,CAAC;QAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,OAAO,EAAE,CAAC;IAClH,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC;AACpC,CAAC"}
1
+ {"version":3,"file":"data-source.mjs","sourceRoot":"","sources":["../../../../../../libs/core/src/lib/record/data-source.ts"],"names":[],"mappings":"AAwHA,6GAA6G;AAC7G,MAAM,UAAU,YAAY,CAAC,MAAc,EAAE,OAA6D,EAAE;IAC1G,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,IAAI,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;AAC/H,CAAC;AAED,6HAA6H;AAC7H,MAAM,UAAU,cAAc,CAAC,MAA6B;IAC1D,MAAM,MAAM,GAAG,MAA+B,CAAC;IAC/C,OAAO,MAAM,CAAC,OAAO,KAAK,IAAI,IAAI,MAAM,CAAC,MAAM,KAAK,IAAI,IAAI,OAAO,MAAM,CAAC,MAAM,KAAK,QAAQ,CAAC,CAAC,CAAE,MAAuB,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,MAAgB,EAAE,CAAC;AACzK,CAAC;AAwTD;;;;;GAKG;AACH,MAAM,OAAO,aAAc,SAAQ,KAAK;IAC7B,QAAQ,CAAe;IACvB,OAAO,CAAc;IAC9B,YAAY,QAAsB,EAAE,OAAO,GAAG,sDAAsD;QAClG,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,eAAe,CAAC;QAC5B,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC;QACzB,IAAI,CAAC,OAAO,GAAG,EAAE,IAAI,EAAE,UAAU,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC;IACzD,CAAC;CACF;AAED,8EAA8E;AAC9E,MAAM,UAAU,WAAW,CAAC,OAAoB;IAC9C,OAAO,MAAM,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,EAAE,OAAO,EAAE,CAAC,CAAC;AAChE,CAAC;AAED,wGAAwG;AACxG,MAAM,UAAU,aAAa,CAAC,KAAc;IAC1C,MAAM,OAAO,GAAI,KAA0C,EAAE,OAAO,CAAC;IACrE,IAAI,OAAO,IAAI,OAAO,OAAO,CAAC,IAAI,KAAK,QAAQ;QAAE,OAAO,OAAO,CAAC;IAChE,MAAM,OAAO,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IACvE,IAAI,KAAK,YAAY,SAAS,IAAI,4BAA4B,CAAC,IAAI,CAAC,OAAO,CAAC;QAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,OAAO,EAAE,CAAC;IAClH,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC;AACpC,CAAC"}
@@ -4,7 +4,8 @@ import type { Page } from '../format/page.mjs';
4
4
  import type { JsonValue } from '../format/json.mjs';
5
5
  import { type FieldTone, type LineState } from './cells.mjs';
6
6
  import { type Messages } from './messages.mjs';
7
- import { type DataSource, type RecordChanges, type SaveProblem } from './data-source.mjs';
7
+ import { type DataSource, type RecordChanges, type RecordPermissions, type RecordVersion, type SaveProblem, type UploadFile } from './data-source.mjs';
8
+ import { type FileUpload, type UploadHow } from './uploads.mjs';
8
9
  import { type Scheduler } from './scheduler.mjs';
9
10
  import { type ActionHost, type ActionRequest, type ActionResult, type OnAction, type PostedMessage, type RunContext, type RunResult } from './run.mjs';
10
11
  import { type RecordId, type RelatedRecord, type Value, type Values } from './values.mjs';
@@ -42,6 +43,36 @@ export interface FormState {
42
43
  readonly choices: Readonly<Record<string, Choices>>;
43
44
  /** The definitions loaded for a properties field with `definitionsFrom`, by field, once asked for. */
44
45
  readonly definitions: Readonly<Record<string, Definitions>>;
46
+ /** Files on their way to the data source's store, in the order picked: each field shows its own (`upload`). */
47
+ readonly uploads: readonly FileUpload[];
48
+ /** The record's version, as loaded or last saved, from a source that keeps versions: each save carries it. Null for a new record, or from a source that tells none. */
49
+ readonly version: RecordVersion | null;
50
+ /**
51
+ * What the person may do with the record, as its source told with its load
52
+ * or its last save: the form keeps to it — fields locked or hidden, Save,
53
+ * Archive, Duplicate and Delete not offered. Null when it told nothing, and
54
+ * everything is allowed.
55
+ */
56
+ readonly can: Readonly<RecordPermissions> | null;
57
+ /** Someone else saved the record after this page opened it, and the last save was refused for it: until Reload, Keep mine or another save. */
58
+ readonly conflict: FormConflict | null;
59
+ /** The person's changes Reload set aside after a conflict, by field, to put back (`putBack`); null when none are. */
60
+ readonly setAside: Readonly<Values> | null;
61
+ /**
62
+ * Fields holding a change of the person's that the last finished save of
63
+ * this record did not send: made while it was on its way, while the run that
64
+ * saved went on (its afterSave run, a copy being made), or since. Worked out
65
+ * from the record as that save left it, so a change back to it is none; and
66
+ * before the record's first save, only a file the store holds and no save
67
+ * sent. Never a value a step, a rule, a compute or the app set: each change
68
+ * is told by its origin. The next record starts with none.
69
+ */
70
+ readonly unsent: readonly string[];
71
+ /**
72
+ * A way out (`leave()`) waits for the save on its way, or files on theirs,
73
+ * before it checks: the page says what it waits for.
74
+ */
75
+ readonly leaving: boolean;
45
76
  /**
46
77
  * Text a person left in a box that does not read as its field's value — "12 34" in a number, "31/02/2026" in a
47
78
  * date — by its path (`field`, or a line's `field.lineKey.subfield`), with why in words. The field keeps the
@@ -54,6 +85,48 @@ export interface UnreadText {
54
85
  readonly text: string;
55
86
  readonly message: string;
56
87
  }
88
+ /** A save refused because someone else saved the record first: who and when, and what each of them changed. */
89
+ export interface FormConflict {
90
+ /** The version the record has now, with who saved it (`by`) and when (`at`): Keep mine saves over it. */
91
+ readonly version: RecordVersion;
92
+ /** The record as they left it, when the source told it. */
93
+ readonly theirs?: Readonly<Values>;
94
+ /** The fields they changed since this page opened the record, in the page's order: none known when the source did not tell the record. */
95
+ readonly changed: readonly string[];
96
+ /** Those of them this person changed too, each to something else. */
97
+ readonly both: readonly string[];
98
+ /**
99
+ * Keep mine was pressed, and the record as they left it could not be read:
100
+ * nothing was saved over their work, and the person may try again or Reload.
101
+ */
102
+ readonly unread?: boolean;
103
+ }
104
+ /**
105
+ * Whether leaving the record now would lose the person's work: changes not
106
+ * saved, files still on their way to the store, or changes Reload set aside
107
+ * and not yet put back or let go, or text left in a box that does not read.
108
+ * Every way out of a record asks this.
109
+ */
110
+ export declare function unsavedWork(state: FormState): boolean;
111
+ /**
112
+ * Whether a way out would lose the person's work without their knowing: a
113
+ * change of theirs the last save did not send (`unsent`), files still on
114
+ * their way, changes Reload set aside — and, `how.changes`, any change not
115
+ * saved. What `form.leave()` — the one check every way out goes through —
116
+ * asks about, once the save on its way and the files on theirs settled.
117
+ * Before a record's first save, a change not saved is not at risk: as in
118
+ * 0.20, each way out decides.
119
+ */
120
+ export declare function workAtRisk(state: FormState, how?: LeaveHow): boolean;
121
+ /**
122
+ * How a way out asks: `changes`, every change not saved counts too, as a
123
+ * panel's Escape has always asked; `discard`, the person's own Discard: it
124
+ * waits as any way out does, asks nothing, and lets their work go.
125
+ */
126
+ export interface LeaveHow {
127
+ changes?: boolean;
128
+ discard?: boolean;
129
+ }
57
130
  /** A properties field's definitions from the app, as they load. */
58
131
  export interface Definitions {
59
132
  /** The definitions last loaded: null until the first load ends well. */
@@ -236,20 +309,21 @@ export interface Form {
236
309
  node(id: string): NodeState;
237
310
  /** Whether the record is being edited, as conditions read `editing`: a view that locks it to be read says false, and parts follow. */
238
311
  setEditing(editing: boolean): void;
312
+ /** Whether a field is read-only whatever the conditions say: by its definition, or for this person by what the record's source said (`can`). */
239
313
  fieldReadonly(name: string): boolean;
240
314
  /** What a field would be told if the form were checked now, or null when it passes. Nothing is shown. */
241
315
  problem(field: string): string | null;
242
316
  /**
243
317
  * Keep text a person left in a box that does not read (`FormState.unread`), by its path — `field`, or a line's
244
318
  * `field.lineKey.subfield` — with why in words; null once it reads, or is let go. Nothing is shown until the box
245
- * is left (`leave`) or the form is checked.
319
+ * is left (`boxLeft`) or the form is checked.
246
320
  */
247
321
  setUnread(path: string, unread: UnreadText | null): void;
248
322
  /**
249
323
  * A person left a field's box, by its path: its own problem — text that does not read, a format, min or max, a
250
324
  * whole number, decimals, a pattern — is shown now, in the words Save would use. That it is required waits for Save.
251
325
  */
252
- leave(path: string): void;
326
+ boxLeft(path: string): void;
253
327
  /**
254
328
  * How a box reads what is typed in it and not yet read — a number, hours, a date, a colour's code, a name looked
255
329
  * up — now, as if it were left: every save and send, every check and step forward runs these first (`settle`), so
@@ -290,9 +364,32 @@ export interface Form {
290
364
  /**
291
365
  * Show another record of the page's model in this form, loaded from the
292
366
  * data source as a pager moves to it, or a new one for null. Changes not
293
- * saved are dropped: save first. The page's `open` steps run for it.
367
+ * saved are dropped: save first. The page's `open` steps run for it. It
368
+ * goes through `leave()` first: false when the person kept their work.
369
+ */
370
+ openRecord(id: RecordId | null): Promise<boolean>;
371
+ /**
372
+ * The one check every way out of the form goes through — another record,
373
+ * the record loaded again, the form closed, reset by a step — and the app's
374
+ * own ways out should too, before it navigates or unmounts the form. It
375
+ * first waits for a save on its way and files on theirs (`leaving`
376
+ * meanwhile), then looks at the record as it is now. With the person's
377
+ * work at risk (`workAtRisk`), it asks "Discard your changes?" through the
378
+ * host (`ask(message, { discard: true })`), else `confirm`: true on
379
+ * Discard — files on their way stopped, changes set aside let go — false
380
+ * on Cancel, the form as it was. Without either to ask with, the work is
381
+ * kept: false. A way out taken while one waits or asks joins it. Nothing
382
+ * on its way and nothing at risk: true, as in 0.20.
383
+ */
384
+ leave(how?: LeaveHow): Promise<boolean>;
385
+ /**
386
+ * Whether a way out goes at once: nothing on its way, no check going, and
387
+ * nothing of the person's at risk (with `how`) — then it goes in the same
388
+ * moment, as in 0.20, and `leave()` would answer true. Else take it through
389
+ * `leave()`. What the viewer's dialogs, pages in place and breadcrumbs ask
390
+ * first, so a way out with nothing to wait for is not put off.
294
391
  */
295
- openRecord(id: RecordId | null): Promise<void>;
392
+ leaveNow(how?: LeaveHow): boolean;
296
393
  /**
297
394
  * Post words in the record's conversation, as a `post` step does: through
298
395
  * the form's host when it has a way to, else told to the form's own
@@ -396,6 +493,48 @@ export interface Form {
396
493
  on<E extends keyof FormEvents>(event: E, listener: (event: FormEvents[E]) => void): () => void;
397
494
  restoreDraft(): void;
398
495
  discardDraft(): void;
496
+ /**
497
+ * Whether a file picked for a file or picture field is stored at once,
498
+ * through the data source's `upload`. False without one: a file then
499
+ * travels inside the record as base64, as it always has.
500
+ */
501
+ canUpload(): boolean;
502
+ /**
503
+ * Store files picked for a file or picture field — or a field on a table's
504
+ * line (`how.line`) — through the data source's `upload`, each as soon as
505
+ * it is picked: `uploads` in the state shows each on its way, and once
506
+ * stored the field holds its link, a field of several in the order they
507
+ * were picked. Three go at once, the rest wait their turn; `how.prepare`
508
+ * makes each ready first, one at a time (a photo made smaller). A field of
509
+ * one file keeps the last one picked. Gives each upload's id. A save waits
510
+ * for those still going, and stops, under the field, for one that failed.
511
+ */
512
+ upload(field: string, files: readonly UploadFile[], how?: UploadHow): string[];
513
+ /**
514
+ * A field's files on their way, in the order picked: the uploads in the
515
+ * state that are its own. A table's cell (`lineForm`) gives its line's.
516
+ */
517
+ uploading(field: string): readonly FileUpload[];
518
+ /** Stop an upload and drop its file, whether it is on its way, waiting, or failed. */
519
+ cancelUpload(id: string): void;
520
+ /** Send a file that failed again. */
521
+ retryUpload(id: string): void;
522
+ /**
523
+ * After a conflict: show the record as the other person left it, loaded
524
+ * again, and set this person's changes aside (`setAside`), to put back.
525
+ * False when it could not be loaded.
526
+ */
527
+ reloadTheirs(): Promise<boolean>;
528
+ /**
529
+ * After a conflict: save this person's changes over the record as it is
530
+ * now — the other person's values kept wherever this one changed nothing.
531
+ * Refused again as a conflict when a third person saved in between.
532
+ */
533
+ keepMine(): Promise<boolean>;
534
+ /** Write a change set aside by Reload back, as the person's own: one field's, or all when none is named. */
535
+ putBack(field?: string): void;
536
+ /** Let go of the changes set aside. */
537
+ discardSetAside(): void;
399
538
  /** Resolves once every onchange, save, autosave and run of steps started so far has finished — a question still waiting for its answer included. */
400
539
  settled(): Promise<void>;
401
540
  /**