@almadar/std 16.180.0 → 16.182.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 (81) hide show
  1. package/behaviors/lolo/infra/atoms/std-calendar-sync.lolo +290 -0
  2. package/behaviors/lolo/infra/atoms/std-notification-center.lolo +290 -0
  3. package/behaviors/lolo/infra/atoms/std-notify-on-event.lolo +238 -40
  4. package/behaviors/lolo/ui/core/atoms/std-esign-flow.lolo +180 -3
  5. package/behaviors/lolo/ui/core/atoms/ui-badge.lolo +1 -9
  6. package/behaviors/lolo/ui/core/atoms/ui-filter-pill.lolo +2 -25
  7. package/behaviors/lolo/ui/core/molecules/ui-calendar-grid.lolo +6 -0
  8. package/behaviors/lolo/ui/core/organisms/ui-detail-panel.lolo +4 -4
  9. package/behaviors/lolo/ui/game/.hand-authored.json +1 -1
  10. package/behaviors/lolo/ui/game/atoms/ui-control-grid.lolo +5 -0
  11. package/behaviors/lolo/ui/game/atoms/ui-game-hud.lolo +5 -0
  12. package/behaviors/lolo/ui/game/atoms/ui-trait-slot.lolo +2 -36
  13. package/behaviors/lolo/ui/game/templates/ui-game-shell.lolo +5 -5
  14. package/behaviors/registry/infra/atoms/std-calendar-sync.orb +1643 -0
  15. package/behaviors/registry/infra/atoms/std-notification-center.orb +1621 -0
  16. package/behaviors/registry/infra/atoms/std-notify-on-event.orb +1734 -169
  17. package/behaviors/registry/ui/core/atoms/std-esign-flow.orb +846 -2
  18. package/behaviors/registry/ui/core/atoms/ui-badge.orb +1 -38
  19. package/behaviors/registry/ui/core/atoms/ui-filter-pill.orb +2 -90
  20. package/behaviors/registry/ui/core/molecules/ui-calendar-grid.orb +9 -0
  21. package/behaviors/registry/ui/core/organisms/ui-detail-panel.orb +4 -2
  22. package/behaviors/registry/ui/game/atoms/ui-control-grid.orb +13 -1
  23. package/behaviors/registry/ui/game/atoms/ui-game-hud.orb +13 -1
  24. package/behaviors/registry/ui/game/atoms/ui-trait-slot.orb +2 -101
  25. package/behaviors/registry/ui/game/templates/ui-game-shell.orb +5 -5
  26. package/canonical-operators.json +21 -1
  27. package/dist/behaviors/exports-reader.js +8303 -3043
  28. package/dist/behaviors/functions/index.d.ts +476 -36
  29. package/dist/behaviors/functions/index.js +18352 -13109
  30. package/dist/behaviors/index.d.ts +1 -1
  31. package/dist/behaviors/index.js +8304 -3044
  32. package/dist/behaviors/query.js +8303 -3043
  33. package/dist/behaviors-embeddings.hash.json +3 -3
  34. package/dist/behaviors-embeddings.json +365 -357
  35. package/dist/behaviors-registry.json +1170 -29
  36. package/dist/index.d.ts +3 -3
  37. package/dist/index.js +8275 -2978
  38. package/dist/knob-embeddings.hash.json +3 -3
  39. package/dist/knob-embeddings.json +1 -1
  40. package/dist/modules/anim.d.ts +1 -1
  41. package/dist/modules/array.d.ts +1 -1
  42. package/dist/modules/async.d.ts +1 -1
  43. package/dist/modules/behavior.d.ts +1 -1
  44. package/dist/modules/browser.d.ts +1 -1
  45. package/dist/modules/browser.js +21 -0
  46. package/dist/modules/composition.d.ts +1 -1
  47. package/dist/modules/contract.d.ts +1 -1
  48. package/dist/modules/core.d.ts +1 -1
  49. package/dist/modules/core.js +18 -2
  50. package/dist/modules/data.d.ts +1 -1
  51. package/dist/modules/ease.d.ts +1 -1
  52. package/dist/modules/format.d.ts +1 -1
  53. package/dist/modules/geo.d.ts +1 -1
  54. package/dist/modules/graph.d.ts +1 -1
  55. package/dist/modules/grid.d.ts +1 -1
  56. package/dist/modules/index.d.ts +1 -1
  57. package/dist/modules/index.js +39 -2
  58. package/dist/modules/integration.d.ts +1 -1
  59. package/dist/modules/llm.d.ts +1 -1
  60. package/dist/modules/math.d.ts +1 -1
  61. package/dist/modules/memory.d.ts +1 -1
  62. package/dist/modules/nn.d.ts +1 -1
  63. package/dist/modules/noise.d.ts +1 -1
  64. package/dist/modules/object.d.ts +1 -1
  65. package/dist/modules/os.d.ts +1 -1
  66. package/dist/modules/path.d.ts +1 -1
  67. package/dist/modules/prob.d.ts +1 -1
  68. package/dist/modules/session.d.ts +1 -1
  69. package/dist/modules/str.d.ts +1 -1
  70. package/dist/modules/tensor.d.ts +1 -1
  71. package/dist/modules/time.d.ts +1 -1
  72. package/dist/modules/trace.d.ts +1 -1
  73. package/dist/modules/train.d.ts +1 -1
  74. package/dist/modules/validate.d.ts +1 -1
  75. package/dist/modules/vector.d.ts +1 -1
  76. package/dist/modules/workspace.d.ts +1 -1
  77. package/dist/registry/factory-signatures.json +1 -1
  78. package/dist/registry.d.ts +1 -1
  79. package/dist/registry.js +39 -2
  80. package/dist/{types-DGJ6Y6nW.d.ts → types-BXWzEjFm.d.ts} +1 -1
  81. package/package.json +3 -3
@@ -0,0 +1,290 @@
1
+ app std-calendar-sync "1.0.0"
2
+ "std-calendar-sync — two-way Google Calendar synchronization engine. PUSH: a 15-minute cron scan (and the user-addressable SYNC_NOW) walks `targetEntity` rows whose `externalEventId` marker is blank and creates a remote event per row through calendar.createEvent, reading the summary/start/end via the configured field mapping (`titleField`/`startField`/`endField`/`durationMinutes` fallback/`locationField`/`descriptionField`) — so ANY event-shaped host entity pushes, provided it declares the two marker columns `externalEventId : string` and `syncedAt : number` (the remindedAt precedent). PULL: `CAL_REMOTE_CHANGED` (delivered by the shared /api/hooks/google-calendar ingress from a calendar.watch channel) or the same cycle after a clean push lists remote events (incremental via the in-memory sync token; a restart falls back to a full window list) and upserts each into the atom's own canonical `CalendarEvent` store, matched by `externalEventId` — pulls land in the canonical store, NOT the arbitrary target, because persist keys are static in the language; bind calendar UI to `CalendarEvent` to surface remote events. Conflict detection (local-newer-than-remote) needs a local-modified marker convention and is a recorded gap, not a decorative state. Composes with std-notify-on-event via CAL_SYNCED / CAL_SYNC_FAILED."
3
+ type RemoteEvent = { id : string, summary : string, description : string, location : string, start : string, end : string, status : string, updated : string }
4
+ type TargetRow = { id : string, externalEventId : string }
5
+ orbital CalendarSyncOrbital {
6
+ type CAL_REMOTE_CHANGED = Event { channelId : string, resourceId : string, resourceState : string } "Google watch-channel push notification, delivered by the /api/hooks/google-calendar ingress."
7
+
8
+ entity CalendarSyncRun [persistent: calendarSyncState] {
9
+ id : string!
10
+ syncToken : string @description "Scratch: Google incremental-sync token from the last listEvents; in-memory per instance — a restart falls back to a full list." @synonyms "sync cursor, next token" = ""
11
+ lastSyncedAt : number @description "When the last full push+pull cycle completed; 0 = never." @synonyms "last sync time" = 0
12
+ headId : string @description "Scratch: id of the target row the push loop is currently exporting." @synonyms "cursor, current row" = ""
13
+ pending : [RemoteEvent] @description "Scratch: remote events remaining in the current pull-upsert loop." @synonyms "pull queue, remaining events" = []
14
+ lastError : string @description "Last sync failure detail; blank = none." @synonyms "sync error, failure" = ""
15
+ }
16
+
17
+ entity CalendarEvent [persistent: calendarEvents] {
18
+ id : string!
19
+ title : string @description "Event title/summary." @synonyms "summary, name, subject" = ""
20
+ startsAt : string @description "ISO start (dateTime, or YYYY-MM-DD for all-day)." @synonyms "start, begins, from" = ""
21
+ endsAt : string @description "ISO end (dateTime, or YYYY-MM-DD for all-day)." @synonyms "end, finishes, until" = ""
22
+ location : string @description "Event location." @synonyms "place, where, venue" = ""
23
+ description : string @description "Event description/notes." @synonyms "notes, details, body" = ""
24
+ status : string @description "Remote status (confirmed/tentative/cancelled)." @synonyms "state, confirmation" = ""
25
+ externalEventId : string @description "Google Calendar event id this row mirrors; the pull-upsert match key." @synonyms "remote id, google id, calendar event id" = ""
26
+ syncedAt : number @description "When this row last synced with the remote calendar; 0 = never." @synonyms "last sync, synced" = 0
27
+ }
28
+
29
+ trait CalendarSync -> CalendarSyncRun [lifecycle, instance] {
30
+ initial: idle
31
+
32
+ state idle {
33
+ INIT -> idle
34
+ SYNC_NOW -> idle when @config.enabled
35
+ (fetch @config.targetEntity {
36
+ emit: { failure: CAL_SYNC_FAILED, success: PUSH_ROWS_LOADED }
37
+ filter: ["=", (object/get @entity externalEventId), ""]
38
+ })
39
+ CAL_REMOTE_CHANGED -> idle when @config.enabled
40
+ (call-service "calendar" "listEvents" {
41
+ calendarId: @config.calendarId
42
+ syncToken: @entity.syncToken
43
+ } { emit: { failure: CAL_SYNC_FAILED, success: CAL_REMOTE_LISTED } })
44
+ PUSH_ROWS_LOADED -> pushing when (and @config.enabled [">", (array/len ?data), 0])
45
+ (set @entity.headId (object/get (array/first ?data) id))
46
+ (call-service "calendar" "createEvent" {
47
+ calendarId: @config.calendarId
48
+ summary: (object/get (array/first ?data) @config.titleField)
49
+ description: (if ["=", @config.descriptionField, ""] "" (object/get (array/first ?data) @config.descriptionField))
50
+ location: (if ["=", @config.locationField, ""] "" (object/get (array/first ?data) @config.locationField))
51
+ start: (object/get (array/first ?data) @config.startField)
52
+ end: (if ["=", @config.endField, ""] "" (object/get (array/first ?data) @config.endField))
53
+ durationMinutes: (if ["=", @config.durationField, ""] @config.durationMinutes (object/get (array/first ?data) @config.durationField))
54
+ } { emit: { failure: CAL_SYNC_FAILED, success: CAL_EVENT_CREATED } })
55
+ PUSH_ROWS_LOADED -> idle when ["=", (array/len ?data), 0]
56
+ (call-service "calendar" "listEvents" {
57
+ calendarId: @config.calendarId
58
+ syncToken: @entity.syncToken
59
+ } { emit: { failure: CAL_SYNC_FAILED, success: CAL_REMOTE_LISTED } })
60
+ CAL_REMOTE_LISTED -> pulling when [">", (array/len ?events), 0]
61
+ (set @entity.pending ?events)
62
+ (set @entity.syncToken (str/default ?nextSyncToken ""))
63
+ (fetch CalendarEvent {
64
+ emit: { failure: CAL_SYNC_FAILED, success: PULL_TARGET_LOADED }
65
+ })
66
+ CAL_REMOTE_LISTED -> idle when ["=", (array/len ?events), 0]
67
+ (set @entity.syncToken (str/default ?nextSyncToken ""))
68
+ (set @entity.lastSyncedAt @now)
69
+ (emit CAL_SYNCED { syncedAt: @entity.lastSyncedAt })
70
+ CAL_SYNC_FAILED -> idle
71
+ (set @entity.lastError ?error)
72
+ }
73
+
74
+ state pushing {
75
+ CAL_EVENT_CREATED -> pushing
76
+ (persist update @config.targetEntity { id: @entity.headId, externalEventId: ?id, syncedAt: @now } {
77
+ emit: { failure: CAL_SYNC_FAILED, success: PUSH_STEPPED }
78
+ })
79
+ PUSH_STEPPED -> pushing
80
+ (fetch @config.targetEntity {
81
+ emit: { failure: CAL_SYNC_FAILED, success: PUSH_ROWS_LOADED }
82
+ filter: ["=", (object/get @entity externalEventId), ""]
83
+ })
84
+ PUSH_ROWS_LOADED -> pushing when [">", (array/len ?data), 0]
85
+ (set @entity.headId (object/get (array/first ?data) id))
86
+ (call-service "calendar" "createEvent" {
87
+ calendarId: @config.calendarId
88
+ summary: (object/get (array/first ?data) @config.titleField)
89
+ description: (if ["=", @config.descriptionField, ""] "" (object/get (array/first ?data) @config.descriptionField))
90
+ location: (if ["=", @config.locationField, ""] "" (object/get (array/first ?data) @config.locationField))
91
+ start: (object/get (array/first ?data) @config.startField)
92
+ end: (if ["=", @config.endField, ""] "" (object/get (array/first ?data) @config.endField))
93
+ durationMinutes: (if ["=", @config.durationField, ""] @config.durationMinutes (object/get (array/first ?data) @config.durationField))
94
+ } { emit: { failure: CAL_SYNC_FAILED, success: CAL_EVENT_CREATED } })
95
+ PUSH_ROWS_LOADED -> idle when ["=", (array/len ?data), 0]
96
+ (call-service "calendar" "listEvents" {
97
+ calendarId: @config.calendarId
98
+ syncToken: @entity.syncToken
99
+ } { emit: { failure: CAL_SYNC_FAILED, success: CAL_REMOTE_LISTED } })
100
+ CAL_SYNC_FAILED -> idle
101
+ (set @entity.lastError ?error)
102
+ }
103
+
104
+ state pulling {
105
+ PULL_TARGET_LOADED -> pulling
106
+ (set @entity.headId (object/get (array/first @entity.pending) id))
107
+ (if ["=", (array/len (array/filter ?data (fn r ["=", (object/get @r externalEventId), @entity.headId]))), 0] (persist create CalendarEvent {
108
+ title: (object/get (array/first @entity.pending) summary)
109
+ startsAt: (object/get (array/first @entity.pending) start)
110
+ endsAt: (object/get (array/first @entity.pending) end)
111
+ location: (object/get (array/first @entity.pending) location)
112
+ description: (object/get (array/first @entity.pending) description)
113
+ status: (object/get (array/first @entity.pending) status)
114
+ externalEventId: @entity.headId
115
+ syncedAt: @now
116
+ } { emit: { failure: CAL_SYNC_FAILED, success: PULL_STEPPED } }) (persist update CalendarEvent {
117
+ id: (object/get (array/first (array/filter ?data (fn r ["=", (object/get @r externalEventId), @entity.headId]))) id)
118
+ title: (object/get (array/first @entity.pending) summary)
119
+ startsAt: (object/get (array/first @entity.pending) start)
120
+ endsAt: (object/get (array/first @entity.pending) end)
121
+ location: (object/get (array/first @entity.pending) location)
122
+ description: (object/get (array/first @entity.pending) description)
123
+ status: (object/get (array/first @entity.pending) status)
124
+ syncedAt: @now
125
+ } { emit: { failure: CAL_SYNC_FAILED, success: PULL_STEPPED } }))
126
+ PULL_STEPPED -> pulling
127
+ (set @entity.pending (array/drop @entity.pending 1))
128
+ (emit PULL_NEXT {
129
+ remaining: (array/len @entity.pending)
130
+ })
131
+ PULL_NEXT -> pulling when [">", ?remaining, 0]
132
+ (fetch CalendarEvent {
133
+ emit: { failure: CAL_SYNC_FAILED, success: PULL_TARGET_LOADED }
134
+ })
135
+ PULL_NEXT -> idle when ["=", ?remaining, 0]
136
+ (set @entity.lastSyncedAt @now)
137
+ (emit CAL_SYNCED { syncedAt: @entity.lastSyncedAt })
138
+ CAL_SYNC_FAILED -> idle
139
+ (set @entity.lastError ?error)
140
+ }
141
+
142
+ emits {
143
+ CAL_SYNCED -> external {
144
+ syncedAt : number
145
+ }
146
+ @description "A full push+pull cycle completed; wire to std-notify-on-event or a status surface."
147
+ CAL_SYNC_FAILED {
148
+ error : string!
149
+ }
150
+ @description "A calendar service call / fetch / persist failed — lastError carries the detail and the cycle stops until the next trigger."
151
+ @tier "secondary"
152
+ PUSH_ROWS_LOADED {
153
+ data : [TargetRow]
154
+ }
155
+ @description "Target rows with a blank externalEventId marker — the remaining local→remote export queue."
156
+ @tier "secondary"
157
+ CAL_EVENT_CREATED {
158
+ id : string!
159
+ status : string!
160
+ htmlLink : string!
161
+ }
162
+ @description "calendar.createEvent result, flattened onto the payload by the call-service effect executor."
163
+ @tier "secondary"
164
+ PUSH_STEPPED {
165
+ id : string!
166
+ }
167
+ @description "Target row stamped with its new externalEventId; drives the next push pass."
168
+ @tier "secondary"
169
+ CAL_REMOTE_LISTED {
170
+ events : [RemoteEvent]
171
+ nextSyncToken : string
172
+ }
173
+ @description "calendar.listEvents result — the remote change set for the pull-upsert loop."
174
+ @tier "secondary"
175
+ PULL_TARGET_LOADED {
176
+ data : [TargetRow]
177
+ }
178
+ @description "CalendarEvent rows loaded for externalEventId matching in the pull-upsert."
179
+ @tier "secondary"
180
+ PULL_STEPPED {
181
+ id : string!
182
+ }
183
+ @description "One remote event upserted into the CalendarEvent store; drives the next pull pass."
184
+ @tier "secondary"
185
+ PULL_NEXT {
186
+ remaining : number
187
+ }
188
+ @description "Pull loop control: how many remote events remain in the queue."
189
+ @tier "secondary"
190
+ }
191
+
192
+ listens {
193
+ SYNC_NOW {
194
+ }
195
+ CAL_REMOTE_CHANGED {
196
+ channelId : string
197
+ resourceId : string
198
+ resourceState : string
199
+ }
200
+ PUSH_ROWS_LOADED {
201
+ data : [TargetRow]
202
+ }
203
+ CAL_EVENT_CREATED {
204
+ id : string!
205
+ status : string!
206
+ htmlLink : string!
207
+ }
208
+ PUSH_STEPPED {
209
+ id : string!
210
+ }
211
+ CAL_REMOTE_LISTED {
212
+ events : [RemoteEvent]
213
+ nextSyncToken : string
214
+ }
215
+ PULL_TARGET_LOADED {
216
+ data : [TargetRow]
217
+ }
218
+ PULL_STEPPED {
219
+ id : string!
220
+ }
221
+ PULL_NEXT {
222
+ remaining : number
223
+ }
224
+ }
225
+
226
+ ticks {
227
+ syncCycle every "*/15 * * * *"
228
+ (fetch @config.targetEntity {
229
+ emit: { failure: CAL_SYNC_FAILED, success: PUSH_ROWS_LOADED }
230
+ filter: ["=", (object/get @entity externalEventId), ""]
231
+ })
232
+
233
+ }
234
+
235
+ config {
236
+ enabled : boolean = false
237
+ @label "Sync with Google Calendar"
238
+ @description "Push local records to Google Calendar and pull remote events into the CalendarEvent store. Off by default; needs the calendar service credentials (GOOGLE_CALENDAR_SA_KEY)."
239
+ @synonyms "enable, calendar sync, google calendar, two-way sync, sync events, connect calendar"
240
+ @tier "infra"
241
+ targetEntity : entity = CalendarEvent
242
+ @label "Entity to push"
243
+ @description "Event-shaped entity whose rows export to Google Calendar. MUST declare the marker columns `externalEventId : string = \"\"` and `syncedAt : number = 0` (stamped by the push loop). Defaults to the atom's own CalendarEvent store so the standalone validates; rebind per composition."
244
+ @synonyms "entity to sync, source entity, record type, appointments entity, sessions entity"
245
+ @tier "internal"
246
+ calendarId : string = ""
247
+ @label "Calendar id"
248
+ @description "Google calendar to sync with; blank = the service's default (GOOGLE_CALENDAR_ID env, else primary)."
249
+ @synonyms "google calendar id, target calendar"
250
+ @tier "infra"
251
+ titleField : string = title
252
+ @label "Title field"
253
+ @description "Target-entity field holding the event title."
254
+ @synonyms "summary field, name field"
255
+ @tier "internal"
256
+ startField : string = startsAt
257
+ @label "Start field"
258
+ @description "Target-entity field holding the ISO start (dateTime, or YYYY-MM-DD for all-day)."
259
+ @synonyms "start date field, begins field, scheduledAt field"
260
+ @tier "internal"
261
+ endField : string = endsAt
262
+ @label "End field"
263
+ @description "Target-entity field holding the ISO end. Blank = derive the end from `durationMinutes` instead (the corpus carries all four event shapes)."
264
+ @synonyms "end date field, finishes field"
265
+ @tier "internal"
266
+ durationMinutes : number = 60
267
+ @label "Duration (minutes)"
268
+ @description "Fixed event length used when both `endField` and `durationField` are blank; the calendar service computes the timezone-correct end."
269
+ @synonyms "length, event duration, minutes"
270
+ @tier "internal"
271
+ durationField : string = ""
272
+ @label "Duration field"
273
+ @description "Target-entity field holding the per-row duration in minutes (e.g. ClassSession.durationMinutes). Takes precedence over the fixed `durationMinutes` when set; ignored when `endField` is set. Field precedence: endField > durationField > durationMinutes — the corpus carries all these event shapes."
274
+ @synonyms "minutes field, length field, per-row duration"
275
+ @tier "internal"
276
+ locationField : string = ""
277
+ @label "Location field"
278
+ @description "Target-entity field holding the location; blank = none."
279
+ @synonyms "place field, venue field"
280
+ @tier "internal"
281
+ descriptionField : string = ""
282
+ @label "Description field"
283
+ @description "Target-entity field holding the description/notes; blank = none."
284
+ @synonyms "notes field, details field"
285
+ @tier "internal"
286
+ }
287
+ }
288
+
289
+ page "/calendar-sync" as CalendarSyncPage -> CalendarSync
290
+ }
@@ -0,0 +1,290 @@
1
+ app std-notification-center "1.0.0"
2
+ "std-notification-center — per-user notification preferences, web-push subscriptions, and the digest drain for suppressed notifications. Owns the two owner-scoped stores std-notify-on-event consults at dispatch time: NotificationPreference rows (per event key: mode instant/digest/off, channel override, per-user quiet hours) and PushSubscription rows (captured via the browser/push-subscribe flow — the shared service worker + PushManager subscription, persisted as flat endpoint/p256dh/auth credentials). The DigestDrain trait is the delivery half of every suppression std-notify-on-event records: an hourly cron tick scans NotificationRecord audit rows left undispatched for digest/quiet-hours reasons and at least `minAgeHours` old, delivers each by email (the digest channel), and marks it dispatched — the reminder-scheduler self-driving scan loop verbatim. Compose alongside std-notify-on-event: the listener suppresses and records, this atom captures subscriptions, edits preferences, and drains."
3
+ type NotifRow = { id : string, recipient : string, channel : string, message : string, severity : string, dispatched : boolean, error : string, recordedAt : number }
4
+ type PushCreds = { endpoint : string, p256dh : string, auth : string }
5
+ orbital NotificationCenterOrbital {
6
+ type PUSH_SUBSCRIBED = Event { result : PushCreds } "browser/push-subscribe resolved — the flat subscription credentials arrive in `result`."
7
+
8
+ entity NotificationPreference [persistent: notificationPreferences] {
9
+ id : string!
10
+ userId : string @description "Owner of this preference row — rows are meant to be private to their owner. Access predicates need an [identity] roster, so the binding organism (which declares one) owns the @read/@update scoping; the generic atom cannot." @synonyms "user, owner, account" = ""
11
+ eventType : string @description "Event key this preference applies to (matches a listener's eventKey); blank = all events." @synonyms "event, trigger, subscription, topic" = ""
12
+ mode : "instant" | "digest" | "off" @description "Delivery mode: instant (default), digest (deferred to the drain cycle), or off (muted)." @synonyms "delivery mode, frequency, cadence, mute, defer" = instant
13
+ channel : string @description "Channel override for this user (in-app/email/sms/push/webhook); blank = the listener's configured channel." @synonyms "preferred channel, delivery method" = ""
14
+ quietStartHour : number @description "Per-user quiet-hours start (hour 0-23); equal to quietEndHour = disabled." @synonyms "quiet start, do not disturb from" = 0
15
+ quietEndHour : number @description "Per-user quiet-hours end (hour 0-23); equal to quietStartHour = disabled." @synonyms "quiet end, do not disturb until" = 0
16
+ }
17
+
18
+ entity PushSubscription [persistent: pushSubscriptions] {
19
+ id : string!
20
+ userId : string @description "Owner of this browser push subscription. Owner-scoped access predicates belong to the binding organism's [identity] roster, as with NotificationPreference." @synonyms "user, owner, account" = ""
21
+ endpoint : string @description "Push service endpoint URL for this subscription." @synonyms "push url, subscription endpoint" = ""
22
+ p256dh : string @description "Client public key (p256dh) of the subscription." @synonyms "public key" = ""
23
+ auth : string @description "Auth secret of the subscription." @synonyms "auth secret" = ""
24
+ createdAt : number @description "When this subscription was captured." @synonyms "subscribed at, created" = 0
25
+ lastError : string @description "Scratch: last subscribe-flow failure shown to the user; blank = none." @synonyms "subscribe error, failure reason" = ""
26
+ }
27
+
28
+ entity NotificationRecord [persistent: notifications] {
29
+ id : string!
30
+ recipient : string @description "The identifier of the entity receiving the notification." @synonyms "user, target, receiver, destination" = ""
31
+ channel : string @description "The delivery method for the notification (e.g., email, SMS, in-app)." @synonyms "delivery, medium, type" = in-app
32
+ message : string @description "The content of the notification message." @synonyms "text, content, body" = ""
33
+ severity : string @description "Indicates the importance or urgency level of the notification." @synonyms "priority, level, importance" = info
34
+ dispatched : boolean @description "Indicates whether the notification has been successfully sent." @synonyms "sent, delivered, status" = false
35
+ error : string @description "Suppression reason or delivery failure detail when dispatched is false." @synonyms "error message, failure reason, dispatch error" = ""
36
+ recordedAt : number @description "Timestamp indicating when the notification record was created." @synonyms "creation time, timestamp, date, when" = 0
37
+ }
38
+
39
+ entity DigestRunView [runtime] {
40
+ id : string!
41
+ headId : string @description "Scratch: id of the audit row the drain loop is currently delivering." @synonyms "current id, cursor" = ""
42
+ headRecipient : string @description "Scratch: recipient of the row being delivered." @synonyms "current recipient" = ""
43
+ headMessage : string @description "Scratch: message of the row being delivered." @synonyms "current message" = ""
44
+ drainedCount : number @description "Number of rows delivered in the current drain cycle." @synonyms "delivered count, processed" = 0
45
+ lastError : string @description "Last drain failure; blank = none." @synonyms "drain error, failure" = ""
46
+ }
47
+
48
+ trait PushSubscriber -> PushSubscription [lifecycle, instance] {
49
+ initial: idle
50
+
51
+ state idle {
52
+ SUBSCRIBE_PUSH -> idle
53
+ (browser/push-subscribe { emit: { failure: PUSH_SUBSCRIBE_FAILED, success: PUSH_SUBSCRIBED } })
54
+ PUSH_SUBSCRIBED -> idle
55
+ (set @entity.lastError "")
56
+ (persist create PushSubscription {
57
+ auth: ?result.auth
58
+ createdAt: @now
59
+ endpoint: ?result.endpoint
60
+ p256dh: ?result.p256dh
61
+ userId: @user.id
62
+ } { emit: { failure: PUSH_SAVE_FAILED, success: PUSH_SAVED } })
63
+ PUSH_SAVED -> idle
64
+ (notify success "Push notifications enabled on this browser")
65
+ PUSH_SUBSCRIBE_FAILED -> idle
66
+ (set @entity.lastError ?error)
67
+ (notify warning "Could not enable push notifications")
68
+ PUSH_SAVE_FAILED -> idle
69
+ (set @entity.lastError ?error)
70
+ (notify warning "Could not save the push subscription")
71
+ }
72
+
73
+ emits {
74
+ PUSH_SUBSCRIBED {
75
+ result : PushCreds
76
+ }
77
+ @description "browser/push-subscribe resolved with the flat subscription credentials in `result`"
78
+ @tier "secondary"
79
+ PUSH_SUBSCRIBE_FAILED {
80
+ error : string!
81
+ }
82
+ @description "Push API unavailable, permission denied, or the host has no VAPID key"
83
+ @tier "secondary"
84
+ PUSH_SAVED {
85
+ id : string!
86
+ }
87
+ @description "Subscription row persisted for the current user"
88
+ @tier "secondary"
89
+ PUSH_SAVE_FAILED {
90
+ error : string!
91
+ }
92
+ @description "Persisting the subscription row failed"
93
+ @tier "secondary"
94
+ }
95
+
96
+ listens {
97
+ SUBSCRIBE_PUSH {
98
+ }
99
+ }
100
+ }
101
+
102
+ trait PreferenceEditor -> NotificationPreference [lifecycle, instance] {
103
+ initial: idle
104
+
105
+ state idle {
106
+ SAVE_PREFERENCE -> idle
107
+ (persist create NotificationPreference {
108
+ channel: ?channel
109
+ eventType: ?eventType
110
+ mode: ?mode
111
+ quietEndHour: ?quietEndHour
112
+ quietStartHour: ?quietStartHour
113
+ userId: @user.id
114
+ } { emit: { failure: PREFERENCE_SAVE_FAILED, success: PREFERENCE_SAVED } })
115
+ UPDATE_PREFERENCE -> idle
116
+ (persist update NotificationPreference {
117
+ id: ?id
118
+ channel: ?channel
119
+ eventType: ?eventType
120
+ mode: ?mode
121
+ quietEndHour: ?quietEndHour
122
+ quietStartHour: ?quietStartHour
123
+ } { emit: { failure: PREFERENCE_SAVE_FAILED, success: PREFERENCE_SAVED } })
124
+ DELETE_PREFERENCE -> idle
125
+ (persist delete NotificationPreference ?id { emit: { failure: PREFERENCE_SAVE_FAILED, success: PREFERENCE_DELETED } })
126
+ PREFERENCE_SAVED -> idle
127
+ (notify success "Notification preference saved")
128
+ PREFERENCE_DELETED -> idle
129
+ (notify info "Notification preference removed")
130
+ PREFERENCE_SAVE_FAILED -> idle
131
+ (notify warning "Could not save the notification preference")
132
+ }
133
+
134
+ emits {
135
+ PREFERENCE_SAVED {
136
+ id : string!
137
+ }
138
+ @description "Preference row persisted"
139
+ @tier "secondary"
140
+ PREFERENCE_DELETED {
141
+ id : string!
142
+ }
143
+ @description "Preference row removed"
144
+ @tier "secondary"
145
+ PREFERENCE_SAVE_FAILED {
146
+ error : string!
147
+ }
148
+ @description "Preference persist failed"
149
+ @tier "secondary"
150
+ }
151
+
152
+ listens {
153
+ SAVE_PREFERENCE {
154
+ eventType : string
155
+ mode : string
156
+ channel : string
157
+ quietStartHour : number
158
+ quietEndHour : number
159
+ }
160
+ UPDATE_PREFERENCE {
161
+ id : string
162
+ eventType : string
163
+ mode : string
164
+ channel : string
165
+ quietStartHour : number
166
+ quietEndHour : number
167
+ }
168
+ DELETE_PREFERENCE {
169
+ id : string
170
+ }
171
+ }
172
+ }
173
+
174
+ trait DigestDrain -> DigestRunView [lifecycle, instance] {
175
+ initial: idle
176
+
177
+ state idle {
178
+ DIGEST_ROWS_LOADED -> draining when (and @config.digestEnabled [">", (array/len ?data), 0])
179
+ (set @entity.headId (object/get (array/first ?data) id))
180
+ (set @entity.headRecipient (object/get (array/first ?data) recipient))
181
+ (set @entity.headMessage (object/get (array/first ?data) message))
182
+ (call-service "email" "send" {
183
+ body: @entity.headMessage
184
+ from: ""
185
+ subject: @config.digestSubject
186
+ to: @entity.headRecipient
187
+ } { emit: { failure: DIGEST_FAILED, success: DIGEST_ITEM_SENT } })
188
+ DIGEST_ROWS_LOADED -> idle when ["=", (array/len ?data), 0]
189
+ DIGEST_FAILED -> idle
190
+ (set @entity.lastError ?error)
191
+ }
192
+
193
+ state draining {
194
+ DIGEST_ITEM_SENT -> draining
195
+ (set @entity.drainedCount ["+", @entity.drainedCount, 1])
196
+ (persist update NotificationRecord { id: @entity.headId, dispatched: true } {
197
+ emit: { failure: DIGEST_FAILED, success: DIGEST_STEPPED }
198
+ })
199
+ DIGEST_STEPPED -> draining
200
+ (fetch NotificationRecord {
201
+ emit: { failure: DIGEST_FAILED, success: DIGEST_ROWS_LOADED }
202
+ filter: (and ["=", (object/get @entity dispatched), false] (or ["=", (object/get @entity error), "digest-deferred"] ["=", (object/get @entity error), "quiet-hours"]) ["<", (object/get @entity recordedAt), ["-", @now, ["*", @config.minAgeHours, 3600000]]])
203
+ })
204
+ DIGEST_ROWS_LOADED -> draining when [">", (array/len ?data), 0]
205
+ (set @entity.headId (object/get (array/first ?data) id))
206
+ (set @entity.headRecipient (object/get (array/first ?data) recipient))
207
+ (set @entity.headMessage (object/get (array/first ?data) message))
208
+ (call-service "email" "send" {
209
+ body: @entity.headMessage
210
+ from: ""
211
+ subject: @config.digestSubject
212
+ to: @entity.headRecipient
213
+ } { emit: { failure: DIGEST_FAILED, success: DIGEST_ITEM_SENT } })
214
+ DIGEST_ROWS_LOADED -> idle when ["=", (array/len ?data), 0]
215
+ DIGEST_FAILED -> idle
216
+ (set @entity.lastError ?error)
217
+ }
218
+
219
+ emits {
220
+ DIGEST_ROWS_LOADED {
221
+ data : [NotifRow]
222
+ }
223
+ @description "Undispatched digest/quiet-hours audit rows past minAgeHours, oldest batch first"
224
+ @tier "secondary"
225
+ DIGEST_ITEM_SENT {
226
+ id : string!
227
+ status : string!
228
+ }
229
+ @description "email.send result for the current digest row"
230
+ @tier "secondary"
231
+ DIGEST_STEPPED {
232
+ id : string!
233
+ }
234
+ @description "Current row marked dispatched; drives the next loop pass"
235
+ @tier "secondary"
236
+ DIGEST_FAILED {
237
+ error : string!
238
+ }
239
+ @description "Digest scan / delivery / persist failed — the cycle stops until the next tick"
240
+ @tier "secondary"
241
+ }
242
+
243
+ listens {
244
+ DIGEST_ROWS_LOADED {
245
+ data : [NotifRow]
246
+ }
247
+ DIGEST_ITEM_SENT {
248
+ id : string!
249
+ status : string!
250
+ }
251
+ DIGEST_STEPPED {
252
+ id : string!
253
+ }
254
+ DIGEST_FAILED {
255
+ error : string!
256
+ }
257
+ }
258
+
259
+ ticks {
260
+ digestCycle every "0 * * * *"
261
+ (fetch NotificationRecord {
262
+ emit: { failure: DIGEST_FAILED, success: DIGEST_ROWS_LOADED }
263
+ filter: (and ["=", (object/get @entity dispatched), false] (or ["=", (object/get @entity error), "digest-deferred"] ["=", (object/get @entity error), "quiet-hours"]) ["<", (object/get @entity recordedAt), ["-", @now, ["*", @config.minAgeHours, 3600000]]])
264
+ })
265
+
266
+ }
267
+
268
+ config {
269
+ digestEnabled : boolean = false
270
+ @label "Deliver digests"
271
+ @description "Drain suppressed notifications (digest-deferred and quiet-hours audit rows) on the hourly cycle. Off by default."
272
+ @synonyms "enable digest, digest mode, batch notifications, daily summary, drain suppressed"
273
+ @tier "infra"
274
+ digestSubject : string = "Notification digest"
275
+ @label "Digest email subject"
276
+ @description "Subject line for drained digest deliveries."
277
+ @synonyms "digest title, summary subject, email subject"
278
+ @tier "presentation"
279
+ minAgeHours : number = 1
280
+ @label "Minimum age (hours)"
281
+ @description "Only drain audit rows at least this many hours old, so quiet-hours suppressions are not redelivered inside their window."
282
+ @synonyms "delay, drain delay, hold for, batch window"
283
+ @tier "infra"
284
+ }
285
+ }
286
+
287
+ page "/notifications/preferences" as NotificationPreferencesPage -> PreferenceEditor
288
+ page "/notifications/subscribe" as PushSubscribePage -> PushSubscriber
289
+ page "/notifications/digest" as NotificationDigestPage -> DigestDrain
290
+ }