@almadar/std 16.216.0 → 16.218.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.
@@ -0,0 +1,272 @@
1
+ app std-data-import "1.0.0"
2
+ "std-data-import — staged CSV import run, source-agnostic: upload → staged review → confirm → handoff. UPLOAD_CSV opens a run and persists an ImportBatch row (status uploaded, fieldMappingJson recorded verbatim for provenance); the host's file UI parses the CSV and answers with STAGE_ROWS, one JSON string per raw row, which the atom bulk-persists into import_rows against the batch id and presents as a staged review (row/error counts + Confirm/Discard). CONFIRM_IMPORT marks the batch confirmed and emits IMPORT_CONFIRMED { batchId, sourceName, rowCount } — the mapping+persist half is the CONSUMER organism's: it listens for IMPORT_CONFIRMED, fetches import_rows where batchId matches, maps each rowJson through its own field mapping, and persists into its own target entity (cross-atom persist into a consumer's entity is not expressible from here). This is deliberately the CSV-file path: the atom never calls a source service — sourceName/fieldMappingJson/targetEntity/historyWindowDays are declarative consumer configs, and any persist failure marks the batch row failed and fires IMPORT_FAILED, never a silent swallow. Compose with std-import for the file-picker menu."
3
+ orbital DataImportOrbital {
4
+
5
+ entity ImportBatch [persistent: import_batches] {
6
+ id : string!
7
+ sourceName : string @description "Free-form name of the upstream source this run imported from." @synonyms "source, origin, provider name" = ""
8
+ status : "uploaded" | "staged" | "confirmed" | "failed" @description "Lifecycle position of the import run." = uploaded
9
+ rowCount : number @description "Number of rows staged for review." @synonyms "rows, staged count, record count" = 0
10
+ errorCount : number @description "Number of rows the host parser flagged with errors during staging." @synonyms "errors, failed rows, parse errors" = 0
11
+ mappingJson : string @description "Source-column → target-field mapping (JSON string), recorded verbatim from config for provenance; the consumer's mapping layer applies it." @synonyms "field mapping, column mapping, mapping" = "{}"
12
+ createdAt : number @description "Run start timestamp." = 0
13
+ }
14
+
15
+ entity ImportRow [persistent: import_rows] {
16
+ id : string!
17
+ batchId : string @description "ImportBatch id this staged row belongs to." @synonyms "batch, import run, parent batch" = ""
18
+ rowJson : string @description "The raw parsed CSV row as a JSON string (source shape, pre-mapping). The consumer maps it into its own entity shape after confirm." @synonyms "row, raw row, staged row, record json" = "{}"
19
+ status : "staged" | "imported" | "failed" @description "staged = awaiting confirm; imported = the consumer persisted it into the target entity; failed = rejected." = staged
20
+ }
21
+
22
+ trait DataImportLifecycle -> @rebindable ImportBatch [lifecycle, instance, import] {
23
+ initial: idle
24
+
25
+ state idle {
26
+ INIT -> idle
27
+ (set @entity.sourceName @config.sourceName)
28
+ (set @entity.status uploaded)
29
+ (set @entity.rowCount 0)
30
+ (set @entity.errorCount 0)
31
+ (set @entity.mappingJson @config.fieldMappingJson)
32
+ (set @entity.createdAt 0)
33
+ UPLOAD_CSV -> uploaded
34
+ (set @entity.id ?batchId)
35
+ (set @entity.sourceName (or ?sourceName @config.sourceName))
36
+ (set @entity.status uploaded)
37
+ (set @entity.mappingJson @config.fieldMappingJson)
38
+ (set @entity.rowCount 0)
39
+ (set @entity.errorCount 0)
40
+ (set @entity.createdAt (time/now))
41
+ (persist create ImportBatch {
42
+ createdAt: @entity.createdAt
43
+ errorCount: 0
44
+ id: @entity.id
45
+ mappingJson: @entity.mappingJson
46
+ rowCount: 0
47
+ sourceName: @entity.sourceName
48
+ status: uploaded
49
+ } {
50
+ emit: { failure: IMPORT_PERSIST_FAILED, success: BATCH_CREATED }
51
+ })
52
+ }
53
+
54
+ state uploaded {
55
+ BATCH_CREATED -> uploaded
56
+ (set @entity.id ?id)
57
+ STAGE_ROWS -> staged when [">", (array/len ?rows), 0]
58
+ (set @entity.rowCount (array/len ?rows))
59
+ (set @entity.errorCount (or ?errorCount 0))
60
+ (set @entity.status staged)
61
+ (persist create ImportRow (array/map ?rows (fn r { batchId: @entity.id, rowJson: @r, status: staged })) {
62
+ emit: { failure: IMPORT_PERSIST_FAILED, success: ROWS_STAGED }
63
+ })
64
+ (persist update ImportBatch {
65
+ errorCount: @entity.errorCount
66
+ id: @entity.id
67
+ rowCount: @entity.rowCount
68
+ status: staged
69
+ })
70
+ (render-ui main {
71
+ children: [{
72
+ content: (str/concat "Review staged import — " @entity.sourceName)
73
+ type: typography
74
+ variant: h3
75
+ }, {
76
+ label: "Rows staged"
77
+ type: stat-display
78
+ value: @entity.rowCount
79
+ }, {
80
+ label: "Rows with errors"
81
+ type: stat-display
82
+ value: @entity.errorCount
83
+ }, {
84
+ children: [{
85
+ action: CONFIRM_IMPORT
86
+ label: "Confirm import"
87
+ type: button
88
+ variant: primary
89
+ }, {
90
+ action: DISCARD_IMPORT
91
+ label: Discard
92
+ type: button
93
+ variant: ghost
94
+ }]
95
+ direction: horizontal
96
+ gap: sm
97
+ type: stack
98
+ }]
99
+ direction: vertical
100
+ gap: md
101
+ type: stack
102
+ })
103
+ ;; Empty staging is a failed run, not a silent no-op. `< 1` (not
104
+ ;; `= 0`) so guard-payload synthesis can steer both arms.
105
+ STAGE_ROWS -> failed when ["<", (array/len ?rows), 1]
106
+ (set @entity.status failed)
107
+ (persist update ImportBatch { id: @entity.id, status: failed })
108
+ (emit IMPORT_FAILED { batchId: @entity.id, error: "The file parsed to zero rows — nothing to stage." })
109
+ (render-ui main {
110
+ message: "The file parsed to zero rows — nothing to stage."
111
+ type: alert
112
+ variant: error
113
+ })
114
+ ;; Persist failure is transient: stay in `uploaded` (the user can
115
+ ;; re-stage or discard) while the batch row records `failed` —
116
+ ;; std-data-erasure's ErasureSaveFailed -> browsing idiom. Keeping this
117
+ ;; a self-loop also leaves `failed` reachable only via dispatchable
118
+ ;; edges, so the runtime walk's reconcile genuinely settles there.
119
+ IMPORT_PERSIST_FAILED -> uploaded
120
+ (set @entity.status failed)
121
+ (set @entity.errorCount 1)
122
+ (persist update ImportBatch { errorCount: 1, id: @entity.id, status: failed })
123
+ (emit IMPORT_FAILED { batchId: @entity.id, error: ?error })
124
+ (render-ui main {
125
+ message: ?error
126
+ type: alert
127
+ variant: error
128
+ })
129
+ DISCARD_IMPORT -> idle
130
+ (set @entity.status uploaded)
131
+ (set @entity.rowCount 0)
132
+ (set @entity.errorCount 0)
133
+ }
134
+
135
+ state staged {
136
+ ;; Self-loop so the validator sees an in-atom listener for the
137
+ ;; bulk-persist success signal.
138
+ ROWS_STAGED -> staged
139
+ CONFIRM_IMPORT -> confirmed
140
+ (set @entity.status confirmed)
141
+ (persist update ImportBatch { id: @entity.id, status: confirmed })
142
+ (render-ui main {
143
+ message: "Import confirmed — staged rows are ready for mapping."
144
+ type: alert
145
+ variant: success
146
+ })
147
+ (emit IMPORT_CONFIRMED { batchId: @entity.id, rowCount: @entity.rowCount, sourceName: @entity.sourceName })
148
+ ;; Same transient-failure self-loop as in `uploaded`.
149
+ IMPORT_PERSIST_FAILED -> staged
150
+ (set @entity.status failed)
151
+ (set @entity.errorCount 1)
152
+ (persist update ImportBatch { errorCount: 1, id: @entity.id, status: failed })
153
+ (emit IMPORT_FAILED { batchId: @entity.id, error: ?error })
154
+ (render-ui main {
155
+ message: ?error
156
+ type: alert
157
+ variant: error
158
+ })
159
+ ;; The batch row stays (status staged) for provenance; staged rows are
160
+ ;; left in import_rows for the host to clean up or re-run.
161
+ DISCARD_IMPORT -> idle
162
+ (set @entity.status uploaded)
163
+ (set @entity.rowCount 0)
164
+ (set @entity.errorCount 0)
165
+ (render-ui main null)
166
+ }
167
+
168
+ state confirmed {
169
+ RESET -> idle
170
+ (set @entity.status uploaded)
171
+ (set @entity.rowCount 0)
172
+ (set @entity.errorCount 0)
173
+ (render-ui main null)
174
+ ;; Self-loop so the validator sees an in-atom listener for the
175
+ ;; externally-consumed confirmation event.
176
+ IMPORT_CONFIRMED -> confirmed
177
+ }
178
+
179
+ state failed {
180
+ RESET -> idle
181
+ (set @entity.status uploaded)
182
+ (set @entity.rowCount 0)
183
+ (set @entity.errorCount 0)
184
+ (render-ui main null)
185
+ ;; Mirrors the confirmed self-loop: IMPORT_FAILED is emitted for
186
+ ;; downstream consumers and listened to here for the validator.
187
+ IMPORT_FAILED -> failed
188
+ }
189
+
190
+ emits {
191
+ BATCH_CREATED {
192
+ id : string!
193
+ }
194
+ @description "import_batches persist-create success — rebinds @entity.id to the server-issued id, the fallback for when the host didn't mint a client-side batchId on UPLOAD_CSV"
195
+ @tier "secondary"
196
+ ROWS_STAGED
197
+ @description "import_rows bulk persist-create success signal — payload carries the persisted rows flat; the atom never reads it (self-loop only)"
198
+ @tier "secondary"
199
+ IMPORT_PERSIST_FAILED {
200
+ error : string!
201
+ }
202
+ @description "Any import_batches / import_rows persist failure — the batch row is updated to failed and IMPORT_FAILED emitted, never a silent swallow; the trait stays put so the user can retry or discard"
203
+ @tier "secondary"
204
+ IMPORT_CONFIRMED -> external {
205
+ batchId : string!
206
+ sourceName : string
207
+ rowCount : number
208
+ }
209
+ @description "The user confirmed the staged review and the batch row is persisted as confirmed. Consumers listen, fetch import_rows where batchId matches, map each rowJson through their field mapping, and persist into their own target entity."
210
+ @synonyms "import confirmed, commit import, accept staged rows, approve import"
211
+ @tier "domain"
212
+ IMPORT_FAILED -> external {
213
+ batchId : string
214
+ error : string!
215
+ }
216
+ @description "The run failed (persist failure or empty staging); error carries the detail and the batch row was updated to failed"
217
+ @synonyms "import failed, upload failed, staging failed"
218
+ @tier "domain"
219
+ }
220
+
221
+ listens {
222
+ UPLOAD_CSV {
223
+ batchId : string
224
+ sourceName : string
225
+ }
226
+ @description "Starts an import run for a CSV file upload. batchId is the client-minted batch id (idempotent upload — the host generates it so a retry never double-creates a batch); when omitted, the server-issued id is bound on BATCH_CREATED instead. The host's file UI (e.g. a sibling std-import menu) only announces the upload — parsing is host work and arrives as STAGE_ROWS."
227
+ @synonyms "upload csv, import csv, start import, import file, bulk import"
228
+ @tier "domain"
229
+ STAGE_ROWS {
230
+ rows : [string]!
231
+ errorCount : number
232
+ }
233
+ @description "Host-side CSV parsing finished: rows carries one JSON string per parsed row (raw source shape, pre-mapping); errorCount counts rows the parser flagged. The atom bulk-persists them into import_rows and opens the staged review."
234
+ @tier "internal"
235
+ CONFIRM_IMPORT
236
+ @description "The user approved the staged review; the batch is marked confirmed and IMPORT_CONFIRMED fires for the consumer's mapping+persist half."
237
+ @synonyms "confirm import, approve staged rows, accept import"
238
+ @tier "domain"
239
+ DISCARD_IMPORT
240
+ @description "The user discarded the staged run before confirm; the batch row stays for provenance."
241
+ @tier "internal"
242
+ RESET
243
+ @description "Returns the atom to idle from a terminal state"
244
+ @tier "internal"
245
+ }
246
+
247
+ config {
248
+ sourceName : string = ""
249
+ @label "Source name"
250
+ @description "Free-form name of the upstream source this import pulls from — whatever tool or file export the user names. Recorded on the batch row for provenance and echoed on IMPORT_CONFIRMED."
251
+ @synonyms "source, origin, import from, provider name, data source"
252
+ @tier "domain"
253
+ fieldMappingJson : string = "{}"
254
+ @label "Field mapping (JSON)"
255
+ @description "JSON object mapping source CSV column names to target field names. Stored verbatim on the batch row (mappingJson) so the consumer's mapping layer applies the same mapping when it persists staged rows into the target entity."
256
+ @synonyms "field mapping, column mapping, map columns, remap fields, csv columns"
257
+ @tier "domain"
258
+ targetEntity : string = ""
259
+ @label "Target entity"
260
+ @description "Name of the consumer organism's entity the staged rows land in after confirm. Declarative contract only — the atom never writes the target entity; the consumer persists on IMPORT_CONFIRMED."
261
+ @synonyms "destination, import into, target type"
262
+ @tier "domain"
263
+ historyWindowDays : number = 0
264
+ @label "Historical data range (days)"
265
+ @description "How many days back an import may reach (0 = unbounded). A declared import-config knob for the consumer's staging/mapping layer — the atom stages whatever rows it is handed and never filters by date."
266
+ @synonyms "history window, historical range, import range, days back, lookback period"
267
+ @tier "policy"
268
+ }
269
+ }
270
+
271
+ page "/data-import" as DataImportPage -> DataImportLifecycle
272
+ }
@@ -0,0 +1,248 @@
1
+ app std-global-search "1.0.0"
2
+ "std-global-search — cross-module federated search. Use when a user voices that one search box should query several modules at once (global search, search everything, unified search, cross-module search, federated search, search across records, omnisearch, one search box, aggregated results, grouped results). One SEARCH event fans out to every module key listed in the `modules` config (comma-separated, deterministic — no registry magic): the trait walks the parsed key list with an internal FAN_OUT_STEP sentinel and emits one scoped MODULE_SEARCH_REQUESTED { moduleKey, queryText, typeFilter, scopeJson } -> external per key, so modules respond in parallel rather than in sequence. Modules answer with SEARCH_RESULTS { moduleKey, resultsJson }; each answer appends a SearchResultGroup to the entity, so the merged view stays grouped by module. Client/person/date/type filters ride `scopeJson` — the atom never interprets them. Every query persists as a SearchQuery row (recent/saved searches) when `saveHistory` is on. The input is the mapped `search-input` type rendered inline with event renamed to SEARCH (the std-geosearch idiom); its native {searchTerm}/{value} payloads are bridged via str/default, so programmatic SEARCH { queryText, typeFilter, scopeJson } emitters and the UI box drive the same arm."
3
+ type SearchResultGroup = { moduleKey : string, resultsJson : string, receivedAt : number }
4
+ orbital GlobalSearchOrbital {
5
+ uses Typography from "std/behaviors/ui-typography"
6
+ uses Spinner from "std/behaviors/ui-spinner"
7
+
8
+ type SEARCH = Event { queryText : string, typeFilter : string, scopeJson : string, value : string, searchTerm : string } "Run a federated query. Canonical payload is { queryText, typeFilter, scopeJson }; value/searchTerm are accepted for the composed search-input's native emissions."
9
+
10
+ type MODULE_SEARCH_REQUESTED = Event { moduleKey : string, queryText : string, typeFilter : string, scopeJson : string } "Scoped fan-out request, one per configured module key. Modules subscribe by moduleKey and answer with SEARCH_RESULTS."
11
+
12
+ type SEARCH_RESULTS = Event { moduleKey : string, resultsJson : string } "One module's answer; resultsJson is the module's serialized hit list, merged into the grouped view under moduleKey."
13
+
14
+ type GLOBAL_SEARCH_COMPLETED = Event { queryText : string, moduleCount : number, respondedCount : number } "Fired when every requested module has answered."
15
+
16
+ type QUERY_SAVED = Event { id : string! } "The SearchQuery row persisted."
17
+
18
+ type QUERY_SAVE_FAILED = Event { error : string! } "Persisting the SearchQuery row failed."
19
+
20
+ type FAN_OUT_STEP = Event { remaining : number } "Internal sentinel: drains pendingModules, one MODULE_SEARCH_REQUESTED per pass. Carries the post-seed/post-drop queue length so the loop guard reads the payload (walker-provable), not the config-seeded entity field."
21
+
22
+ entity SearchQuery [persistent: search_queries] {
23
+ id : string!
24
+ personId : string @description "Owner of this query — the user who ran it." @synonyms "user, owner, searcher, account" = ""
25
+ queryText : string @description "The search text the user typed." @synonyms "query, search term, keywords, search string" = ""
26
+ typeFilter : string @description "Record-type filter applied to the query; blank = all types." @synonyms "type, kind, category filter, record type" = ""
27
+ scopeJson : string @description "Serialized scope filters (client/person/date/type) passed through to modules uninterpreted." @synonyms "filters, scope, search filters, filter json" = ""
28
+ createdAt : number @description "When the query was run." @synonyms "timestamp, searched at, date" = 0
29
+ pendingModules : [string] @description "Scratch: configured module keys still awaiting a fan-out request." @synonyms "remaining modules, queue" = []
30
+ requestedCount : number @description "How many module requests have been emitted for the current query." @synonyms "fanned out, requests sent" = 0
31
+ respondedCount : number @description "How many modules have answered the current query." @synonyms "answers received, responses" = 0
32
+ fanOutDone : boolean @description "Scratch: true once every configured module has been sent its request." @synonyms "fan-out complete, all requested" = false
33
+ searching : boolean @description "True while a query is in flight." @synonyms "loading, in progress, busy" = false
34
+ results : [SearchResultGroup] @description "Merged answers, one entry per responding module, grouped by moduleKey." @synonyms "grouped results, hits, matches, answers" = []
35
+ resultsSummary : string @description "Display rendering of the grouped results: one \"moduleKey: resultsJson\" line per answering module." @synonyms "results text, summary, grouped display" = ""
36
+ savedQueryId : string @description "Id of the persisted SearchQuery row for the current query; blank until saved." @synonyms "query id, saved search id" = ""
37
+ lastError : string @description "Scratch: last failure shown to the user; blank = none." @synonyms "error, failure reason" = ""
38
+ }
39
+
40
+ trait GlobalSearch -> @rebindable SearchQuery [lifecycle, instance, search, discovery] {
41
+ initial: idle
42
+
43
+ state idle {
44
+ INIT -> idle
45
+ (fetch SearchQuery)
46
+ (render-ui main {
47
+ children: [{
48
+ event: SEARCH
49
+ placeholder: @config.placeholder
50
+ type: search-input
51
+ }, <Typography.traits.TypographyRender color={muted} content={"Search across every connected module — results group by module."} linkedEntity={SearchQuery} variant={caption} />]
52
+ direction: vertical
53
+ gap: md
54
+ type: stack
55
+ })
56
+ SEARCH -> searching
57
+ (set @entity.queryText (str/default ?queryText (str/default ?value ?searchTerm)))
58
+ (set @entity.typeFilter (str/default ?typeFilter ""))
59
+ (set @entity.scopeJson (str/default ?scopeJson ""))
60
+ (set @entity.personId @user.id)
61
+ (set @entity.createdAt @now)
62
+ (set @entity.pendingModules (array/filter (array/map (str/split @config.modules ",") (fn m (str/trim @m))) (fn m (!= @m ""))))
63
+ (set @entity.requestedCount 0)
64
+ (set @entity.respondedCount 0)
65
+ (set @entity.fanOutDone false)
66
+ (set @entity.searching true)
67
+ (set @entity.results [])
68
+ (set @entity.resultsSummary "")
69
+ (set @entity.lastError "")
70
+ (when @config.saveHistory
71
+ (persist create SearchQuery {
72
+ createdAt: @entity.createdAt
73
+ personId: @entity.personId
74
+ queryText: @entity.queryText
75
+ scopeJson: @entity.scopeJson
76
+ typeFilter: @entity.typeFilter
77
+ } { emit: { failure: QUERY_SAVE_FAILED, success: QUERY_SAVED } }))
78
+ (when [">", (array/len @entity.pendingModules), 0]
79
+ (emit FAN_OUT_STEP { remaining: (array/len @entity.pendingModules) }))
80
+ (render-ui main {
81
+ children: [{
82
+ event: SEARCH
83
+ placeholder: @config.placeholder
84
+ type: search-input
85
+ }, {
86
+ align: center
87
+ children: [<Spinner.traits.SpinnerRender linkedEntity={SearchQuery} />, <Typography.traits.TypographyRender color={muted} content={"Searching…"} linkedEntity={SearchQuery} variant={caption} />]
88
+ direction: horizontal
89
+ gap: sm
90
+ type: stack
91
+ }]
92
+ direction: vertical
93
+ gap: md
94
+ type: stack
95
+ })
96
+ }
97
+
98
+ state searching {
99
+ SEARCH -> searching
100
+ (set @entity.queryText (str/default ?queryText (str/default ?value ?searchTerm)))
101
+ (set @entity.typeFilter (str/default ?typeFilter ""))
102
+ (set @entity.scopeJson (str/default ?scopeJson ""))
103
+ (set @entity.personId @user.id)
104
+ (set @entity.createdAt @now)
105
+ (set @entity.pendingModules (array/filter (array/map (str/split @config.modules ",") (fn m (str/trim @m))) (fn m (!= @m ""))))
106
+ (set @entity.requestedCount 0)
107
+ (set @entity.respondedCount 0)
108
+ (set @entity.fanOutDone false)
109
+ (set @entity.searching true)
110
+ (set @entity.results [])
111
+ (set @entity.resultsSummary "")
112
+ (set @entity.lastError "")
113
+ (when @config.saveHistory
114
+ (persist create SearchQuery {
115
+ createdAt: @entity.createdAt
116
+ personId: @entity.personId
117
+ queryText: @entity.queryText
118
+ scopeJson: @entity.scopeJson
119
+ typeFilter: @entity.typeFilter
120
+ } { emit: { failure: QUERY_SAVE_FAILED, success: QUERY_SAVED } }))
121
+ (when [">", (array/len @entity.pendingModules), 0]
122
+ (emit FAN_OUT_STEP { remaining: (array/len @entity.pendingModules) }))
123
+ FAN_OUT_STEP -> searching when [">", ?remaining, 0]
124
+ (emit MODULE_SEARCH_REQUESTED {
125
+ moduleKey: (array/first @entity.pendingModules)
126
+ queryText: @entity.queryText
127
+ scopeJson: @entity.scopeJson
128
+ typeFilter: @entity.typeFilter
129
+ })
130
+ (set @entity.requestedCount ["+", @entity.requestedCount, 1])
131
+ (set @entity.pendingModules (array/drop @entity.pendingModules 1))
132
+ (when ["=", (array/len @entity.pendingModules), 0]
133
+ (set @entity.fanOutDone true))
134
+ (when [">", (array/len @entity.pendingModules), 0]
135
+ (emit FAN_OUT_STEP { remaining: (array/len @entity.pendingModules) }))
136
+ SEARCH_RESULTS -> searching
137
+ (set @entity.respondedCount ["+", @entity.respondedCount, 1])
138
+ (set @entity.results (array/append @entity.results {
139
+ moduleKey: ?moduleKey
140
+ receivedAt: @now
141
+ resultsJson: ?resultsJson
142
+ }))
143
+ (set @entity.resultsSummary (str/concat @entity.resultsSummary (str/concat ?moduleKey (str/concat ": " (str/concat ?resultsJson "\n")))))
144
+ (render-ui main {
145
+ children: [{
146
+ event: SEARCH
147
+ placeholder: @config.placeholder
148
+ type: search-input
149
+ }, <Typography.traits.TypographyRender content={@entity.resultsSummary} linkedEntity={SearchQuery} variant={body} />]
150
+ direction: vertical
151
+ gap: md
152
+ type: stack
153
+ })
154
+ (when (and @entity.fanOutDone [">=", @entity.respondedCount, @entity.requestedCount])
155
+ (do
156
+ (set @entity.searching false)
157
+ (emit GLOBAL_SEARCH_COMPLETED {
158
+ moduleCount: @entity.requestedCount
159
+ queryText: @entity.queryText
160
+ respondedCount: @entity.respondedCount
161
+ })))
162
+ QUERY_SAVED -> searching
163
+ (set @entity.savedQueryId ?id)
164
+ QUERY_SAVE_FAILED -> searching
165
+ (set @entity.lastError ?error)
166
+ (notify warning "Could not save the search to history")
167
+ }
168
+
169
+ emits {
170
+ SEARCH -> external {
171
+ queryText : string
172
+ typeFilter : string
173
+ scopeJson : string
174
+ value : string
175
+ searchTerm : string
176
+ }
177
+ @description "The query as submitted — re-broadcast so downstream listeners (recent-searches trays, analytics) can react without intercepting the input"
178
+ @tier "essential"
179
+ MODULE_SEARCH_REQUESTED -> external {
180
+ moduleKey : string
181
+ queryText : string
182
+ typeFilter : string
183
+ scopeJson : string
184
+ }
185
+ @description "One per configured module key — the module owning that key runs its own search and answers with SEARCH_RESULTS"
186
+ @tier "essential"
187
+ GLOBAL_SEARCH_COMPLETED -> external {
188
+ queryText : string
189
+ moduleCount : number
190
+ respondedCount : number
191
+ }
192
+ @description "Every requested module has answered; the grouped view is final"
193
+ @tier "secondary"
194
+ QUERY_SAVED -> external {
195
+ id : string!
196
+ }
197
+ @description "The query persisted to search history"
198
+ @tier "secondary"
199
+ QUERY_SAVE_FAILED -> external {
200
+ error : string!
201
+ }
202
+ @description "Persisting the query failed — the search itself is unaffected"
203
+ @tier "secondary"
204
+ FAN_OUT_STEP {
205
+ remaining : number
206
+ }
207
+ @description "Internal sentinel draining the module queue, one MODULE_SEARCH_REQUESTED per pass; `remaining` is the queue length after the last seed/drop"
208
+ @tier "internal"
209
+ }
210
+
211
+ listens {
212
+ SEARCH {
213
+ queryText : string
214
+ typeFilter : string
215
+ scopeJson : string
216
+ value : string
217
+ searchTerm : string
218
+ }
219
+ SEARCH_RESULTS {
220
+ moduleKey : string
221
+ resultsJson : string
222
+ }
223
+ FAN_OUT_STEP {
224
+ remaining : number
225
+ }
226
+ }
227
+
228
+ config {
229
+ modules : string = ""
230
+ @label "Modules to search"
231
+ @description "Comma-separated module keys to fan each query out to (e.g. \"contacts,projects,invoices\"). Each key gets one scoped MODULE_SEARCH_REQUESTED; the module owning a key subscribes to it and answers with SEARCH_RESULTS. Empty = no fan-out (the query is still persisted to history)."
232
+ @synonyms "search modules, search scope, sources, providers, search providers, indexes, search targets, modules"
233
+ @tier "internal"
234
+ saveHistory : boolean = true
235
+ @label "Keep recent searches"
236
+ @description "Persist each query as a SearchQuery row so recent and saved searches survive reloads."
237
+ @synonyms "search history, recent searches, saved searches, remember searches, keep history"
238
+ @tier "policy"
239
+ placeholder : string = "Search across modules…"
240
+ @label "Search placeholder"
241
+ @description "Hint text in the search input."
242
+ @synonyms "placeholder, hint text, input hint"
243
+ @tier "presentation"
244
+ }
245
+ }
246
+
247
+ page "/global-search" as GlobalSearchPage -> GlobalSearch
248
+ }