@almadar/std 16.181.0 → 16.183.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-calendar-sync.lolo +290 -0
- package/behaviors/lolo/infra/atoms/std-migration-job.lolo +2 -2
- package/behaviors/lolo/infra/atoms/std-notification-center.lolo +290 -0
- package/behaviors/lolo/infra/atoms/std-notify-on-event.lolo +238 -40
- package/behaviors/lolo/ui/core/atoms/std-esign-flow.lolo +180 -3
- package/behaviors/lolo/ui/core/molecules/ui-calendar-grid.lolo +6 -0
- package/behaviors/lolo/ui/core/organisms/ui-detail-panel.lolo +4 -4
- package/behaviors/lolo/ui/game/.hand-authored.json +1 -1
- package/behaviors/lolo/ui/game/atoms/std-fx-trail.lolo +63 -0
- package/behaviors/lolo/ui/game/atoms/std-gate-lock.lolo +96 -0
- package/behaviors/lolo/ui/game/atoms/std-lives.lolo +121 -0
- package/behaviors/lolo/ui/game/atoms/std-patrol-hazard.lolo +96 -0
- package/behaviors/lolo/ui/game/atoms/std-tutorial-overlay.lolo +164 -0
- package/behaviors/lolo/ui/game/atoms/ui-control-grid.lolo +5 -0
- package/behaviors/lolo/ui/game/atoms/ui-game-hud.lolo +5 -0
- package/behaviors/lolo/ui/game/templates/ui-game-shell.lolo +5 -5
- package/behaviors/lolo/ui/learning/atoms/std-learn-slope-probe.lolo +117 -0
- package/behaviors/lolo/ui/learning/atoms/ui-math-canvas.lolo +18 -0
- package/behaviors/registry/infra/atoms/std-calendar-sync.orb +1643 -0
- package/behaviors/registry/infra/atoms/std-migration-job.orb +2 -2
- package/behaviors/registry/infra/atoms/std-notification-center.orb +1621 -0
- package/behaviors/registry/infra/atoms/std-notify-on-event.orb +1734 -169
- package/behaviors/registry/ui/core/atoms/std-esign-flow.orb +846 -2
- package/behaviors/registry/ui/core/molecules/ui-calendar-grid.orb +9 -0
- package/behaviors/registry/ui/core/organisms/ui-detail-panel.orb +4 -2
- package/behaviors/registry/ui/game/atoms/std-fx-trail.orb +376 -0
- package/behaviors/registry/ui/game/atoms/std-gate-lock.orb +852 -0
- package/behaviors/registry/ui/game/atoms/std-lives.orb +739 -0
- package/behaviors/registry/ui/game/atoms/std-patrol-hazard.orb +768 -0
- package/behaviors/registry/ui/game/atoms/std-tutorial-overlay.orb +1060 -0
- package/behaviors/registry/ui/game/atoms/ui-control-grid.orb +13 -1
- package/behaviors/registry/ui/game/atoms/ui-game-hud.orb +13 -1
- package/behaviors/registry/ui/game/templates/ui-game-shell.orb +5 -5
- package/behaviors/registry/ui/learning/atoms/std-learn-slope-probe.orb +908 -0
- package/canonical-operators.json +21 -1
- package/dist/behaviors/exports-reader.js +9021 -3497
- package/dist/behaviors/functions/index.d.ts +1001 -10
- package/dist/behaviors/functions/index.js +18568 -13079
- package/dist/behaviors/index.d.ts +1 -1
- package/dist/behaviors/index.js +9022 -3498
- package/dist/behaviors/query.js +9021 -3497
- package/dist/behaviors-embeddings.hash.json +3 -3
- package/dist/behaviors-embeddings.json +347 -315
- package/dist/behaviors-registry.json +2694 -56
- package/dist/index.d.ts +3 -3
- package/dist/index.js +8502 -2941
- package/dist/knob-embeddings.hash.json +3 -3
- package/dist/knob-embeddings.json +1 -1
- package/dist/modules/anim.d.ts +1 -1
- package/dist/modules/array.d.ts +1 -1
- package/dist/modules/async.d.ts +1 -1
- package/dist/modules/behavior.d.ts +1 -1
- package/dist/modules/browser.d.ts +1 -1
- package/dist/modules/browser.js +21 -0
- package/dist/modules/composition.d.ts +1 -1
- package/dist/modules/contract.d.ts +1 -1
- package/dist/modules/core.d.ts +1 -1
- package/dist/modules/core.js +18 -2
- package/dist/modules/data.d.ts +1 -1
- package/dist/modules/ease.d.ts +1 -1
- package/dist/modules/format.d.ts +1 -1
- package/dist/modules/geo.d.ts +1 -1
- package/dist/modules/graph.d.ts +1 -1
- package/dist/modules/grid.d.ts +1 -1
- package/dist/modules/index.d.ts +1 -1
- package/dist/modules/index.js +39 -2
- package/dist/modules/integration.d.ts +1 -1
- package/dist/modules/llm.d.ts +1 -1
- package/dist/modules/math.d.ts +1 -1
- package/dist/modules/memory.d.ts +1 -1
- package/dist/modules/nn.d.ts +1 -1
- package/dist/modules/noise.d.ts +1 -1
- package/dist/modules/object.d.ts +1 -1
- package/dist/modules/os.d.ts +1 -1
- package/dist/modules/path.d.ts +1 -1
- package/dist/modules/prob.d.ts +1 -1
- package/dist/modules/session.d.ts +1 -1
- package/dist/modules/str.d.ts +1 -1
- package/dist/modules/tensor.d.ts +1 -1
- package/dist/modules/time.d.ts +1 -1
- package/dist/modules/trace.d.ts +1 -1
- package/dist/modules/train.d.ts +1 -1
- package/dist/modules/validate.d.ts +1 -1
- package/dist/modules/vector.d.ts +1 -1
- package/dist/modules/workspace.d.ts +1 -1
- package/dist/registry/factory-signatures.json +1 -1
- package/dist/registry.d.ts +1 -1
- package/dist/registry.js +39 -2
- package/dist/{types-DGJ6Y6nW.d.ts → types-BXWzEjFm.d.ts} +1 -1
- 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
|
+
}
|
|
@@ -297,9 +297,9 @@ orbital MigrationJobOrbital {
|
|
|
297
297
|
@description "Name of the host entity this import writes into (e.g. the organism's own entity). The migration service derives the full target descriptor from the running app's schema."
|
|
298
298
|
@synonyms "destination, import into, target type"
|
|
299
299
|
@tier "domain"
|
|
300
|
-
connectionRef :
|
|
300
|
+
connectionRef : secret = ""
|
|
301
301
|
@label "Connection reference"
|
|
302
|
-
@description "Secret-store / environment KEY holding the source credentials. The reference is stored; the secret itself never enters config, traces, or snapshots — the migration service resolves it at run time. (Typed `
|
|
302
|
+
@description "Secret-store / environment KEY holding the source credentials. The reference is stored; the secret itself never enters config, traces, or snapshots — the migration service resolves it at run time. (Typed `secret` — the config-field type that marks a credential reference; UI password-renders it and traces mask it.)"
|
|
303
303
|
@synonyms "credential key, connection string reference, secret name"
|
|
304
304
|
@tier "internal"
|
|
305
305
|
reviewSlot : slot = modal
|
|
@@ -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
|
+
}
|