@dxos/functions 0.8.3 → 0.8.4-main.1da679c

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 (161) hide show
  1. package/dist/lib/browser/bundler/index.mjs +73 -44
  2. package/dist/lib/browser/bundler/index.mjs.map +3 -3
  3. package/dist/lib/browser/chunk-D2XO7XXY.mjs +611 -0
  4. package/dist/lib/browser/chunk-D2XO7XXY.mjs.map +7 -0
  5. package/dist/lib/browser/chunk-J5LGTIGS.mjs +10 -0
  6. package/dist/lib/browser/chunk-J5LGTIGS.mjs.map +7 -0
  7. package/dist/lib/browser/edge/index.mjs +24 -10
  8. package/dist/lib/browser/edge/index.mjs.map +3 -3
  9. package/dist/lib/browser/index.mjs +981 -137
  10. package/dist/lib/browser/index.mjs.map +4 -4
  11. package/dist/lib/browser/meta.json +1 -1
  12. package/dist/lib/browser/testing/index.mjs +110 -9
  13. package/dist/lib/browser/testing/index.mjs.map +4 -4
  14. package/dist/lib/node-esm/bundler/index.mjs +72 -44
  15. package/dist/lib/node-esm/bundler/index.mjs.map +3 -3
  16. package/dist/lib/node-esm/chunk-HSLMI22Q.mjs +11 -0
  17. package/dist/lib/node-esm/chunk-HSLMI22Q.mjs.map +7 -0
  18. package/dist/lib/node-esm/chunk-Z4CJ62WS.mjs +613 -0
  19. package/dist/lib/node-esm/chunk-Z4CJ62WS.mjs.map +7 -0
  20. package/dist/lib/node-esm/edge/index.mjs +23 -10
  21. package/dist/lib/node-esm/edge/index.mjs.map +3 -3
  22. package/dist/lib/node-esm/index.mjs +981 -137
  23. package/dist/lib/node-esm/index.mjs.map +4 -4
  24. package/dist/lib/node-esm/meta.json +1 -1
  25. package/dist/lib/node-esm/testing/index.mjs +110 -9
  26. package/dist/lib/node-esm/testing/index.mjs.map +4 -4
  27. package/dist/types/src/bundler/bundler.d.ts +12 -14
  28. package/dist/types/src/bundler/bundler.d.ts.map +1 -1
  29. package/dist/types/src/edge/functions.d.ts +4 -3
  30. package/dist/types/src/edge/functions.d.ts.map +1 -1
  31. package/dist/types/src/errors.d.ts +137 -0
  32. package/dist/types/src/errors.d.ts.map +1 -0
  33. package/dist/types/src/examples/fib.d.ts +7 -0
  34. package/dist/types/src/examples/fib.d.ts.map +1 -0
  35. package/dist/types/src/examples/index.d.ts +4 -0
  36. package/dist/types/src/examples/index.d.ts.map +1 -0
  37. package/dist/types/src/examples/reply.d.ts +3 -0
  38. package/dist/types/src/examples/reply.d.ts.map +1 -0
  39. package/dist/types/src/examples/sleep.d.ts +5 -0
  40. package/dist/types/src/examples/sleep.d.ts.map +1 -0
  41. package/dist/types/src/executor/executor.d.ts +4 -1
  42. package/dist/types/src/executor/executor.d.ts.map +1 -1
  43. package/dist/types/src/handler.d.ts +46 -16
  44. package/dist/types/src/handler.d.ts.map +1 -1
  45. package/dist/types/src/index.d.ts +3 -0
  46. package/dist/types/src/index.d.ts.map +1 -1
  47. package/dist/types/src/schema.d.ts +8 -3
  48. package/dist/types/src/schema.d.ts.map +1 -1
  49. package/dist/types/src/services/credentials.d.ts +18 -4
  50. package/dist/types/src/services/credentials.d.ts.map +1 -1
  51. package/dist/types/src/services/database.d.ts +55 -3
  52. package/dist/types/src/services/database.d.ts.map +1 -1
  53. package/dist/types/src/services/event-logger.d.ts +65 -30
  54. package/dist/types/src/services/event-logger.d.ts.map +1 -1
  55. package/dist/types/src/services/index.d.ts +2 -2
  56. package/dist/types/src/services/index.d.ts.map +1 -1
  57. package/dist/types/src/services/local-function-execution.d.ts +25 -0
  58. package/dist/types/src/services/local-function-execution.d.ts.map +1 -0
  59. package/dist/types/src/services/queues.d.ts +33 -6
  60. package/dist/types/src/services/queues.d.ts.map +1 -1
  61. package/dist/types/src/services/remote-function-execution-service.d.ts +15 -0
  62. package/dist/types/src/services/remote-function-execution-service.d.ts.map +1 -0
  63. package/dist/types/src/services/service-container.d.ts +29 -17
  64. package/dist/types/src/services/service-container.d.ts.map +1 -1
  65. package/dist/types/src/services/service-registry.d.ts +29 -0
  66. package/dist/types/src/services/service-registry.d.ts.map +1 -0
  67. package/dist/types/src/services/service-registry.test.d.ts +2 -0
  68. package/dist/types/src/services/service-registry.test.d.ts.map +1 -0
  69. package/dist/types/src/services/tracing.d.ts +46 -4
  70. package/dist/types/src/services/tracing.d.ts.map +1 -1
  71. package/dist/types/src/testing/index.d.ts +1 -0
  72. package/dist/types/src/testing/index.d.ts.map +1 -1
  73. package/dist/types/src/testing/layer.d.ts +15 -0
  74. package/dist/types/src/testing/layer.d.ts.map +1 -0
  75. package/dist/types/src/testing/logger.d.ts +3 -3
  76. package/dist/types/src/testing/logger.d.ts.map +1 -1
  77. package/dist/types/src/testing/persist-database.test.d.ts +2 -0
  78. package/dist/types/src/testing/persist-database.test.d.ts.map +1 -0
  79. package/dist/types/src/testing/services.d.ts +55 -9
  80. package/dist/types/src/testing/services.d.ts.map +1 -1
  81. package/dist/types/src/trace.d.ts +20 -22
  82. package/dist/types/src/trace.d.ts.map +1 -1
  83. package/dist/types/src/translations.d.ts +9 -9
  84. package/dist/types/src/translations.d.ts.map +1 -1
  85. package/dist/types/src/triggers/index.d.ts +4 -0
  86. package/dist/types/src/triggers/index.d.ts.map +1 -0
  87. package/dist/types/src/triggers/input-builder.d.ts +3 -0
  88. package/dist/types/src/triggers/input-builder.d.ts.map +1 -0
  89. package/dist/types/src/triggers/invocation-tracer.d.ts +35 -0
  90. package/dist/types/src/triggers/invocation-tracer.d.ts.map +1 -0
  91. package/dist/types/src/triggers/trigger-dispatcher.d.ts +75 -0
  92. package/dist/types/src/triggers/trigger-dispatcher.d.ts.map +1 -0
  93. package/dist/types/src/triggers/trigger-dispatcher.test.d.ts +2 -0
  94. package/dist/types/src/triggers/trigger-dispatcher.test.d.ts.map +1 -0
  95. package/dist/types/src/triggers/trigger-state-store.d.ts +27 -0
  96. package/dist/types/src/triggers/trigger-state-store.d.ts.map +1 -0
  97. package/dist/types/src/types.d.ts +55 -245
  98. package/dist/types/src/types.d.ts.map +1 -1
  99. package/dist/types/src/url.d.ts +10 -6
  100. package/dist/types/src/url.d.ts.map +1 -1
  101. package/dist/types/tsconfig.tsbuildinfo +1 -1
  102. package/package.json +35 -25
  103. package/src/bundler/bundler.test.ts +9 -10
  104. package/src/bundler/bundler.ts +56 -35
  105. package/src/edge/functions.ts +9 -6
  106. package/src/errors.ts +21 -0
  107. package/src/examples/fib.ts +30 -0
  108. package/src/examples/index.ts +7 -0
  109. package/src/examples/reply.ts +18 -0
  110. package/src/examples/sleep.ts +22 -0
  111. package/src/executor/executor.ts +22 -15
  112. package/src/handler.ts +117 -27
  113. package/src/index.ts +3 -2
  114. package/src/schema.ts +11 -0
  115. package/src/services/credentials.ts +87 -5
  116. package/src/services/database.ts +146 -3
  117. package/src/services/event-logger.ts +68 -37
  118. package/src/services/index.ts +2 -2
  119. package/src/services/local-function-execution.ts +127 -0
  120. package/src/services/queues.ts +56 -11
  121. package/src/services/remote-function-execution-service.ts +46 -0
  122. package/src/services/service-container.ts +47 -42
  123. package/src/services/service-registry.test.ts +42 -0
  124. package/src/services/service-registry.ts +59 -0
  125. package/src/services/tracing.ts +118 -5
  126. package/src/testing/index.ts +1 -0
  127. package/src/testing/layer.ts +111 -0
  128. package/src/testing/logger.ts +4 -4
  129. package/src/testing/persist-database.test.ts +87 -0
  130. package/src/testing/services.ts +97 -14
  131. package/src/trace.ts +17 -19
  132. package/src/translations.ts +4 -4
  133. package/src/triggers/index.ts +7 -0
  134. package/src/triggers/input-builder.ts +35 -0
  135. package/src/triggers/invocation-tracer.ts +99 -0
  136. package/src/triggers/trigger-dispatcher.test.ts +652 -0
  137. package/src/triggers/trigger-dispatcher.ts +516 -0
  138. package/src/triggers/trigger-state-store.ts +60 -0
  139. package/src/types.ts +39 -36
  140. package/src/url.ts +13 -10
  141. package/dist/lib/browser/chunk-WEFZUEL2.mjs +0 -300
  142. package/dist/lib/browser/chunk-WEFZUEL2.mjs.map +0 -7
  143. package/dist/lib/node/bundler/index.cjs +0 -260
  144. package/dist/lib/node/bundler/index.cjs.map +0 -7
  145. package/dist/lib/node/chunk-IJAE7FZK.cjs +0 -320
  146. package/dist/lib/node/chunk-IJAE7FZK.cjs.map +0 -7
  147. package/dist/lib/node/edge/index.cjs +0 -94
  148. package/dist/lib/node/edge/index.cjs.map +0 -7
  149. package/dist/lib/node/index.cjs +0 -522
  150. package/dist/lib/node/index.cjs.map +0 -7
  151. package/dist/lib/node/meta.json +0 -1
  152. package/dist/lib/node/testing/index.cjs +0 -43
  153. package/dist/lib/node/testing/index.cjs.map +0 -7
  154. package/dist/lib/node-esm/chunk-LIYPMWNQ.mjs +0 -302
  155. package/dist/lib/node-esm/chunk-LIYPMWNQ.mjs.map +0 -7
  156. package/dist/types/src/services/ai.d.ts +0 -12
  157. package/dist/types/src/services/ai.d.ts.map +0 -1
  158. package/dist/types/src/services/function-call-service.d.ts +0 -16
  159. package/dist/types/src/services/function-call-service.d.ts.map +0 -1
  160. package/src/services/ai.ts +0 -32
  161. package/src/services/function-call-service.ts +0 -64
@@ -0,0 +1,516 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { Cause, Context, Cron, Duration, Effect, Either, Exit, Fiber, Layer, Option, Record, Schedule } from 'effect';
6
+
7
+ import { DXN, Filter, Obj, Query } from '@dxos/echo';
8
+ import { causeToError } from '@dxos/effect';
9
+ import { invariant } from '@dxos/invariant';
10
+ import { log } from '@dxos/log';
11
+ import { KEY_QUEUE_POSITION } from '@dxos/protocols';
12
+
13
+ import { deserializeFunction } from '../handler';
14
+ import { FunctionType } from '../schema';
15
+ import { ComputeEventLogger, DatabaseService, QueueService, type Services, TracingService } from '../services';
16
+ import { LocalFunctionExecutionService } from '../services/local-function-execution';
17
+ import {
18
+ type EventType,
19
+ FunctionTrigger,
20
+ type QueueTriggerOutput,
21
+ type SubscriptionTriggerOutput,
22
+ type TimerTrigger,
23
+ type TimerTriggerOutput,
24
+ type TriggerKind,
25
+ } from '../types';
26
+
27
+ import { createInvocationPayload } from './input-builder';
28
+ import { InvocationTracer } from './invocation-tracer';
29
+ import { type TriggerState, TriggerStateStore } from './trigger-state-store';
30
+
31
+ export type TimeControl = 'natural' | 'manual';
32
+
33
+ export interface TriggerDispatcherOptions {
34
+ /**
35
+ * Time control mode.
36
+ * - 'natural': Use real time.
37
+ * - 'manual': Use internal clock for testing.
38
+ */
39
+ timeControl: TimeControl;
40
+
41
+ /**
42
+ * Starting time for manual time control mode.
43
+ * @default current time
44
+ */
45
+ startingTime?: Date;
46
+
47
+ /**
48
+ * Poll interval for cron triggers in 'natural' time control mode.
49
+ * @default 1 second
50
+ */
51
+ livePollInterval?: Duration.Duration;
52
+ }
53
+
54
+ export interface InvokeTriggerOptions {
55
+ trigger: FunctionTrigger;
56
+ event: EventType;
57
+ }
58
+ export interface TriggerExecutionResult {
59
+ triggerId: string;
60
+ result: Exit.Exit<unknown>;
61
+ }
62
+
63
+ /**
64
+ * Cront trigger runtime state.
65
+ */
66
+ interface ScheduledTrigger {
67
+ trigger: FunctionTrigger;
68
+ cron: Cron.Cron;
69
+ nextExecution: Date;
70
+ }
71
+
72
+ // TODO(dmaretskyi): Refactor service management.
73
+ type TriggerDispatcherServices =
74
+ | Exclude<Services, ComputeEventLogger | TracingService>
75
+ | LocalFunctionExecutionService
76
+ // TODO(dmaretskyi): Move those into layer deps.
77
+ | TriggerStateStore
78
+ | InvocationTracer;
79
+
80
+ export class TriggerDispatcher extends Context.Tag('@dxos/functions/TriggerDispatcher')<
81
+ TriggerDispatcher,
82
+ {
83
+ readonly timeControl: TimeControl;
84
+
85
+ get running(): boolean;
86
+
87
+ /**
88
+ * Start the trigger dispatcher.
89
+ * Will automatically invoke triggers.
90
+ */
91
+ start(): Effect.Effect<void, never, TriggerDispatcherServices>;
92
+
93
+ /**
94
+ * Stop the trigger dispatcher.
95
+ */
96
+ stop(): Effect.Effect<void>;
97
+
98
+ /**
99
+ * Refresh triggers.
100
+ */
101
+ refreshTriggers(): Effect.Effect<void, never, DatabaseService>;
102
+
103
+ /**
104
+ * Manually invoke a specific trigger.
105
+ */
106
+ invokeTrigger(
107
+ options: InvokeTriggerOptions,
108
+ ): Effect.Effect<TriggerExecutionResult, never, TriggerDispatcherServices>;
109
+
110
+ /**
111
+ * Invoke all scheduled triggers who are due.
112
+ */
113
+ invokeScheduledTriggers(opts?: {
114
+ kinds?: TriggerKind[];
115
+ }): Effect.Effect<TriggerExecutionResult[], never, TriggerDispatcherServices>;
116
+
117
+ /**
118
+ * Advance the internal clock (manual time control only).
119
+ * Note: Does not invoke triggers.
120
+ */
121
+ advanceTime(duration: Duration.Duration): Effect.Effect<void>;
122
+
123
+ /**
124
+ * Get current time based on time control mode.
125
+ */
126
+ getCurrentTime(): Date;
127
+ }
128
+ >() {
129
+ static layer = (options: Omit<TriggerDispatcherOptions, 'database'>) =>
130
+ Layer.effect(
131
+ TriggerDispatcher,
132
+ Effect.gen(function* () {
133
+ return new TriggerDispatcherImpl(options);
134
+ }),
135
+ );
136
+ }
137
+
138
+ class TriggerDispatcherImpl implements Context.Tag.Service<TriggerDispatcher> {
139
+ readonly livePollInterval: Duration.Duration;
140
+ readonly timeControl: TimeControl;
141
+
142
+ private _running = false;
143
+ private _internalTime: Date;
144
+ private _timerFiber: Fiber.Fiber<void, void> | undefined;
145
+ private _scheduledTriggers = new Map<string, ScheduledTrigger>();
146
+
147
+ constructor(options: TriggerDispatcherOptions) {
148
+ this.timeControl = options.timeControl;
149
+ this.livePollInterval = options.livePollInterval ?? Duration.seconds(1);
150
+ this._internalTime = options.startingTime ?? new Date();
151
+ }
152
+
153
+ get running(): boolean {
154
+ return this._running;
155
+ }
156
+
157
+ start = (): Effect.Effect<void, never, TriggerDispatcherServices> =>
158
+ Effect.gen(this, function* () {
159
+ if (this._running) {
160
+ return;
161
+ }
162
+
163
+ this._running = true;
164
+
165
+ // Start natural time processing if enabled
166
+ if (this.timeControl === 'natural') {
167
+ this._timerFiber = yield* this._startNaturalTimeProcessing().pipe(
168
+ Effect.tapErrorCause((cause) => {
169
+ const error = causeToError(cause);
170
+ log.error('trigger dispatcher error', { error });
171
+ this._running = false;
172
+ return Effect.void;
173
+ }),
174
+ Effect.forkDaemon,
175
+ );
176
+ } else {
177
+ return yield* Effect.dieMessage('TriggerDispatcher started in manual time control mode');
178
+ }
179
+
180
+ log.info('TriggerDispatcher started', { timeControl: this.timeControl });
181
+ });
182
+
183
+ stop = (): Effect.Effect<void> =>
184
+ Effect.gen(this, function* () {
185
+ if (!this._running) {
186
+ return;
187
+ }
188
+
189
+ this._running = false;
190
+
191
+ // Stop timer processing
192
+ if (this._timerFiber) {
193
+ yield* Fiber.interrupt(this._timerFiber);
194
+ this._timerFiber = undefined;
195
+ }
196
+
197
+ // Clear scheduled triggers
198
+ this._scheduledTriggers.clear();
199
+
200
+ log.info('TriggerDispatcher stopped');
201
+ });
202
+
203
+ invokeTrigger = (
204
+ options: InvokeTriggerOptions,
205
+ ): Effect.Effect<TriggerExecutionResult, never, TriggerDispatcherServices> =>
206
+ Effect.gen(this, function* () {
207
+ const { trigger, event } = options;
208
+ log.info('running trigger', { triggerId: trigger.id, spec: trigger.spec, event });
209
+
210
+ const tracer = yield* InvocationTracer;
211
+ const trace = yield* tracer.traceInvocationStart({
212
+ target: trigger.function?.dxn,
213
+ payload: {
214
+ trigger: {
215
+ id: trigger.id,
216
+ // TODO(dmaretskyi): Is `spec` always there>
217
+ kind: trigger.spec!.kind,
218
+ },
219
+ data: event,
220
+ },
221
+ });
222
+
223
+ // Sandboxed section.
224
+ const result = yield* Effect.gen(this, function* () {
225
+ if (!trigger.enabled) {
226
+ return yield* Effect.dieMessage('Attempting to invoke disabled trigger');
227
+ }
228
+
229
+ if (!trigger.function) {
230
+ return yield* Effect.dieMessage('Trigger has no function reference');
231
+ }
232
+
233
+ // Resolve the function
234
+ const serialiedFunction = yield* DatabaseService.load(trigger.function!).pipe(Effect.orDie);
235
+ invariant(Obj.instanceOf(FunctionType, serialiedFunction));
236
+ const functionDef = deserializeFunction(serialiedFunction);
237
+
238
+ // Prepare input data
239
+ const inputData = this._prepareInputData(trigger, event);
240
+
241
+ // Invoke the function
242
+ return yield* LocalFunctionExecutionService.invokeFunction(functionDef, inputData).pipe(
243
+ Effect.provide(
244
+ ComputeEventLogger.layerFromTracing.pipe(
245
+ Layer.provideMerge(TracingService.layerQueue(trace.invocationTraceQueue)),
246
+ ),
247
+ ),
248
+ );
249
+ }).pipe(Effect.exit);
250
+
251
+ const triggerExecutionResult: TriggerExecutionResult = {
252
+ triggerId: trigger.id,
253
+ result,
254
+ };
255
+ if (Exit.isSuccess(result)) {
256
+ log.info('trigger execution success', {
257
+ triggerId: trigger.id,
258
+ });
259
+ } else {
260
+ log.error('trigger execution failure', {
261
+ error: causeToError(result.cause),
262
+ });
263
+ }
264
+ yield* tracer.traceInvocationEnd({
265
+ trace,
266
+ // TODO(dmaretskyi): Might miss errors.
267
+ exception: Exit.isFailure(result) ? Cause.prettyErrors(result.cause)[0] : undefined,
268
+ });
269
+ return triggerExecutionResult;
270
+ });
271
+
272
+ invokeScheduledTriggers = ({ kinds = ['timer', 'queue', 'subscription'] } = {}): Effect.Effect<
273
+ TriggerExecutionResult[],
274
+ never,
275
+ TriggerDispatcherServices
276
+ > =>
277
+ Effect.gen(this, function* () {
278
+ const invocations: TriggerExecutionResult[] = [];
279
+ for (const kind of kinds) {
280
+ switch (kind) {
281
+ case 'timer':
282
+ {
283
+ yield* this.refreshTriggers();
284
+ const now = this.getCurrentTime();
285
+ const triggersToInvoke: FunctionTrigger[] = [];
286
+
287
+ for (const [triggerId, scheduledTrigger] of this._scheduledTriggers.entries()) {
288
+ if (scheduledTrigger.nextExecution <= now) {
289
+ triggersToInvoke.push(scheduledTrigger.trigger);
290
+
291
+ // Update next execution time using Effect's Cron
292
+ scheduledTrigger.nextExecution = Cron.next(scheduledTrigger.cron, now);
293
+ }
294
+ }
295
+
296
+ // Invoke all due triggers
297
+ invocations.push(
298
+ ...(yield* Effect.forEach(
299
+ triggersToInvoke,
300
+ (trigger) =>
301
+ this.invokeTrigger({
302
+ trigger,
303
+ event: { tick: now.getTime() } satisfies TimerTriggerOutput,
304
+ }),
305
+ { concurrency: 1 },
306
+ )),
307
+ );
308
+ }
309
+ break;
310
+ case 'queue': {
311
+ const triggers = yield* this._fetchTriggers();
312
+ for (const trigger of triggers) {
313
+ const spec = trigger.spec;
314
+ if (spec?.kind !== 'queue') {
315
+ continue;
316
+ }
317
+ const cursor = Obj.getKeys(trigger, KEY_QUEUE_CURSOR).at(0)?.id;
318
+ const queue = yield* QueueService.getQueue(DXN.parse(spec.queue));
319
+
320
+ // TODO(dmaretskyi): Include cursor & limit in the query.
321
+ const objects = yield* Effect.promise(() => queue.queryObjects());
322
+ for (const object of objects) {
323
+ const objectPos = Obj.getKeys(object, KEY_QUEUE_POSITION).at(0)?.id;
324
+ // TODO(dmaretskyi): Extract methods for managing queue position.
325
+ if (!objectPos || (cursor && parseInt(cursor) >= parseInt(objectPos))) {
326
+ continue;
327
+ }
328
+
329
+ invocations.push(
330
+ yield* this.invokeTrigger({
331
+ trigger,
332
+ event: {
333
+ queue: spec.queue,
334
+ item: object,
335
+ cursor: objectPos,
336
+ } satisfies QueueTriggerOutput,
337
+ }),
338
+ );
339
+
340
+ // Update trigger cursor.
341
+ Obj.deleteKeys(trigger, KEY_QUEUE_CURSOR);
342
+ Obj.getMeta(trigger).keys.push({ source: KEY_QUEUE_CURSOR, id: objectPos });
343
+ yield* DatabaseService.flush();
344
+
345
+ // We only invoke one trigger for each queue at a time.
346
+ break;
347
+ }
348
+ }
349
+ break;
350
+ }
351
+ case 'subscription': {
352
+ const triggers = yield* this._fetchTriggers();
353
+ for (const trigger of triggers) {
354
+ const spec = Obj.getSnapshot(trigger).spec;
355
+ if (spec?.kind !== 'subscription') {
356
+ continue;
357
+ }
358
+
359
+ const { objects } = yield* DatabaseService.runQuery(Query.fromAst(spec.query));
360
+
361
+ const state: TriggerState = yield* TriggerStateStore.getState(trigger.id).pipe(
362
+ Effect.catchTag('TRIGGER_STATE_NOT_FOUND', () =>
363
+ Effect.succeed({
364
+ version: '1',
365
+ triggerId: trigger.id,
366
+ state: {
367
+ _tag: 'subscription',
368
+ processedVersions: {} as Record<string, string>,
369
+ },
370
+ } satisfies TriggerState),
371
+ ),
372
+ );
373
+ invariant(state.state?._tag === 'subscription');
374
+
375
+ let updated = false;
376
+ for (const object of objects) {
377
+ const existingVersion = Record.get(state.state.processedVersions, object.id).pipe(
378
+ Option.map(Obj.decodeVersion),
379
+ );
380
+ const currentVersion = Obj.version(object);
381
+ const run =
382
+ Option.isNone(existingVersion) ||
383
+ Obj.compareVersions(currentVersion, existingVersion.value) === 'different';
384
+
385
+ if (!run) {
386
+ continue;
387
+ }
388
+
389
+ const { db } = yield* DatabaseService;
390
+ invocations.push(
391
+ yield* this.invokeTrigger({
392
+ trigger,
393
+ event: {
394
+ // TODO(dmaretskyi): Change type not supported.
395
+ type: 'unknown',
396
+
397
+ subject: db.ref(Obj.getDXN(object)),
398
+
399
+ changedObjectId: object.id,
400
+ } satisfies SubscriptionTriggerOutput,
401
+ }),
402
+ );
403
+ (state.state.processedVersions as any)[object.id] = Obj.encodeVersion(currentVersion);
404
+ updated = true;
405
+ }
406
+
407
+ if (updated) {
408
+ yield* TriggerStateStore.saveState(state);
409
+ }
410
+ }
411
+ break;
412
+ }
413
+ default: {
414
+ return yield* Effect.dieMessage(`Unknown trigger kind: ${kind}`);
415
+ }
416
+ }
417
+ }
418
+ return invocations;
419
+ });
420
+
421
+ advanceTime = (duration: Duration.Duration): Effect.Effect<void> =>
422
+ Effect.gen(this, function* () {
423
+ if (this.timeControl !== 'manual') {
424
+ return yield* Effect.dieMessage('advanceTime can only be used in manual time control mode');
425
+ }
426
+
427
+ const millis = Duration.toMillis(duration);
428
+ this._internalTime = new Date(this._internalTime.getTime() + millis);
429
+
430
+ log('Advanced internal time', {
431
+ newTime: this._internalTime,
432
+ advancedBy: Duration.format(duration),
433
+ });
434
+ }).pipe(Effect.orDie);
435
+
436
+ getCurrentTime = (): Date => {
437
+ if (this.timeControl === 'natural') {
438
+ return new Date();
439
+ } else {
440
+ return new Date(this._internalTime);
441
+ }
442
+ };
443
+
444
+ refreshTriggers = (): Effect.Effect<void, never, DatabaseService> =>
445
+ Effect.gen(this, function* () {
446
+ const triggers = yield* this._fetchTriggers();
447
+ const currentTriggerIds = new Set(triggers.map((t) => t.id));
448
+
449
+ // Remove triggers that are no longer present
450
+ for (const triggerId of this._scheduledTriggers.keys()) {
451
+ if (!currentTriggerIds.has(triggerId)) {
452
+ this._scheduledTriggers.delete(triggerId);
453
+ }
454
+ }
455
+
456
+ // Add or update triggers
457
+ for (const trigger of triggers) {
458
+ if (trigger.spec?.kind === 'timer' && trigger.enabled) {
459
+ const timerSpec = trigger.spec as TimerTrigger;
460
+
461
+ // Parse cron expression using Effect's Cron module
462
+ const cronEither = Cron.parse(timerSpec.cron);
463
+
464
+ if (Either.isRight(cronEither)) {
465
+ const cron = cronEither.right;
466
+ const existing = this._scheduledTriggers.get(trigger.id);
467
+ const now = this.getCurrentTime();
468
+ const nextExecution = existing?.nextExecution ?? Cron.next(cron, now);
469
+
470
+ log('Updated scheduled trigger', {
471
+ triggerId: trigger.id,
472
+ cron: timerSpec.cron,
473
+ nextExecution,
474
+ now,
475
+ });
476
+ this._scheduledTriggers.set(trigger.id, {
477
+ trigger,
478
+ cron,
479
+ nextExecution,
480
+ });
481
+ } else {
482
+ log.error('Invalid cron expression', {
483
+ triggerId: trigger.id,
484
+ cron: timerSpec.cron,
485
+ error: cronEither.left.message,
486
+ });
487
+ }
488
+ }
489
+ }
490
+
491
+ log('Updated scheduled triggers', { count: this._scheduledTriggers.size });
492
+ }).pipe(Effect.withSpan('TriggerDispatcher.refreshTriggers'));
493
+
494
+ private _fetchTriggers = () =>
495
+ Effect.gen(this, function* () {
496
+ const { objects } = yield* DatabaseService.runQuery(Filter.type(FunctionTrigger));
497
+ return objects;
498
+ }).pipe(Effect.withSpan('TriggerDispatcher.fetchTriggers'));
499
+
500
+ private _startNaturalTimeProcessing = (): Effect.Effect<void, never, TriggerDispatcherServices> =>
501
+ Effect.gen(this, function* () {
502
+ yield* this.invokeScheduledTriggers();
503
+ }).pipe(Effect.repeat(Schedule.fixed(this.livePollInterval)), Effect.asVoid);
504
+
505
+ private _prepareInputData = (trigger: FunctionTrigger, event: EventType): any => {
506
+ return createInvocationPayload(trigger, event);
507
+ };
508
+ }
509
+
510
+ // Re-exports
511
+ export { FunctionTrigger, type TimerTrigger } from '../types';
512
+
513
+ /**
514
+ * Key for the current queue cursor for queue triggers.
515
+ */
516
+ const KEY_QUEUE_CURSOR = 'dxos.org/key/local-trigger-dispatcher/queue-cursor';
@@ -0,0 +1,60 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { KeyValueStore } from '@effect/platform';
6
+ import { Context } from 'effect';
7
+ import { Schema } from 'effect';
8
+ import { Effect, Layer } from 'effect';
9
+ import { Option } from 'effect';
10
+
11
+ import { ObjectId } from '@dxos/keys';
12
+
13
+ import { TriggerStateNotFoundError } from '../errors';
14
+
15
+ export const TriggerState = Schema.Struct({
16
+ version: Schema.Literal('1'),
17
+ triggerId: Schema.String,
18
+ state: Schema.optional(
19
+ Schema.Union(
20
+ Schema.TaggedStruct('subscription', {
21
+ processedVersions: Schema.Record({ key: ObjectId, value: Schema.String }),
22
+ }),
23
+ ),
24
+ ),
25
+ });
26
+ export interface TriggerState extends Schema.Schema.Type<typeof TriggerState> {}
27
+
28
+ export class TriggerStateStore extends Context.Tag('@dxos/functions/TriggerStateStore')<
29
+ TriggerStateStore,
30
+ {
31
+ getState(triggerId: ObjectId): Effect.Effect<TriggerState, TriggerStateNotFoundError>;
32
+ saveState(state: TriggerState): Effect.Effect<void>;
33
+ }
34
+ >() {
35
+ static getState = Effect.serviceFunctionEffect(TriggerStateStore, (_) => _.getState);
36
+ static saveState = Effect.serviceFunctionEffect(TriggerStateStore, (_) => _.saveState);
37
+
38
+ static layerKv = Layer.effect(
39
+ TriggerStateStore,
40
+ Effect.gen(function* () {
41
+ const kv = yield* KeyValueStore.KeyValueStore;
42
+ const schemaStore = kv.forSchema(Schema.parseJson(TriggerState));
43
+ const store: Context.Tag.Service<TriggerStateStore> = {
44
+ getState: Effect.fn('TriggerStateStore.getState')(function* (triggerId: ObjectId) {
45
+ const valueOption = yield* schemaStore.get(triggerId).pipe(Effect.orDie);
46
+ if (Option.isNone(valueOption)) {
47
+ return yield* Effect.fail(new TriggerStateNotFoundError());
48
+ }
49
+ return valueOption.value;
50
+ }),
51
+ saveState: Effect.fn('TriggerStateStore.saveState')(function* (state: TriggerState) {
52
+ yield* schemaStore.set(state.triggerId, state).pipe(Effect.orDie);
53
+ }),
54
+ };
55
+ return store;
56
+ }),
57
+ );
58
+
59
+ static layerMemory = TriggerStateStore.layerKv.pipe(Layer.provide(KeyValueStore.layerMemory));
60
+ }