@xmemory/temporal 1.0.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/LICENSE +21 -0
- package/NOTICE +27 -0
- package/README.md +371 -0
- package/dist/activities.d.ts +27 -0
- package/dist/activities.js +195 -0
- package/dist/config.d.ts +53 -0
- package/dist/config.js +114 -0
- package/dist/deadline.d.ts +32 -0
- package/dist/deadline.js +50 -0
- package/dist/defaults.d.ts +49 -0
- package/dist/defaults.js +58 -0
- package/dist/dto.d.ts +54 -0
- package/dist/dto.js +83 -0
- package/dist/errors.d.ts +18 -0
- package/dist/errors.js +247 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +38 -0
- package/dist/interceptor.d.ts +38 -0
- package/dist/interceptor.js +113 -0
- package/dist/names.d.ts +29 -0
- package/dist/names.js +47 -0
- package/dist/plugin.d.ts +31 -0
- package/dist/plugin.js +81 -0
- package/dist/workflow.d.ts +130 -0
- package/dist/workflow.js +412 -0
- package/package.json +70 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) xmemory Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/NOTICE
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
xmemory Temporal integration
|
|
2
|
+
Copyright (c) xmemory Inc.
|
|
3
|
+
|
|
4
|
+
This product is licensed under the MIT License (see LICENSE). The following
|
|
5
|
+
notices supplement — but do not modify — that license.
|
|
6
|
+
|
|
7
|
+
SCOPE. The MIT license covers only this Temporal integration — the source code
|
|
8
|
+
in this directory (the `xmemory-temporal` and `@xmemory/temporal` plugin
|
|
9
|
+
packages), which is a thin client over the xmemory API wired into Temporal.
|
|
10
|
+
|
|
11
|
+
PROPRIETARY SERVICE. The xmemory service that this integration talks to, and the
|
|
12
|
+
underlying technology behind it — including the xmemory backend, its memory
|
|
13
|
+
engine, schemas, extraction and reader models, APIs, and hosted
|
|
14
|
+
infrastructure — are proprietary to xmemory Inc. and are NOT licensed under the
|
|
15
|
+
MIT license. Nothing in this license grants any right to access, copy, reverse
|
|
16
|
+
engineer, reimplement, or create derivative works of the xmemory service or its
|
|
17
|
+
underlying technology. Use of the service is governed by the Terms & Conditions
|
|
18
|
+
at https://xmemory.ai/terms-and-conditions.html and the Privacy Policy at
|
|
19
|
+
https://xmemory.ai/privacy-policy.html, and requires valid credentials issued by
|
|
20
|
+
xmemory Inc.
|
|
21
|
+
|
|
22
|
+
TRADEMARKS. The "xmemory" name and logo are trademarks of xmemory Inc. The MIT
|
|
23
|
+
license does not grant permission to use them, except as required for reasonable
|
|
24
|
+
and customary use in describing the origin of the Software. "Temporal" is a
|
|
25
|
+
trademark of Temporal Technologies, Inc.; this is an independent, unofficial
|
|
26
|
+
integration and is not affiliated with, endorsed by, or sponsored by Temporal
|
|
27
|
+
Technologies, Inc.
|
package/README.md
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
# @xmemory/temporal
|
|
2
|
+
|
|
3
|
+
Durable agent memory for [Temporal](https://temporal.io) — add
|
|
4
|
+
[xmemory](https://xmemory.ai) reads and writes to your workflows as replay-safe
|
|
5
|
+
Temporal Activities, with one plugin line on your Worker.
|
|
6
|
+
|
|
7
|
+
> An agent's memory is exactly the state you don't want to lose when a worker
|
|
8
|
+
> crashes mid-turn. Putting xmemory behind Temporal makes a memory write a
|
|
9
|
+
> durable step: it survives process death, redeploys, and rolling upgrades, and
|
|
10
|
+
> Temporal — not your code — owns its retries and timeouts.
|
|
11
|
+
|
|
12
|
+
A Python port with the same API ships as
|
|
13
|
+
[`xmemory-temporal`](https://github.com/xmemory-ai/xmemory-temporal).
|
|
14
|
+
|
|
15
|
+
### Memory is untrusted data, both ways
|
|
16
|
+
|
|
17
|
+
What goes in is user-controlled text. What comes back is that text plus whatever
|
|
18
|
+
the extraction engine made of it. Neither is a safe source of instructions: a read
|
|
19
|
+
result in a prompt is the indirect prompt-injection path, and the same string in a
|
|
20
|
+
shell or a query is the ordinary injection path. Quote it, bound it, and keep it
|
|
21
|
+
out of anything that decides what to do next.
|
|
22
|
+
|
|
23
|
+
## What you get
|
|
24
|
+
|
|
25
|
+
- **Memory as Activities.** `read`, `write`, `writeAsyncStart` + `writeStatus` run as
|
|
26
|
+
Activities (all I/O stays out of workflow code, so workflows replay
|
|
27
|
+
deterministically).
|
|
28
|
+
- **A durable deep write.** `writeDurable(text)` enqueues a write and polls it to
|
|
29
|
+
completion from the workflow, so a multi-minute extraction survives worker
|
|
30
|
+
restarts — the poll state lives in workflow history, not a worker process.
|
|
31
|
+
- **A near-zero-diff migration.** The workflow-side handle mirrors the plain
|
|
32
|
+
xmemory client's methods, so agent code that already calls `inst.read(...)` /
|
|
33
|
+
`inst.write(...)` keeps working — it just dispatches to an Activity. Two
|
|
34
|
+
deliberate differences: the enqueue is `writeAsyncStart` (the client calls it
|
|
35
|
+
`writeAsync`), and results are projected into this package's own DTOs so a
|
|
36
|
+
client field rename cannot break replay of a completed workflow.
|
|
37
|
+
- **Temporal-owned retries and timeouts.** xmemory errors map to typed
|
|
38
|
+
`ApplicationFailure`s with retryable/non-retryable verdicts, so you can tune
|
|
39
|
+
`RetryPolicy` against stable error-type strings.
|
|
40
|
+
- **Opt-in auto-capture** of activity results into memory, via an Activity
|
|
41
|
+
interceptor that never touches the replay path.
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npm install @xmemory/temporal
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Requires Node.js 22.12+ and `@temporalio/*` 1.20.
|
|
50
|
+
|
|
51
|
+
Two things to know if you type-check with `skipLibCheck: false`:
|
|
52
|
+
|
|
53
|
+
- Add `@types/ms` to your own devDependencies. `@temporalio/common` references
|
|
54
|
+
`ms`, which ships no types.
|
|
55
|
+
- Use `@types/node` 22 or 24. Temporal's own declarations do not compile against
|
|
56
|
+
25 or newer (`EventEmitter<[never]>` fails its own constraint, still true in
|
|
57
|
+
`@temporalio/worker` 1.22.0), so those need `skipLibCheck: true` until the SDK
|
|
58
|
+
catches up. The runtime is unaffected — only the types.
|
|
59
|
+
|
|
60
|
+
## Quickstart
|
|
61
|
+
|
|
62
|
+
Register the plugin on your **Worker**:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
import { NativeConnection, Worker } from '@temporalio/worker';
|
|
66
|
+
import { XmemoryPlugin } from '@xmemory/temporal';
|
|
67
|
+
|
|
68
|
+
const plugin = new XmemoryPlugin({ instanceId: '<your-instance-id>' }); // reads XMEM_API_KEY
|
|
69
|
+
const connection = await NativeConnection.connect({ address: 'localhost:7233' });
|
|
70
|
+
const worker = await Worker.create({
|
|
71
|
+
connection,
|
|
72
|
+
taskQueue: 'my-agent',
|
|
73
|
+
workflowsPath: require.resolve('./workflows'),
|
|
74
|
+
plugins: [plugin],
|
|
75
|
+
});
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Then call memory from inside a workflow:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
// workflows.ts
|
|
82
|
+
import { xmemoryForWorkflow } from '@xmemory/temporal/workflow';
|
|
83
|
+
|
|
84
|
+
export async function myWorkflow(userName: string, userMessage: string): Promise<unknown> {
|
|
85
|
+
const mem = xmemoryForWorkflow();
|
|
86
|
+
// Name whom the fact is about — a memory store has no ambient "current user".
|
|
87
|
+
await mem.writeDurable(`${userName}: ${userMessage}`);
|
|
88
|
+
return (await mem.read(`what do we know about ${userName}?`)).readerResult;
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
One worker, one instance. The activities bind to whatever instance the plugin
|
|
93
|
+
configured, so **every worker polling a task queue must share that configuration**.
|
|
94
|
+
Per-tenant isolation means a task queue per tenant, not a per-workflow option; the
|
|
95
|
+
example above uses one shared instance and names the user in the text rather than
|
|
96
|
+
isolating them.
|
|
97
|
+
|
|
98
|
+
Which queue is a trust decision: derive the tenant from an authenticated identity,
|
|
99
|
+
never from a caller- or model-supplied value. A workflow argument naming a task
|
|
100
|
+
queue is a request to read someone else's memory.
|
|
101
|
+
|
|
102
|
+
Workflow code imports from **`@xmemory/temporal/workflow`**, not the package
|
|
103
|
+
root. That subpath is a leaf: it reaches no Activity code and no xmemory client,
|
|
104
|
+
so Temporal's workflow bundler accepts it. The root entry loads the plugin and
|
|
105
|
+
the client, which the bundler rejects by design, so it belongs in worker setup
|
|
106
|
+
only. A CI step bundles a workflow against the built package to keep it that
|
|
107
|
+
way.
|
|
108
|
+
|
|
109
|
+
> **Use one plugin instance per Worker.** The bound client lives in a per-plugin
|
|
110
|
+
> holder, so reusing one plugin object across two Workers is last-bind-wins.
|
|
111
|
+
|
|
112
|
+
Runnable end-to-end scripts live in [`examples/`](./examples): create an instance
|
|
113
|
+
with a schema, run a worker, and drive a support-agent workflow.
|
|
114
|
+
|
|
115
|
+
## Timeouts
|
|
116
|
+
|
|
117
|
+
**The workflow owns every activity budget.** `xmemoryForWorkflow()` sets each
|
|
118
|
+
call's `startToCloseTimeout`, and the activity derives its xmemory client timeout
|
|
119
|
+
from the deadline Temporal assigned it, always a margin below, so the client gives
|
|
120
|
+
up first and you get an attributable xmemory error instead of an opaque Temporal
|
|
121
|
+
activity timeout. An activity scheduled with neither close timeout fails fast with
|
|
122
|
+
`XmemoryNoDeadline` rather than picking a budget of its own.
|
|
123
|
+
|
|
124
|
+
The margin covers the whole attempt, including work Temporal does before the
|
|
125
|
+
activity function runs. Keep it larger than your Client-level activity interceptors.
|
|
126
|
+
The ordering holds above timer resolution: a deadline of a few milliseconds is too
|
|
127
|
+
short to fit a call and a margin, and which side fires first is then a coin toss.
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
const mem = xmemoryForWorkflow({
|
|
131
|
+
readTimeout: '60s', // a deep read on a large instance
|
|
132
|
+
writeTimeout: '5m',
|
|
133
|
+
});
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
The client timeout is derived, not configured separately, so lowering a workflow's
|
|
137
|
+
budget lowers the client's with it. `DEFAULT_TIMEOUTS` supplies the defaults;
|
|
138
|
+
`{ clientMarginMs }` tunes the gap.
|
|
139
|
+
|
|
140
|
+
Each of those bounds one **attempt**. Nothing bounds the retry sequence unless you
|
|
141
|
+
say so: a server asking for an hour before the next try is honoured as asked, so a
|
|
142
|
+
rate-limited read can sit in retries far longer than its own timeout suggests. Set
|
|
143
|
+
`totalTimeout` when a call has a deadline of its own — it becomes the activity's
|
|
144
|
+
`scheduleToCloseTimeout`, covering every attempt.
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
const mem = xmemoryForWorkflow({ readTimeout: '30s', totalTimeout: '2m' });
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
**A timed-out write is indeterminate.** Nothing distinguishes "never arrived" from
|
|
151
|
+
"arrived, response lost", and the request is abandoned rather than cancelled (the
|
|
152
|
+
client exposes no `AbortSignal`). So write Activities default to
|
|
153
|
+
`maximumAttempts: 1` and surface the failure. Treat a timed-out write as *may or may
|
|
154
|
+
not have happened*, and reconcile with a read if it matters.
|
|
155
|
+
|
|
156
|
+
## Durable writes
|
|
157
|
+
|
|
158
|
+
`writeDurable(text)` enqueues a deep write and polls it to completion from the
|
|
159
|
+
workflow, so the wait is a Temporal timer in server-side history rather than a
|
|
160
|
+
blocked activity slot. Redeploy the worker fleet mid-write and nothing is lost:
|
|
161
|
+
the poll loop resumes on the new worker and completes.
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
const status = await mem.writeDurable(text, { maxWaitMs: 15 * 60_000 });
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Each poll adds history events — an activity, a timer, and the workflow tasks driving
|
|
168
|
+
them — and how many is a detail of your SDK and server versions, not something this
|
|
169
|
+
package can predict for you. Backoff slows the growth until the interval reaches
|
|
170
|
+
`maxPollIntervalMs`, after which history grows linearly with the wait. Keep
|
|
171
|
+
`pollIntervalMs` at seconds rather than milliseconds, since that history is shared
|
|
172
|
+
with the rest of your workflow; the loop warns once Temporal itself suggests
|
|
173
|
+
continuing as new.
|
|
174
|
+
|
|
175
|
+
Two different kinds of pacing sit here, so the names are worth separating.
|
|
176
|
+
`pollIntervalMs` and `maxPollIntervalMs` set how long the loop waits *between*
|
|
177
|
+
polls. `writeStatusRetry` sets how one poll retries when it fails — attempts and
|
|
178
|
+
backoff for the activity itself:
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
const mem = xmemoryForWorkflow({
|
|
182
|
+
writeStatusRetry: { attempts: 4, intervalMs: 2_000, maxIntervalMs: 8_000 },
|
|
183
|
+
});
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Those are plain numbers rather than a Temporal `RetryPolicy`, and this package
|
|
187
|
+
builds the policy from them. Temporal compiles a policy when it schedules the
|
|
188
|
+
activity, which for the first poll is *after* the write is enqueued — so a policy it
|
|
189
|
+
refuses would leave a queued write nobody is watching. There is no policy to refuse
|
|
190
|
+
if the package builds it.
|
|
191
|
+
|
|
192
|
+
This helper cannot call `continueAsNew` for you — it runs inside *your* workflow,
|
|
193
|
+
and restarting that would discard your state. For multi-hour waits, run
|
|
194
|
+
`writeDurable` in a child workflow.
|
|
195
|
+
|
|
196
|
+
`maxWaitMs` bounds the *waiting*: every poll is scheduled to finish inside it. The
|
|
197
|
+
one exception is the last observation, which happens **at** the deadline so a write
|
|
198
|
+
that lands late is still seen rather than reported as a timeout — so a call can
|
|
199
|
+
return up to one status poll after `maxWaitMs`. When the server has asked for a
|
|
200
|
+
retry delay longer than the wait has left, that final poll is skipped instead:
|
|
201
|
+
arriving before the server said it would answer is worse than not looking.
|
|
202
|
+
|
|
203
|
+
For the fire-and-forget pattern (kick off several writes, keep working, join
|
|
204
|
+
before the turn ends), `writeAsyncStart()` and `writeStatus()` are public too.
|
|
205
|
+
|
|
206
|
+
## Credentials never reach workflow history
|
|
207
|
+
|
|
208
|
+
The config holds the **name** of the environment variable that supplies the API
|
|
209
|
+
key (`XMEM_API_KEY` by default), never the key itself — so nothing secret is ever
|
|
210
|
+
serialized into activity arguments, which Temporal persists in the clear. Pass
|
|
211
|
+
the key in-process instead with `new XmemoryPlugin(config, { apiKey })` if you
|
|
212
|
+
prefer.
|
|
213
|
+
|
|
214
|
+
**Your memory text and queries, however, *are* in history.** Queries, written text,
|
|
215
|
+
and `readerResult` are activity payloads, persisted in the clear and visible in the
|
|
216
|
+
Web UI. The error mapping keeps raw transport strings and the server's failure
|
|
217
|
+
detail out of failures (set `logServerErrorDetail: true` to log the reason
|
|
218
|
+
worker-side), but the payloads themselves remain.
|
|
219
|
+
|
|
220
|
+
`includeContentInSummary: false` (the default) only affects the one-line activity
|
|
221
|
+
*summary*. For sensitive memory text, install a Temporal **Payload Codec**; this
|
|
222
|
+
plugin does not impose one, since a codec applies to every payload in the
|
|
223
|
+
namespace, not just xmemory's.
|
|
224
|
+
|
|
225
|
+
## Replay safety and idempotency
|
|
226
|
+
|
|
227
|
+
Two things keep memory operations correct under retries and replay:
|
|
228
|
+
|
|
229
|
+
- **Replay never re-issues an operation.** All I/O is in Activities; workflow
|
|
230
|
+
code only schedules Activities and sleeps. Temporal replays workflow code but
|
|
231
|
+
never re-runs a completed Activity, so a replay never repeats a memory read or
|
|
232
|
+
write. The suite proves this with a forced-replay (`maxCachedWorkflows: 0`)
|
|
233
|
+
side-effects test.
|
|
234
|
+
- **Writes default to at-most-once.** Primary-key dedup looks like it would make
|
|
235
|
+
retries safe, but PK extraction is non-deterministic: a model normalizes the same
|
|
236
|
+
value differently across runs (`Dr. Robert Kim` vs `Robert Kim`), and a
|
|
237
|
+
disagreement forks a new row. So a lost-response retry can duplicate. Write
|
|
238
|
+
Activities default to `maximumAttempts: 1` and surface the failure to your
|
|
239
|
+
workflow. Reads and status polls are idempotent and retry generously.
|
|
240
|
+
|
|
241
|
+
**Structured writes are the reliable way to make a write retryable.** Pass
|
|
242
|
+
explicit mutations instead of free text and the primary key is one you supply, so
|
|
243
|
+
nothing is extracted and re-applying the write is deterministic.
|
|
244
|
+
|
|
245
|
+
Retrying is opted into per handle, through `writeRetryPolicy`, and an opted-in handle
|
|
246
|
+
is no longer at-most-once: every write through it retries, text writes and creates
|
|
247
|
+
included. So give keyed structured writes a handle of their own, and keep the
|
|
248
|
+
default handle for everything else. Always set `maximumAttempts` — Temporal reads an
|
|
249
|
+
omitted one as unlimited.
|
|
250
|
+
|
|
251
|
+
```ts
|
|
252
|
+
const keyedWrites = xmemoryForWorkflow({ writeRetryPolicy: { maximumAttempts: 3 } });
|
|
253
|
+
await keyedWrites.write('', {
|
|
254
|
+
structuredMutations: [
|
|
255
|
+
{
|
|
256
|
+
object_mutation: {
|
|
257
|
+
object_type: 'Customer',
|
|
258
|
+
update: { key: { customerId: 'c-1' }, values: { tier: 'gold' } },
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
],
|
|
262
|
+
});
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
A mutation is a `create`, `update`, or `delete` on one object or relation. An
|
|
266
|
+
update or delete names the key it addresses, so a retry hits the same row. A create
|
|
267
|
+
does not — the server assigns the key — so a create is not safe to retry.
|
|
268
|
+
|
|
269
|
+
For text writes, opt into retries only when your primary keys are literal
|
|
270
|
+
identifiers appearing verbatim in the text, such as a `customerId` you supply. That
|
|
271
|
+
is a convention you keep, not something the API enforces.
|
|
272
|
+
|
|
273
|
+
[`examples/setup-memory.ts`](./examples/setup-memory.ts) shows creating an
|
|
274
|
+
instance with a schema.
|
|
275
|
+
|
|
276
|
+
## Error handling
|
|
277
|
+
|
|
278
|
+
xmemory errors become `ApplicationFailure`s with stable `type` strings you can
|
|
279
|
+
match in a `RetryPolicy` (`nonRetryableErrorTypes: [...]`). The mapping is
|
|
280
|
+
derived from the server's error codes:
|
|
281
|
+
|
|
282
|
+
| xmemory condition | `type` | Retryable? |
|
|
283
|
+
|---|---|---|
|
|
284
|
+
| transport error / timeout / HTTP ≥ 500 / 408 | `XmemoryServerError` / `XmemoryUnavailable` | yes |
|
|
285
|
+
| `RATE_LIMITED` (429) | `XmemoryRateLimited` | yes — honors `Retry-After` |
|
|
286
|
+
| `QUOTA_EXCEEDED` + `daily_quota_exceeded` | `XmemoryDailyQuotaExceeded` | yes (long backoff) |
|
|
287
|
+
| `QUOTA_EXCEEDED` + `monthly_quota_exceeded` | `XmemoryMonthlyQuotaExceeded` | no |
|
|
288
|
+
| `QUOTA_EXCEEDED` (kind unknown) | `XmemoryQuotaExceeded` | no |
|
|
289
|
+
| `UNAUTHORIZED` / `FORBIDDEN` | `XmemoryAuthFailed` | no |
|
|
290
|
+
| `NOT_FOUND` | `XmemoryNotFound` | no |
|
|
291
|
+
| validation / conflict / schema-evolution rejections | `XmemoryBadRequest` / `XmemorySchemaRejected` | no |
|
|
292
|
+
| activities registered without the plugin | `XmemoryNotBound` | no |
|
|
293
|
+
| an activity scheduled with neither close timeout | `XmemoryNoDeadline` | no |
|
|
294
|
+
| durable-write options that cannot be honored | `XmemoryBadOptions` | no — the same arguments fail identically |
|
|
295
|
+
| an unrecognized code | `XmemoryUnknown` | follows the HTTP status |
|
|
296
|
+
|
|
297
|
+
Plus three raised by the durable write loop (`writeDurable`), from a polled
|
|
298
|
+
`writeStatus`, all non-retryable:
|
|
299
|
+
|
|
300
|
+
| durable-write outcome | `type` |
|
|
301
|
+
|---|---|
|
|
302
|
+
| the queued write reported `failed` | `XmemoryWriteFailed` |
|
|
303
|
+
| the queued write id was `not_found` | `XmemoryWriteNotFound` |
|
|
304
|
+
| polling exceeded `maxWait` | `XmemoryWriteTimeout` |
|
|
305
|
+
|
|
306
|
+
An unrecognized error code never raises and keeps its own type — a stricter client
|
|
307
|
+
that crashed on a newer server's code would break during rolling deploys. Whether it
|
|
308
|
+
is retried comes from the HTTP status rather than the code: 5xx, 408, 429 and a
|
|
309
|
+
missing status are retried, while a 4xx is terminal however it is labelled. Retrying
|
|
310
|
+
a 401 until the attempts run out helps nobody, and on a write it repeats a call that
|
|
311
|
+
cannot succeed.
|
|
312
|
+
|
|
313
|
+
> **Note.** 402 means `QUOTA_EXCEEDED` only. `TRIAL_ENDED` was removed from the
|
|
314
|
+
> xmemory contract when trials were retired end-to-end; do not rely on it.
|
|
315
|
+
|
|
316
|
+
## Auto-capture (opt-in)
|
|
317
|
+
|
|
318
|
+
```ts
|
|
319
|
+
const plugin = new XmemoryPlugin(
|
|
320
|
+
{ instanceId: '<your-instance-id>' },
|
|
321
|
+
{
|
|
322
|
+
autoCapture: {
|
|
323
|
+
project: (activityName, result) => summarize(result), // return undefined to skip
|
|
324
|
+
sampleRate: 0.25,
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
);
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
Off by default. It runs as an **Activity** interceptor, outside the replay path;
|
|
331
|
+
`project` decides what to remember, sampling bounds fan-out, and a capture failure
|
|
332
|
+
never fails the wrapped activity. Capture is an enqueue (`writeAsync`) clamped to
|
|
333
|
+
what the activity has left of its deadline, and skipped when nothing is left, so it
|
|
334
|
+
cannot push the activity past its `startToClose`.
|
|
335
|
+
|
|
336
|
+
Your `project` function is the exception: it is synchronous code, and JavaScript
|
|
337
|
+
cannot interrupt it. One that blocks longer than the activity has left will push it
|
|
338
|
+
past the deadline however carefully the enqueue is budgeted. Keep projections
|
|
339
|
+
cheap.
|
|
340
|
+
|
|
341
|
+
> **Auto-capture is at-least-once.** If a worker dies after the capture enqueue but
|
|
342
|
+
> before the Activity's completion is recorded, the Activity runs again and captures
|
|
343
|
+
> again — and since primary keys are extracted, a duplicate can fork an entity.
|
|
344
|
+
> Capture facts a duplicate would not corrupt.
|
|
345
|
+
|
|
346
|
+
> **Naming caveat.** Auto-capture skips any activity whose name starts with
|
|
347
|
+
> `xmemory_` (to avoid capturing its own writes). If you name one of *your* own
|
|
348
|
+
> activities `xmemory_...`, it will be silently skipped. It also never captures
|
|
349
|
+
> Queries.
|
|
350
|
+
|
|
351
|
+
## Testing
|
|
352
|
+
|
|
353
|
+
```bash
|
|
354
|
+
npm ci
|
|
355
|
+
npm run lint # tsc over src + test + examples (strict)
|
|
356
|
+
npm test # tsc, then node:test over test/*.test.ts
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
The suite runs with no live backend (a fake instance is injected). See
|
|
360
|
+
[`TESTING.md`](./TESTING.md) for the full strategy.
|
|
361
|
+
|
|
362
|
+
## Legal
|
|
363
|
+
|
|
364
|
+
- Privacy policy: <https://xmemory.ai/privacy-policy.html>
|
|
365
|
+
- Terms: <https://xmemory.ai/terms-and-conditions.html>
|
|
366
|
+
|
|
367
|
+
**MIT licensed** — see [`LICENSE`](./LICENSE). The grant covers this integration's
|
|
368
|
+
code only. The xmemory service and its technology remain proprietary to xmemory
|
|
369
|
+
Inc.; using it requires valid credentials and is governed by the Terms above. The
|
|
370
|
+
scope and trademark notices live in [`NOTICE`](./NOTICE), kept separate so the
|
|
371
|
+
package classifies cleanly as MIT.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The xmemory activities: the only place this package does I/O.
|
|
3
|
+
*
|
|
4
|
+
* The client is injected through a holder, so tests substitute a fake with no
|
|
5
|
+
* patching. Each call's client timeout is derived from the deadline Temporal
|
|
6
|
+
* assigned the activity.
|
|
7
|
+
*/
|
|
8
|
+
import type { InstanceHandle } from 'xmemory';
|
|
9
|
+
import type { XmemoryConfig } from './config';
|
|
10
|
+
import { type ReadInput, type ReadOutput, type WriteInput, type WriteOutput, type WriteStartOutput, type WriteStatusInput, type WriteStatusOutput } from './dto';
|
|
11
|
+
import { ACTIVITY_READ, ACTIVITY_WRITE, ACTIVITY_WRITE_START, ACTIVITY_WRITE_STATUS } from './names';
|
|
12
|
+
export { ACTIVITY_READ, ACTIVITY_WRITE, ACTIVITY_WRITE_START, ACTIVITY_WRITE_STATUS };
|
|
13
|
+
/** The client methods this package calls. Injectable, so tests need no backend. */
|
|
14
|
+
export type XmemoryInstance = Pick<InstanceHandle, 'read' | 'write' | 'writeAsync' | 'writeStatus'>;
|
|
15
|
+
/** Holds the injected client so activity functions can close over it. */
|
|
16
|
+
export declare class InstanceHolder {
|
|
17
|
+
private instance;
|
|
18
|
+
bind(instance: XmemoryInstance): void;
|
|
19
|
+
get(): XmemoryInstance;
|
|
20
|
+
}
|
|
21
|
+
export interface XmemoryActivities {
|
|
22
|
+
[ACTIVITY_READ]: (input: ReadInput) => Promise<ReadOutput>;
|
|
23
|
+
[ACTIVITY_WRITE]: (input: WriteInput) => Promise<WriteOutput>;
|
|
24
|
+
[ACTIVITY_WRITE_START]: (input: WriteInput) => Promise<WriteStartOutput>;
|
|
25
|
+
[ACTIVITY_WRITE_STATUS]: (input: WriteStatusInput) => Promise<WriteStatusOutput>;
|
|
26
|
+
}
|
|
27
|
+
export declare function createActivities(holder: InstanceHolder, config: XmemoryConfig): XmemoryActivities;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The xmemory activities: the only place this package does I/O.
|
|
4
|
+
*
|
|
5
|
+
* The client is injected through a holder, so tests substitute a fake with no
|
|
6
|
+
* patching. Each call's client timeout is derived from the deadline Temporal
|
|
7
|
+
* assigned the activity.
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.InstanceHolder = exports.ACTIVITY_WRITE_STATUS = exports.ACTIVITY_WRITE_START = exports.ACTIVITY_WRITE = exports.ACTIVITY_READ = void 0;
|
|
11
|
+
exports.createActivities = createActivities;
|
|
12
|
+
const activity_1 = require("@temporalio/activity");
|
|
13
|
+
const common_1 = require("@temporalio/common");
|
|
14
|
+
const deadline_1 = require("./deadline");
|
|
15
|
+
const defaults_1 = require("./defaults");
|
|
16
|
+
const dto_1 = require("./dto");
|
|
17
|
+
const errors_1 = require("./errors");
|
|
18
|
+
const names_1 = require("./names");
|
|
19
|
+
Object.defineProperty(exports, "ACTIVITY_READ", { enumerable: true, get: function () { return names_1.ACTIVITY_READ; } });
|
|
20
|
+
Object.defineProperty(exports, "ACTIVITY_WRITE", { enumerable: true, get: function () { return names_1.ACTIVITY_WRITE; } });
|
|
21
|
+
Object.defineProperty(exports, "ACTIVITY_WRITE_START", { enumerable: true, get: function () { return names_1.ACTIVITY_WRITE_START; } });
|
|
22
|
+
Object.defineProperty(exports, "ACTIVITY_WRITE_STATUS", { enumerable: true, get: function () { return names_1.ACTIVITY_WRITE_STATUS; } });
|
|
23
|
+
/** Holds the injected client so activity functions can close over it. */
|
|
24
|
+
class InstanceHolder {
|
|
25
|
+
instance;
|
|
26
|
+
bind(instance) {
|
|
27
|
+
this.instance = instance;
|
|
28
|
+
}
|
|
29
|
+
get() {
|
|
30
|
+
if (!this.instance) {
|
|
31
|
+
// The plugin's runWorker hook never bound a client. Retrying cannot fix it.
|
|
32
|
+
throw common_1.ApplicationFailure.create({
|
|
33
|
+
message: 'xmemory activities are not bound to a client — register XmemoryPlugin on the Worker ' +
|
|
34
|
+
'rather than registering the activity functions directly.',
|
|
35
|
+
type: errors_1.TYPE_NOT_BOUND,
|
|
36
|
+
nonRetryable: true,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
return this.instance;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
exports.InstanceHolder = InstanceHolder;
|
|
43
|
+
/**
|
|
44
|
+
* What to hand the client: the text, or the mutations when there are any.
|
|
45
|
+
*
|
|
46
|
+
* Checked at runtime, because activity inputs arrive as JSON and nothing enforces
|
|
47
|
+
* their TypeScript types. The client's `write` and `writeAsync` are overloaded on
|
|
48
|
+
* the first argument and treat anything but a string as structured mutations, so a
|
|
49
|
+
* `text` that arrived as an array would skip extraction and apply as an update or
|
|
50
|
+
* delete, and a `structuredMutations` string would be written as memory.
|
|
51
|
+
*
|
|
52
|
+
* An empty list is refused too: the client answers `[]` with a plain Error, which
|
|
53
|
+
* the mapper reads as a retryable transport failure. Called outside the try, or
|
|
54
|
+
* `toApplicationFailure` would remap the verdict.
|
|
55
|
+
*/
|
|
56
|
+
function requireWritePayload(input) {
|
|
57
|
+
const mutations = input.structuredMutations ?? undefined;
|
|
58
|
+
if (mutations !== undefined) {
|
|
59
|
+
if (!Array.isArray(mutations)) {
|
|
60
|
+
throw badOptions(`xmemory structuredMutations must be a list of mutations, got ${kindOf(mutations)}`);
|
|
61
|
+
}
|
|
62
|
+
if (mutations.length === 0) {
|
|
63
|
+
throw badOptions('xmemory write was given an empty structuredMutations list; omit it to write text instead');
|
|
64
|
+
}
|
|
65
|
+
return mutations;
|
|
66
|
+
}
|
|
67
|
+
const text = input.text;
|
|
68
|
+
if (typeof text !== 'string') {
|
|
69
|
+
throw badOptions(`xmemory write text must be a string, got ${kindOf(text)}`);
|
|
70
|
+
}
|
|
71
|
+
return text;
|
|
72
|
+
}
|
|
73
|
+
// The kind only, never the value: failure messages are persisted to history.
|
|
74
|
+
function kindOf(value) {
|
|
75
|
+
return value === null ? 'null' : Array.isArray(value) ? 'an array' : typeof value;
|
|
76
|
+
}
|
|
77
|
+
function badOptions(message) {
|
|
78
|
+
return common_1.ApplicationFailure.create({ message, type: errors_1.TYPE_BAD_OPTIONS, nonRetryable: true });
|
|
79
|
+
}
|
|
80
|
+
function createActivities(holder, config) {
|
|
81
|
+
const defaultLogic = config.defaultExtractionLogic ?? 'fast';
|
|
82
|
+
/**
|
|
83
|
+
* Client budget for the running activity, from its own deadline. Derived rather
|
|
84
|
+
* than kept as a second worker-side copy, so "the client gives up first" holds
|
|
85
|
+
* even when a workflow lowers its timeout.
|
|
86
|
+
*/
|
|
87
|
+
const budgetMs = () => {
|
|
88
|
+
const info = activity_1.Context.current().info;
|
|
89
|
+
const deadlineMs = (0, deadline_1.activityBudgetMs)(info);
|
|
90
|
+
if (deadlineMs === null) {
|
|
91
|
+
throw common_1.ApplicationFailure.create({
|
|
92
|
+
message: `activity ${info.activityType} was scheduled without a deadline: ` +
|
|
93
|
+
'set startToCloseTimeout or scheduleToCloseTimeout on it.',
|
|
94
|
+
type: errors_1.TYPE_NO_DEADLINE,
|
|
95
|
+
nonRetryable: true,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
if (deadlineMs <= 0) {
|
|
99
|
+
// Temporal has given up on this attempt; a token budget would send a request
|
|
100
|
+
// nobody reads, and a write the server could still accept.
|
|
101
|
+
throw common_1.ApplicationFailure.create({
|
|
102
|
+
message: `activity ${info.activityType} is past its deadline`,
|
|
103
|
+
type: errors_1.TYPE_DEADLINE_EXPIRED,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return (0, defaults_1.clientTimeoutMs)(deadlineMs, config.clientMarginMs);
|
|
107
|
+
};
|
|
108
|
+
const writeOptions = (input, timeoutMs) => ({
|
|
109
|
+
extractionLogic: (input.extractionLogic ?? defaultLogic),
|
|
110
|
+
...(input.diffEngine !== undefined ? { diffEngine: input.diffEngine } : {}),
|
|
111
|
+
timeoutMs,
|
|
112
|
+
});
|
|
113
|
+
return {
|
|
114
|
+
async [names_1.ACTIVITY_READ](input) {
|
|
115
|
+
// Outside the try: an unbound-client or missing-deadline failure must keep
|
|
116
|
+
// its non-retryable type rather than being re-mapped by toApplicationFailure.
|
|
117
|
+
const instance = holder.get();
|
|
118
|
+
const timeoutMs = budgetMs();
|
|
119
|
+
try {
|
|
120
|
+
const result = await (0, deadline_1.withDeadline)(instance.read(input.query, {
|
|
121
|
+
...(input.readMode !== undefined ? { readMode: input.readMode } : {}),
|
|
122
|
+
...(input.scope !== undefined ? { scope: input.scope } : {}),
|
|
123
|
+
timeoutMs,
|
|
124
|
+
}), timeoutMs);
|
|
125
|
+
return (0, dto_1.projectRead)(result);
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
throw (0, errors_1.toApplicationFailure)(err);
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
async [names_1.ACTIVITY_WRITE](input) {
|
|
132
|
+
const instance = holder.get();
|
|
133
|
+
const ms = budgetMs();
|
|
134
|
+
const payload = requireWritePayload(input);
|
|
135
|
+
try {
|
|
136
|
+
// A structured write carries its own keys, so the server applies it without
|
|
137
|
+
// running the extractor; text and extractionLogic are moot.
|
|
138
|
+
const result = await (0, deadline_1.withDeadline)(typeof payload === 'string'
|
|
139
|
+
? instance.write(payload, writeOptions(input, ms))
|
|
140
|
+
: instance.write(payload, { timeoutMs: ms }), ms);
|
|
141
|
+
return (0, dto_1.projectWrite)(result);
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
throw (0, errors_1.toApplicationFailure)(err);
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
async [names_1.ACTIVITY_WRITE_START](input) {
|
|
148
|
+
const instance = holder.get();
|
|
149
|
+
const ms = budgetMs();
|
|
150
|
+
const payload = requireWritePayload(input);
|
|
151
|
+
try {
|
|
152
|
+
return (0, dto_1.projectWriteStart)(await (0, deadline_1.withDeadline)(typeof payload === 'string'
|
|
153
|
+
? instance.writeAsync(payload, writeOptions(input, ms))
|
|
154
|
+
: instance.writeAsync(payload, { timeoutMs: ms }), ms));
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
throw (0, errors_1.toApplicationFailure)(err);
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
async [names_1.ACTIVITY_WRITE_STATUS](input) {
|
|
161
|
+
const instance = holder.get();
|
|
162
|
+
const timeoutMs = budgetMs();
|
|
163
|
+
const writeId = input.writeId;
|
|
164
|
+
try {
|
|
165
|
+
const result = await (0, deadline_1.withDeadline)(instance.writeStatus(writeId, { timeoutMs }), timeoutMs);
|
|
166
|
+
const projected = (0, dto_1.projectWriteStatus)(result);
|
|
167
|
+
if (projected.writeId !== writeId) {
|
|
168
|
+
// Before logging: another write's detail would otherwise be logged under
|
|
169
|
+
// the id we asked about, and its outcome read as ours.
|
|
170
|
+
throw new Error('xmemory returned a status for a different write');
|
|
171
|
+
}
|
|
172
|
+
const errorDetail = result.error_detail;
|
|
173
|
+
if (errorDetail) {
|
|
174
|
+
// Never the return value: history keeps Activity results in the clear.
|
|
175
|
+
// Not verbatim in the log either unless asked for — the detail is not
|
|
176
|
+
// promised user-safe, and logs travel.
|
|
177
|
+
activity_1.Context.current().log.warn('xmemory write failed',
|
|
178
|
+
// `=== true`, not truthiness: this config can come from a file or an
|
|
179
|
+
// env var, and the string "false" would otherwise turn the detail on.
|
|
180
|
+
config.logServerErrorDetail === true
|
|
181
|
+
? { writeId, errorDetail }
|
|
182
|
+
: {
|
|
183
|
+
writeId,
|
|
184
|
+
errorDetailLength: errorDetail.length,
|
|
185
|
+
note: 'detail withheld from logs and history; set logServerErrorDetail to include it',
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
return projected;
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
throw (0, errors_1.toApplicationFailure)(err);
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|