@coji/durably 0.13.0 → 0.15.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/README.md +7 -3
- package/dist/{chunk-UCUP6NMJ.js → chunk-L42OCQEV.js} +3 -3
- package/dist/chunk-L42OCQEV.js.map +1 -0
- package/dist/{index-DWsJlgyh.d.ts → index-CXH4ozmK.d.ts} +104 -18
- package/dist/index.d.ts +26 -4
- package/dist/index.js +1250 -764
- package/dist/index.js.map +1 -1
- package/dist/plugins/index.d.ts +1 -1
- package/dist/plugins/index.js +1 -1
- package/docs/llms.md +149 -17
- package/package.json +5 -5
- package/dist/chunk-UCUP6NMJ.js.map +0 -1
package/dist/plugins/index.d.ts
CHANGED
package/dist/plugins/index.js
CHANGED
package/docs/llms.md
CHANGED
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
# Durably - LLM Documentation
|
|
2
2
|
|
|
3
|
-
> Step-oriented resumable batch execution for Node.js and browsers using SQLite.
|
|
3
|
+
> Step-oriented resumable batch execution for Node.js and browsers using SQLite or PostgreSQL.
|
|
4
4
|
|
|
5
5
|
## Overview
|
|
6
6
|
|
|
7
|
-
Durably is a minimal workflow engine that persists step results to SQLite. If a job is interrupted (server restart, browser tab close, crash), it automatically resumes from the last successful step.
|
|
7
|
+
Durably is a minimal workflow engine that persists step results to SQLite or PostgreSQL. If a job is interrupted (server restart, browser tab close, crash), it automatically resumes from the last successful step. Supports libSQL/Turso (single-server or serverless), PostgreSQL (recommended for multi-worker), and SQLocal (browser/OPFS).
|
|
8
8
|
|
|
9
9
|
## Installation
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
|
-
# Node.js with
|
|
12
|
+
# Node.js with libSQL (recommended for single-server / Turso)
|
|
13
13
|
pnpm add @coji/durably kysely zod @libsql/client @libsql/kysely-libsql
|
|
14
14
|
|
|
15
|
-
#
|
|
15
|
+
# Node.js with better-sqlite3 (lightweight local alternative)
|
|
16
|
+
pnpm add @coji/durably kysely zod better-sqlite3
|
|
17
|
+
|
|
18
|
+
# Node.js with PostgreSQL (recommended for multi-worker)
|
|
19
|
+
pnpm add @coji/durably kysely zod pg
|
|
20
|
+
|
|
21
|
+
# Browser with SQLocal (OPFS-backed)
|
|
16
22
|
pnpm add @coji/durably kysely zod sqlocal
|
|
17
23
|
```
|
|
18
24
|
|
|
@@ -26,13 +32,36 @@ import { LibsqlDialect } from '@libsql/kysely-libsql'
|
|
|
26
32
|
import { createClient } from '@libsql/client'
|
|
27
33
|
import { z } from 'zod'
|
|
28
34
|
|
|
35
|
+
// --- libSQL local (single-server) ---
|
|
29
36
|
const client = createClient({ url: 'file:local.db' })
|
|
30
37
|
const dialect = new LibsqlDialect({ client })
|
|
31
38
|
|
|
39
|
+
// --- Turso remote (serverless/edge) ---
|
|
40
|
+
// const client = createClient({
|
|
41
|
+
// url: process.env.TURSO_DATABASE_URL!,
|
|
42
|
+
// authToken: process.env.TURSO_AUTH_TOKEN!,
|
|
43
|
+
// })
|
|
44
|
+
// const dialect = new LibsqlDialect({ client })
|
|
45
|
+
|
|
46
|
+
// --- better-sqlite3 (lightweight local) ---
|
|
47
|
+
// import Database from 'better-sqlite3'
|
|
48
|
+
// import { SqliteDialect } from 'kysely'
|
|
49
|
+
// const dialect = new SqliteDialect({
|
|
50
|
+
// database: new Database('local.db'),
|
|
51
|
+
// })
|
|
52
|
+
|
|
53
|
+
// --- PostgreSQL (multi-worker) ---
|
|
54
|
+
// import pg from 'pg'
|
|
55
|
+
// import { PostgresDialect } from 'kysely'
|
|
56
|
+
// const dialect = new PostgresDialect({
|
|
57
|
+
// pool: new pg.Pool({ connectionString: process.env.DATABASE_URL }),
|
|
58
|
+
// })
|
|
59
|
+
|
|
32
60
|
// Option 1: With jobs (1-step initialization, returns typed instance)
|
|
33
61
|
const durably = createDurably({
|
|
34
62
|
dialect,
|
|
35
|
-
pollingIntervalMs: 1000, //
|
|
63
|
+
pollingIntervalMs: 1000, // Delay before polling again when idle (ms)
|
|
64
|
+
maxConcurrentRuns: 1, // Concurrent runs processed by the worker (default: 1)
|
|
36
65
|
leaseRenewIntervalMs: 5000, // Lease renewal interval (ms)
|
|
37
66
|
leaseMs: 30000, // Lease duration (ms); expired leases are reclaimed
|
|
38
67
|
preserveSteps: false, // Set to true to keep step output data after terminal state (default: false = cleanup)
|
|
@@ -95,6 +124,7 @@ await durably.init()
|
|
|
95
124
|
// Basic trigger (fire and forget)
|
|
96
125
|
const run = await syncUsers.trigger({ orgId: 'org_123' })
|
|
97
126
|
console.log(run.id, run.status) // "pending"
|
|
127
|
+
console.log(run.disposition) // "created"
|
|
98
128
|
|
|
99
129
|
// Wait for completion
|
|
100
130
|
const result = await syncUsers.triggerAndWait(
|
|
@@ -102,15 +132,27 @@ const result = await syncUsers.triggerAndWait(
|
|
|
102
132
|
{ timeout: 5000 },
|
|
103
133
|
)
|
|
104
134
|
console.log(result.output.syncedCount)
|
|
135
|
+
console.log(result.disposition) // "created"
|
|
105
136
|
|
|
106
137
|
// With idempotency key (prevents duplicate jobs)
|
|
107
|
-
await syncUsers.trigger(
|
|
138
|
+
const idempotentRun = await syncUsers.trigger(
|
|
108
139
|
{ orgId: 'org_123' },
|
|
109
140
|
{ idempotencyKey: 'webhook-event-456' },
|
|
110
141
|
)
|
|
142
|
+
console.log(idempotentRun.disposition) // "created" or "idempotent"
|
|
111
143
|
|
|
112
|
-
// With concurrency key (serializes execution)
|
|
144
|
+
// With concurrency key (serializes execution, max 1 pending per key)
|
|
113
145
|
await syncUsers.trigger({ orgId: 'org_123' }, { concurrencyKey: 'org_123' })
|
|
146
|
+
// Second trigger with same key throws ConflictError if a pending run exists
|
|
147
|
+
|
|
148
|
+
// With coalesce (skip duplicate pending runs gracefully)
|
|
149
|
+
const coalesced = await syncUsers.trigger(
|
|
150
|
+
{ orgId: 'org_123' },
|
|
151
|
+
{ concurrencyKey: 'org_123', coalesce: 'skip' },
|
|
152
|
+
)
|
|
153
|
+
if (coalesced.disposition === 'coalesced') {
|
|
154
|
+
console.log('Reused existing pending run:', coalesced.id)
|
|
155
|
+
}
|
|
114
156
|
|
|
115
157
|
// With labels (for filtering)
|
|
116
158
|
await syncUsers.trigger({ orgId: 'org_123' }, { labels: { source: 'browser' } })
|
|
@@ -156,6 +198,31 @@ step.log.error('Failed to connect', { error: err.message })
|
|
|
156
198
|
|
|
157
199
|
## Run Management
|
|
158
200
|
|
|
201
|
+
### Wait for Existing Run
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
// Wait for an existing run to complete (no new run created)
|
|
205
|
+
// Useful when Job A triggers Job B and returns B's run ID
|
|
206
|
+
const completedRun = await durably.waitForRun(runId)
|
|
207
|
+
console.log(completedRun.output) // available when completed
|
|
208
|
+
|
|
209
|
+
// With timeout and callbacks
|
|
210
|
+
const run = await durably.waitForRun(runId, {
|
|
211
|
+
timeout: 10000,
|
|
212
|
+
onProgress: (p) => console.log(`${p.current}/${p.total}`),
|
|
213
|
+
onLog: (l) => console.log(l.message),
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
// Same-process listeners settle waits immediately; if another runtime completes the run
|
|
217
|
+
// against the same storage, the wait falls back to storage polling. Optional
|
|
218
|
+
// `pollingIntervalMs` overrides the instance `createDurably({ pollingIntervalMs })` for this call only.
|
|
219
|
+
await durably.waitForRun(runId, { pollingIntervalMs: 2000 })
|
|
220
|
+
|
|
221
|
+
// Throws NotFoundError if run doesn't exist
|
|
222
|
+
// Throws CancelledError if run is cancelled
|
|
223
|
+
// Throws Error if run fails
|
|
224
|
+
```
|
|
225
|
+
|
|
159
226
|
### Get Run Status
|
|
160
227
|
|
|
161
228
|
```ts
|
|
@@ -182,6 +249,9 @@ const typedRun = await durably.getRun<MyRun>(runId)
|
|
|
182
249
|
// Get failed runs
|
|
183
250
|
const failedRuns = await durably.getRuns({ status: 'failed' })
|
|
184
251
|
|
|
252
|
+
// Get active runs (multiple statuses)
|
|
253
|
+
const activeRuns = await durably.getRuns({ status: ['pending', 'leased'] })
|
|
254
|
+
|
|
185
255
|
// Filter by job name with pagination
|
|
186
256
|
const runs = await durably.getRuns({
|
|
187
257
|
jobName: 'sync-users', // also accepts string[] for multiple jobs
|
|
@@ -247,11 +317,15 @@ For automatic cleanup, use the `retainRuns` option (see Core Concepts). Cleanup
|
|
|
247
317
|
|
|
248
318
|
## Events
|
|
249
319
|
|
|
250
|
-
Subscribe to job execution events
|
|
320
|
+
Subscribe to job execution events. **Listeners run synchronously** in the worker's hot path — keep them fast and non-blocking. Use fire-and-forget (`void asyncFn()`) for expensive work.
|
|
251
321
|
|
|
252
322
|
```ts
|
|
253
323
|
// Run lifecycle events
|
|
324
|
+
// Note: run:trigger is NOT emitted on idempotent hits (disposition: 'idempotent')
|
|
254
325
|
durably.on('run:trigger', (e) => console.log('Triggered:', e.runId))
|
|
326
|
+
durably.on('run:coalesced', (e) =>
|
|
327
|
+
console.log('Coalesced:', e.runId, 'skipped input:', e.skippedInput),
|
|
328
|
+
)
|
|
255
329
|
durably.on('run:leased', (e) => console.log('Leased:', e.runId))
|
|
256
330
|
durably.on('run:complete', (e) => console.log('Done:', e.output))
|
|
257
331
|
durably.on('run:fail', (e) => console.error('Failed:', e.error))
|
|
@@ -271,6 +345,25 @@ durably.on('step:cancel', (e) => console.log('Step cancelled:', e.stepName))
|
|
|
271
345
|
durably.on('log:write', (e) => console.log(`[${e.level}]`, e.message))
|
|
272
346
|
```
|
|
273
347
|
|
|
348
|
+
### Core event categories
|
|
349
|
+
|
|
350
|
+
The `DurablyEvent` union is grouped for callers who want lifecycle facts vs operational detail:
|
|
351
|
+
|
|
352
|
+
- **Domain** (`DomainEvent` / `DomainEventType`): `run:trigger`, `run:coalesced`, `run:complete`, `run:fail`, `run:cancel`, `run:delete`
|
|
353
|
+
- **Operational** (`OperationalEvent` / `OperationalEventType`): `run:leased`, `run:lease-renewed`, `run:progress`, `step:*`, `log:write`, `worker:error`
|
|
354
|
+
|
|
355
|
+
Use `isDomainEvent(event)` (checks `event.type` only) as a type guard.
|
|
356
|
+
|
|
357
|
+
```ts
|
|
358
|
+
import { isDomainEvent, type DurablyEvent } from '@coji/durably'
|
|
359
|
+
|
|
360
|
+
function handleEvent(event: DurablyEvent) {
|
|
361
|
+
if (isDomainEvent(event)) {
|
|
362
|
+
console.log('State change:', event.type, event.runId)
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
```
|
|
366
|
+
|
|
274
367
|
## Advanced APIs
|
|
275
368
|
|
|
276
369
|
### getJob
|
|
@@ -385,13 +478,13 @@ GET /runs?label.organizationId=org_123
|
|
|
385
478
|
GET /runs/subscribe?label.organizationId=org_123&label.env=prod
|
|
386
479
|
```
|
|
387
480
|
|
|
388
|
-
**Response Shape:** The `/runs` and `/run` endpoints return `ClientRun` objects (internal fields like `leaseOwner`, `leaseExpiresAt`, `idempotencyKey`, `concurrencyKey`, `updatedAt` are stripped). Use `toClientRun()` to apply the same projection in custom code:
|
|
481
|
+
**Response Shape:** The `/runs` and `/run` endpoints return `ClientRun` objects (internal fields like `leaseOwner`, `leaseExpiresAt`, `idempotencyKey`, `concurrencyKey`, `leaseGeneration`, `updatedAt` are stripped). Each response includes derived `isTerminal` and `isActive` booleans from `status` (terminal: completed, failed, or cancelled; active: pending or leased). Use `toClientRun()` to apply the same projection in custom code:
|
|
389
482
|
|
|
390
483
|
```ts
|
|
391
484
|
import { toClientRun } from '@coji/durably'
|
|
392
485
|
|
|
393
486
|
const run = await durably.getRun(runId)
|
|
394
|
-
const clientRun = toClientRun(run) // strips internal fields
|
|
487
|
+
const clientRun = toClientRun(run) // strips internal fields; adds isTerminal / isActive
|
|
395
488
|
```
|
|
396
489
|
|
|
397
490
|
**Handler Interface:**
|
|
@@ -449,11 +542,13 @@ interface TriggerRequest<TLabels> {
|
|
|
449
542
|
input: unknown
|
|
450
543
|
idempotencyKey?: string
|
|
451
544
|
concurrencyKey?: string
|
|
545
|
+
coalesce?: 'skip'
|
|
452
546
|
labels?: TLabels
|
|
453
547
|
}
|
|
454
548
|
|
|
455
549
|
interface TriggerResponse {
|
|
456
550
|
runId: string
|
|
551
|
+
disposition: Disposition
|
|
457
552
|
}
|
|
458
553
|
```
|
|
459
554
|
|
|
@@ -467,6 +562,10 @@ import { withLogPersistence } from '@coji/durably'
|
|
|
467
562
|
durably.use(withLogPersistence())
|
|
468
563
|
```
|
|
469
564
|
|
|
565
|
+
## SQLite WAL Maintenance
|
|
566
|
+
|
|
567
|
+
For local SQLite backends using WAL mode, Durably automatically runs periodic WAL checkpoints (`PRAGMA wal_checkpoint(TRUNCATE)`) during idle maintenance to prevent unbounded WAL file growth. This is probed at `migrate()` time and only enabled when the backend supports it — automatically skipped for Turso (remote libSQL), PostgreSQL, and browser (OPFS) backends.
|
|
568
|
+
|
|
470
569
|
## Browser Usage
|
|
471
570
|
|
|
472
571
|
```ts
|
|
@@ -558,6 +657,8 @@ interface Run<TLabels extends Record<string, string> = Record<string, string>> {
|
|
|
558
657
|
updatedAt: string
|
|
559
658
|
}
|
|
560
659
|
|
|
660
|
+
type Disposition = 'created' | 'idempotent' | 'coalesced'
|
|
661
|
+
|
|
561
662
|
interface TypedRun<
|
|
562
663
|
TOutput,
|
|
563
664
|
TLabels extends Record<string, string> = Record<string, string>,
|
|
@@ -565,6 +666,10 @@ interface TypedRun<
|
|
|
565
666
|
output: TOutput | null
|
|
566
667
|
}
|
|
567
668
|
|
|
669
|
+
type TriggerResult<TOutput, TLabels> = TypedRun<TOutput, TLabels> & {
|
|
670
|
+
disposition: Disposition
|
|
671
|
+
}
|
|
672
|
+
|
|
568
673
|
interface JobHandle<
|
|
569
674
|
TName extends string,
|
|
570
675
|
TInput,
|
|
@@ -575,14 +680,14 @@ interface JobHandle<
|
|
|
575
680
|
trigger(
|
|
576
681
|
input: TInput,
|
|
577
682
|
options?: TriggerOptions<TLabels>,
|
|
578
|
-
): Promise<
|
|
683
|
+
): Promise<TriggerResult<TOutput, TLabels>>
|
|
579
684
|
triggerAndWait(
|
|
580
685
|
input: TInput,
|
|
581
686
|
options?: TriggerAndWaitOptions<TLabels>,
|
|
582
|
-
): Promise<
|
|
687
|
+
): Promise<TriggerAndWaitResult<TOutput>>
|
|
583
688
|
batchTrigger(
|
|
584
689
|
inputs: BatchTriggerInput<TInput, TLabels>[],
|
|
585
|
-
): Promise<
|
|
690
|
+
): Promise<TriggerResult<TOutput, TLabels>[]>
|
|
586
691
|
getRun(id: string): Promise<TypedRun<TOutput, TLabels> | null>
|
|
587
692
|
getRuns(
|
|
588
693
|
filter?: Omit<RunFilter<TLabels>, 'jobName'>,
|
|
@@ -594,17 +699,27 @@ interface TriggerOptions<
|
|
|
594
699
|
> {
|
|
595
700
|
idempotencyKey?: string
|
|
596
701
|
concurrencyKey?: string
|
|
702
|
+
coalesce?: 'skip'
|
|
597
703
|
labels?: TLabels
|
|
598
704
|
}
|
|
599
705
|
|
|
600
|
-
interface
|
|
601
|
-
|
|
602
|
-
|
|
706
|
+
interface TriggerAndWaitResult<TOutput> {
|
|
707
|
+
id: string
|
|
708
|
+
output: TOutput
|
|
709
|
+
disposition: Disposition
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
interface WaitForRunOptions {
|
|
603
713
|
timeout?: number
|
|
604
714
|
onProgress?: (progress: ProgressData) => void | Promise<void>
|
|
605
715
|
onLog?: (log: LogData) => void | Promise<void>
|
|
606
716
|
}
|
|
607
717
|
|
|
718
|
+
interface TriggerAndWaitOptions<
|
|
719
|
+
TLabels extends Record<string, string> = Record<string, string>,
|
|
720
|
+
>
|
|
721
|
+
extends TriggerOptions<TLabels>, WaitForRunOptions {}
|
|
722
|
+
|
|
608
723
|
interface ProgressData {
|
|
609
724
|
current: number
|
|
610
725
|
total?: number
|
|
@@ -621,7 +736,7 @@ interface LogData {
|
|
|
621
736
|
interface RunFilter<
|
|
622
737
|
TLabels extends Record<string, string> = Record<string, string>,
|
|
623
738
|
> {
|
|
624
|
-
status?:
|
|
739
|
+
status?: RunStatus | RunStatus[]
|
|
625
740
|
jobName?: string | string[]
|
|
626
741
|
labels?: Partial<TLabels>
|
|
627
742
|
limit?: number
|
|
@@ -629,6 +744,23 @@ interface RunFilter<
|
|
|
629
744
|
}
|
|
630
745
|
```
|
|
631
746
|
|
|
747
|
+
## Error Classes
|
|
748
|
+
|
|
749
|
+
Durably exports typed error classes for programmatic error handling:
|
|
750
|
+
|
|
751
|
+
```ts
|
|
752
|
+
import {
|
|
753
|
+
DurablyError, // Base class with statusCode (extends Error)
|
|
754
|
+
NotFoundError, // 404 — resource not found
|
|
755
|
+
ValidationError, // 400 — invalid input or request
|
|
756
|
+
ConflictError, // 409 — operation conflicts with current state
|
|
757
|
+
CancelledError, // Run was cancelled during execution
|
|
758
|
+
LeaseLostError, // Worker lost lease ownership
|
|
759
|
+
} from '@coji/durably'
|
|
760
|
+
```
|
|
761
|
+
|
|
762
|
+
`DurablyError` subclasses (`NotFoundError`, `ValidationError`, `ConflictError`) carry a `statusCode` property and are used by the HTTP handler to return appropriate responses.
|
|
763
|
+
|
|
632
764
|
## License
|
|
633
765
|
|
|
634
766
|
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coji/durably",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Step-oriented resumable batch execution for Node.js and browsers using SQLite",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -23,13 +23,11 @@
|
|
|
23
23
|
"scripts": {
|
|
24
24
|
"build": "tsup",
|
|
25
25
|
"test": "pnpm test:node && pnpm test:react && pnpm test:browser",
|
|
26
|
-
"test:node": "vitest run --config vitest.config.ts
|
|
27
|
-
"test:node:postgres": "vitest run --config vitest.config.ts postgres",
|
|
28
|
-
"test:node:all": "vitest run --config vitest.config.ts",
|
|
26
|
+
"test:node": "vitest run --config vitest.config.ts",
|
|
29
27
|
"test:react": "vitest run --config vitest.react.config.ts",
|
|
30
28
|
"test:browser": "vitest run --config vitest.browser.config.ts",
|
|
31
29
|
"typecheck": "tsc --noEmit",
|
|
32
|
-
"lint": "biome lint .",
|
|
30
|
+
"lint": "biome lint --error-on-warnings .",
|
|
33
31
|
"lint:fix": "biome lint --write .",
|
|
34
32
|
"format": "prettier --experimental-cli --check .",
|
|
35
33
|
"format:fix": "prettier --experimental-cli --write ."
|
|
@@ -66,6 +64,7 @@
|
|
|
66
64
|
"@biomejs/biome": "^2.4.7",
|
|
67
65
|
"@libsql/client": "^0.17.0",
|
|
68
66
|
"@libsql/kysely-libsql": "^0.4.1",
|
|
67
|
+
"@testcontainers/postgresql": "11.13.0",
|
|
69
68
|
"@testing-library/react": "^16.3.2",
|
|
70
69
|
"@types/better-sqlite3": "^7.6.13",
|
|
71
70
|
"@types/pg": "^8.15.6",
|
|
@@ -83,6 +82,7 @@
|
|
|
83
82
|
"react": "^19.2.4",
|
|
84
83
|
"react-dom": "^19.2.4",
|
|
85
84
|
"sqlocal": "^0.17.0",
|
|
85
|
+
"testcontainers": "11.13.0",
|
|
86
86
|
"tsup": "^8.5.1",
|
|
87
87
|
"typescript": "^5.9.3",
|
|
88
88
|
"vitest": "^4.1.0",
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/plugins/log-persistence.ts"],"sourcesContent":["import type { DurablyPlugin } from '../durably'\n\n/**\n * Plugin that persists log events to the database\n */\nexport function withLogPersistence(): DurablyPlugin {\n return {\n name: 'log-persistence',\n install(durably) {\n durably.on('log:write', async (event) => {\n await durably.storage.createLog({\n runId: event.runId,\n stepName: event.stepName,\n level: event.level,\n message: event.message,\n data: event.data,\n })\n })\n },\n }\n}\n"],"mappings":";AAKO,SAAS,qBAAoC;AAClD,SAAO;AAAA,IACL,MAAM;AAAA,IACN,QAAQ,SAAS;AACf,cAAQ,GAAG,aAAa,OAAO,UAAU;AACvC,cAAM,QAAQ,QAAQ,UAAU;AAAA,UAC9B,OAAO,MAAM;AAAA,UACb,UAAU,MAAM;AAAA,UAChB,OAAO,MAAM;AAAA,UACb,SAAS,MAAM;AAAA,UACf,MAAM,MAAM;AAAA,QACd,CAAC;AAAA,MACH,CAAC;AAAA,IACH;AAAA,EACF;AACF;","names":[]}
|