@almadar/std 16.216.0 → 16.217.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.
- package/behaviors/lolo/infra/atoms/std-data-import.lolo +272 -0
- package/behaviors/lolo/infra/atoms/std-global-search.lolo +248 -0
- package/behaviors/lolo/infra/atoms/std-notification-preferences.lolo +526 -0
- package/behaviors/lolo/infra/atoms/std-permission-matrix.lolo +522 -0
- package/behaviors/lolo/infra/atoms/std-reminder-scheduler.lolo +11 -3
- package/behaviors/registry/infra/atoms/std-data-import.orb +1164 -0
- package/behaviors/registry/infra/atoms/std-global-search.orb +1417 -0
- package/behaviors/registry/infra/atoms/std-notification-preferences.orb +2404 -0
- package/behaviors/registry/infra/atoms/std-permission-matrix.orb +2229 -0
- package/behaviors/registry/infra/atoms/std-reminder-scheduler.orb +144 -43
- package/dist/behaviors/exports-reader.js +13208 -5484
- package/dist/behaviors/functions/index.d.ts +765 -7
- package/dist/behaviors/functions/index.js +13170 -5485
- package/dist/behaviors/index.d.ts +1 -1
- package/dist/behaviors/index.js +13209 -5485
- package/dist/behaviors/query.js +13208 -5484
- package/dist/behaviors-embeddings.hash.json +3 -3
- package/dist/behaviors-embeddings.json +17 -1
- package/dist/behaviors-registry.json +3496 -681
- package/dist/index.d.ts +1 -1
- package/dist/index.js +13209 -5485
- package/dist/knob-embeddings.hash.json +3 -3
- package/dist/knob-embeddings.json +1 -1
- package/dist/registry/factory-signatures.json +1 -1
- package/package.json +1 -1
|
@@ -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
|
+
}
|