@boringnode/queue 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +103 -20
  2. package/build/chunk-6IO4P6RB.js +145 -0
  3. package/build/chunk-6IO4P6RB.js.map +1 -0
  4. package/build/{chunk-VHN3XZDC.js → chunk-AHUVTAI7.js} +278 -29
  5. package/build/chunk-AHUVTAI7.js.map +1 -0
  6. package/build/chunk-S37X3CBO.js +500 -0
  7. package/build/chunk-S37X3CBO.js.map +1 -0
  8. package/build/index.d.ts +34 -8
  9. package/build/index.js +187 -31
  10. package/build/index.js.map +1 -1
  11. package/build/{job-DImdhRFO.d.ts → job-C4oyCVxR.d.ts} +275 -15
  12. package/build/src/contracts/adapter.d.ts +1 -1
  13. package/build/src/drivers/fake_adapter.d.ts +12 -6
  14. package/build/src/drivers/fake_adapter.js +1 -1
  15. package/build/src/drivers/knex_adapter.d.ts +6 -5
  16. package/build/src/drivers/knex_adapter.js +112 -0
  17. package/build/src/drivers/knex_adapter.js.map +1 -1
  18. package/build/src/drivers/redis_adapter.d.ts +6 -5
  19. package/build/src/drivers/redis_adapter.js +166 -402
  20. package/build/src/drivers/redis_adapter.js.map +1 -1
  21. package/build/src/drivers/redis_job_storage.d.ts +17 -0
  22. package/build/src/drivers/redis_job_storage.js +14 -0
  23. package/build/src/drivers/redis_job_storage.js.map +1 -0
  24. package/build/src/drivers/redis_scripts.d.ts +87 -0
  25. package/build/src/drivers/redis_scripts.js +29 -0
  26. package/build/src/drivers/redis_scripts.js.map +1 -0
  27. package/build/src/drivers/sync_adapter.d.ts +2 -1
  28. package/build/src/drivers/sync_adapter.js +7 -1
  29. package/build/src/drivers/sync_adapter.js.map +1 -1
  30. package/build/src/otel.d.ts +2 -2
  31. package/build/src/otel.js +3 -0
  32. package/build/src/otel.js.map +1 -1
  33. package/build/src/types/index.d.ts +1 -1
  34. package/build/src/types/main.d.ts +1 -1
  35. package/build/src/types/tracing_channels.d.ts +7 -1
  36. package/package.json +18 -19
  37. package/build/chunk-VHN3XZDC.js.map +0 -1
package/README.md CHANGED
@@ -26,6 +26,7 @@ npm install @boringnode/queue
26
26
  - **Priority Queues**: Process high-priority jobs first
27
27
  - **Bulk Dispatch**: Efficiently dispatch thousands of jobs at once
28
28
  - **Job Grouping**: Organize related jobs for monitoring
29
+ - **Job Deduplication**: Prevent duplicate jobs with custom IDs
29
30
  - **Retry with Backoff**: Exponential, linear, or fixed backoff strategies
30
31
  - **Job Timeout**: Fail or retry jobs that exceed a time limit
31
32
  - **Job History**: Retain completed/failed jobs for debugging
@@ -131,6 +132,85 @@ await SendEmailJob.dispatchMany(recipients).group('newsletter-jan-2025')
131
132
 
132
133
  The `groupId` is stored with job data and accessible via `job.data.groupId`.
133
134
 
135
+ ## Job Deduplication
136
+
137
+ Prevent the same job from being pushed multiple times. Four modes, all via `.dedup()`:
138
+
139
+ ### Simple (skip while job exists)
140
+
141
+ ```typescript
142
+ // First dispatch - job is created
143
+ await SendInvoiceJob.dispatch({ orderId: 123 }).dedup({ id: 'order-123' }).run()
144
+
145
+ // Second dispatch with same dedup ID - silently skipped
146
+ await SendInvoiceJob.dispatch({ orderId: 123 }).dedup({ id: 'order-123' }).run()
147
+ ```
148
+
149
+ ### Throttle (skip within TTL window)
150
+
151
+ ```typescript
152
+ // Within 5s, duplicates are skipped. After 5s, a new job is created.
153
+ await SendEmailJob.dispatch({ to: 'user@example.com' })
154
+ .dedup({ id: 'welcome-123', ttl: '5s' })
155
+ .run()
156
+ ```
157
+
158
+ ### Extend (reset TTL on duplicate)
159
+
160
+ ```typescript
161
+ // Each duplicate push resets the TTL timer.
162
+ await RateLimitJob.dispatch({ userId: 42 }).dedup({ id: 'rate-42', ttl: '1m', extend: true }).run()
163
+ ```
164
+
165
+ ### Debounce (replace payload + reset TTL)
166
+
167
+ ```typescript
168
+ // Within the 2s window, the latest payload overwrites the previous pending job.
169
+ await SaveDraftJob.dispatch({ content: 'latest draft' })
170
+ .dedup({ id: 'draft-42', ttl: '2s', replace: true, extend: true })
171
+ .run()
172
+ ```
173
+
174
+ ### Inspecting the outcome
175
+
176
+ `DispatchResult` tells you what happened:
177
+
178
+ ```typescript
179
+ const { jobId, deduped } = await SaveDraftJob.dispatch({ content: '...' })
180
+ .dedup({ id: 'draft-42', ttl: '2s', replace: true })
181
+ .run()
182
+
183
+ // deduped: 'added' | 'skipped' | 'replaced' | 'extended'
184
+ // jobId: the UUID of the job (the existing one when deduped)
185
+ ```
186
+
187
+ ### How it works
188
+
189
+ - The dedup ID is automatically prefixed with the job name (`SendInvoiceJob::order-123`), so different job types can reuse the same key.
190
+ - The user-supplied `id` must be ≤ 400 characters, and the combined `<jobName>::<id>` key must be ≤ 510 characters (constrained by the Knex storage column). Both limits are validated at `.dedup()` time.
191
+ - `ttl` accepts a Duration (`'5s'`, `'1m'`) or milliseconds, and must be **positive** when provided. Use `0` or omit `ttl` if you want no expiry — `ttl: 0` is rejected to avoid an ambiguous "expired immediately vs no-expiry" interpretation across engines.
192
+ - `extend` and `replace` **require** `ttl` — calling them without `ttl` throws.
193
+ - `replace` only applies to jobs in `pending` or `delayed` state. Jobs that are active (executing) or retained in history (`completed`/`failed` with retention) are left alone; the dispatch returns `{ deduped: 'skipped' }`.
194
+ - `replace` swaps the **payload only** — priority, queue, delay, groupId, and stored dedup options of the existing job are retained. To change those, use a different dedup id or wait for the TTL to expire.
195
+ - `extend` resets the TTL clock but never changes the window length. The window length is fixed to the `ttl` from the first dispatch that created the dedup slot. Later dispatches that pass a different `ttl` only reset the clock; their `ttl` value is ignored. To resize the window, let the slot expire and start over with a new dispatch.
196
+ - `extend` works in **all states** — even when the existing job is `active` (executing) or retained in history. Unlike `replace` (which is no-op on non-replaceable states), `extend` always refreshes the dedup TTL window. Use this when you want the dedup slot to keep blocking new dispatches for the lifetime of a long-running job.
197
+ - `extend` requires the **first** dispatch to have set a `ttl`. If the slot was created without a `ttl`, later `extend` dispatches have no window to refresh and return `{ deduped: 'skipped' }` instead of `'extended'`.
198
+ - `retryJob` does not touch the dedup entry — a retried job continues to occupy the dedup slot. TTL runs on wall-clock time, so long-running retries may outlive the TTL window. Use a generous TTL or no TTL if retries must stay deduped.
199
+ - Atomic and race-free:
200
+ - **Redis**: a single Lua script per dispatch performs the dedup-key lookup, state check (pending/delayed ZSCORE), payload swap, and TTL refresh atomically.
201
+ - **Knex**: transactional `SELECT ... FOR UPDATE` + insert/update inside a transaction. A nested savepoint catches unique-constraint violations under concurrent inserts and returns `{ deduped: 'skipped' }` pointing at the winner.
202
+ - **SyncAdapter**: executes inline, no dedup support.
203
+
204
+ ### Caveats
205
+
206
+ - Without `.dedup()`, jobs use auto-generated UUIDs and are never deduplicated.
207
+ - The **Sync adapter** ignores `.dedup()` entirely — every dispatch executes inline and `deduped` is always `undefined` on the result. Use Redis or Knex if you need real deduplication.
208
+ - `.dedup()` is only available on single dispatch. `dispatchMany` / `pushManyOn` reject jobs with a `dedup` field.
209
+ - Scheduled jobs (`.schedule()`) do not support dedup — each cron/interval fire is an independent dispatch.
210
+ - With no `ttl`, dedup persists until the job is removed (completed/failed without retention). When retention keeps the record, re-dispatch stays blocked until the record is pruned.
211
+ - With `ttl`, dedup expires after the window — a new job (new UUID) is created. The old job still runs.
212
+ - Knex MySQL concurrent race: MySQL does not support partial unique indexes, so two `pushOn` calls with the same dedup id firing at the exact same instant can both succeed. Serialize at the app layer if strict guarantees are required, or use Postgres / SQLite / Redis (all of which serialize correctly via the partial unique index or Lua atomicity).
213
+
134
214
  ## Job History & Retention
135
215
 
136
216
  Keep completed and failed jobs for debugging:
@@ -270,20 +350,22 @@ await QueueManager.init({
270
350
  locations: ['./app/jobs/**/*.ts'],
271
351
  })
272
352
 
273
- const adapter = QueueManager.fake()
353
+ // The `using` keyword automatically restores the real adapters when
354
+ // the variable goes out of scope (at the end of the test function).
355
+ using fake = QueueManager.fake()
274
356
 
275
357
  await SendEmailJob.dispatch({ to: 'user@example.com' })
276
358
 
277
- adapter.assertPushed(SendEmailJob)
278
- adapter.assertPushed(SendEmailJob, {
359
+ fake.assertPushed(SendEmailJob)
360
+ fake.assertPushed(SendEmailJob, {
279
361
  queue: 'default',
280
362
  payload: (payload) => payload.to === 'user@example.com',
281
363
  })
282
- adapter.assertPushedCount(1)
283
-
284
- QueueManager.restore()
364
+ fake.assertPushedCount(1)
285
365
  ```
286
366
 
367
+ You can also call `QueueManager.restore()` manually if you need more control over when the real adapters are restored.
368
+
287
369
  ### Sync (for testing)
288
370
 
289
371
  ```typescript
@@ -534,7 +616,7 @@ import * as boringqueue from '@boringnode/queue'
534
616
 
535
617
  const instrumentation = new QueueInstrumentation({
536
618
  messagingSystem: 'boringqueue', // default
537
- executionSpanLinkMode: 'link', // or 'parent'
619
+ executionSpanLinkMode: 'link', // or 'parent'
538
620
  })
539
621
 
540
622
  instrumentation.enable()
@@ -547,19 +629,20 @@ The instrumentation patches `QueueManager.init()` to automatically inject its wr
547
629
 
548
630
  The instrumentation uses standard [OTel messaging semantic conventions](https://opentelemetry.io/docs/specs/semconv/messaging/messaging-spans/) where they map cleanly, plus a few queue-specific custom attributes.
549
631
 
550
- | Attribute | Kind | Description |
551
- | ------------------------------- | ------- | ------------------------------------------ |
552
- | `messaging.system` | Semconv | `'boringqueue'` (configurable) |
553
- | `messaging.operation.name` | Semconv | `'publish'` or `'process'` |
554
- | `messaging.destination.name` | Semconv | Queue name |
555
- | `messaging.message.id` | Semconv | Job ID for single-message spans |
556
- | `messaging.batch.message_count` | Semconv | Number of jobs in a batch dispatch |
557
- | `messaging.message.retry.count` | Custom | Retry count (0-based) for a job attempt |
558
- | `messaging.job.name` | Custom | Job class name (e.g. `SendEmailJob`) |
559
- | `messaging.job.status` | Custom | `'completed'`, `'failed'`, or `'retrying'` |
560
- | `messaging.job.group_id` | Custom | Queue-specific group identifier |
561
- | `messaging.job.priority` | Custom | Queue-specific job priority |
562
- | `messaging.job.delay_ms` | Custom | Delay before the job becomes available |
632
+ | Attribute | Kind | Description |
633
+ | ------------------------------- | ------- | --------------------------------------------- |
634
+ | `messaging.system` | Semconv | `'boringqueue'` (configurable) |
635
+ | `messaging.operation.name` | Semconv | `'publish'` or `'process'` |
636
+ | `messaging.destination.name` | Semconv | Queue name |
637
+ | `messaging.message.id` | Semconv | Job ID for single-message spans |
638
+ | `messaging.batch.message_count` | Semconv | Number of jobs in a batch dispatch |
639
+ | `messaging.message.retry.count` | Custom | Retry count (0-based) for a job attempt |
640
+ | `messaging.job.name` | Custom | Job class name (e.g. `SendEmailJob`) |
641
+ | `messaging.job.status` | Custom | `'completed'`, `'failed'`, or `'retrying'` |
642
+ | `messaging.job.group_id` | Custom | Queue-specific group identifier |
643
+ | `messaging.job.priority` | Custom | Queue-specific job priority |
644
+ | `messaging.job.delay_ms` | Custom | Delay before the job becomes available |
645
+ | `messaging.job.queue_time_ms` | Custom | Time spent waiting in queue before processing |
563
646
 
564
647
  ### Trace Context Propagation
565
648
 
@@ -0,0 +1,145 @@
1
+ // src/drivers/redis_job_storage.ts
2
+ function hydrateRedisJob(data, overlay) {
3
+ const jobData = JSON.parse(data);
4
+ const parsedOverlay = overlay ? JSON.parse(overlay) : void 0;
5
+ if (parsedOverlay?.payloadUndefined) {
6
+ jobData.payload = void 0;
7
+ } else if (parsedOverlay?.payload !== void 0) {
8
+ jobData.payload = JSON.parse(parsedOverlay.payload);
9
+ }
10
+ return {
11
+ ...jobData,
12
+ ...parsedOverlay?.attempts !== void 0 ? { attempts: parsedOverlay.attempts } : {},
13
+ ...parsedOverlay?.stalledCount !== void 0 ? { stalledCount: parsedOverlay.stalledCount } : {}
14
+ };
15
+ }
16
+ function encodeRedisJobPayloadOverlay(payload) {
17
+ const encoded = JSON.stringify(payload);
18
+ return encoded === void 0 ? ["", "1"] : [encoded, "0"];
19
+ }
20
+ var REDIS_JOB_STORAGE_LUA = `
21
+ local function read_job_overlay(overlay_key, job_id)
22
+ local overlay_json = redis.call('HGET', overlay_key, job_id)
23
+ if overlay_json then
24
+ return cjson.decode(overlay_json)
25
+ end
26
+ return {}
27
+ end
28
+
29
+ local function write_job_overlay(overlay_key, job_id, overlay)
30
+ local has_overlay = false
31
+ for _ in pairs(overlay) do
32
+ has_overlay = true
33
+ break
34
+ end
35
+
36
+ if has_overlay then
37
+ redis.call('HSET', overlay_key, job_id, cjson.encode(overlay))
38
+ else
39
+ redis.call('HDEL', overlay_key, job_id)
40
+ end
41
+ end
42
+
43
+ local function store_job_data(data_key, overlay_key, job_id, job_data)
44
+ redis.call('HDEL', overlay_key, job_id)
45
+ redis.call('HSET', data_key, job_id, job_data)
46
+ end
47
+
48
+ local function delete_job_data(data_key, overlay_key, job_id)
49
+ redis.call('HDEL', data_key, job_id)
50
+ redis.call('HDEL', overlay_key, job_id)
51
+ end
52
+
53
+ local function delete_jobs_data(data_key, overlay_key, job_ids)
54
+ if #job_ids > 0 then
55
+ redis.call('HDEL', data_key, unpack(job_ids))
56
+ redis.call('HDEL', overlay_key, unpack(job_ids))
57
+ end
58
+ end
59
+
60
+ local function encode_job_result(job_data, overlay_key, job_id, result)
61
+ local encoded = result or {}
62
+ encoded.data = job_data
63
+
64
+ local overlay_json = redis.call('HGET', overlay_key, job_id)
65
+ if overlay_json then
66
+ encoded.overlay = overlay_json
67
+ end
68
+
69
+ return cjson.encode(encoded)
70
+ end
71
+
72
+ local function apply_payload_overlay(overlay, payload_data, payload_is_undefined)
73
+ if payload_is_undefined == 1 then
74
+ overlay.payload = nil
75
+ overlay.payloadUndefined = true
76
+ else
77
+ overlay.payload = payload_data
78
+ overlay.payloadUndefined = nil
79
+ end
80
+ end
81
+ `;
82
+ var REDIS_DEDUP_LUA = `
83
+ local function resolve_dedup_existing_job(
84
+ data_key,
85
+ state_key_a,
86
+ state_key_b,
87
+ overlay_key,
88
+ dedup_key,
89
+ extend,
90
+ replace,
91
+ payload_data,
92
+ payload_is_undefined
93
+ )
94
+ local existing = redis.call('GET', dedup_key)
95
+ if not existing then
96
+ return nil
97
+ end
98
+
99
+ local existing_data = redis.call('HGET', data_key, existing)
100
+ if not existing_data then
101
+ return nil
102
+ end
103
+
104
+ local in_state_a = redis.call('ZSCORE', state_key_a, existing)
105
+ local in_state_b = redis.call('ZSCORE', state_key_b, existing)
106
+ local replaceable = in_state_a or in_state_b
107
+ local ok, existing_job = pcall(cjson.decode, existing_data)
108
+ local original_ttl = nil
109
+
110
+ if ok and type(existing_job) == 'table' and existing_job.dedup then
111
+ original_ttl = tonumber(existing_job.dedup.ttl)
112
+ end
113
+
114
+ if replace == 1 and replaceable then
115
+ if ok and existing_job then
116
+ local overlay = read_job_overlay(overlay_key, existing)
117
+ apply_payload_overlay(overlay, payload_data, payload_is_undefined)
118
+ write_job_overlay(overlay_key, existing, overlay)
119
+
120
+ if extend == 1 and original_ttl and original_ttl > 0 then
121
+ redis.call('PEXPIRE', dedup_key, original_ttl)
122
+ end
123
+
124
+ return {'replaced', existing}
125
+ end
126
+
127
+ return {'skipped', existing}
128
+ end
129
+
130
+ if extend == 1 and original_ttl and original_ttl > 0 then
131
+ redis.call('PEXPIRE', dedup_key, original_ttl)
132
+ return {'extended', existing}
133
+ end
134
+
135
+ return {'skipped', existing}
136
+ end
137
+ `;
138
+
139
+ export {
140
+ hydrateRedisJob,
141
+ encodeRedisJobPayloadOverlay,
142
+ REDIS_JOB_STORAGE_LUA,
143
+ REDIS_DEDUP_LUA
144
+ };
145
+ //# sourceMappingURL=chunk-6IO4P6RB.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/drivers/redis_job_storage.ts"],"sourcesContent":["import type { JobData } from '../types/main.js'\n\n/**\n * Redis stores the original job JSON as an opaque payload in the data hash.\n * Lua scripts write only mutable Redis-side overrides in this overlay so\n * legacy jobs without overlays keep decoding from their original data.\n */\nexport type RedisJobOverlay = Partial<Pick<JobData, 'attempts' | 'stalledCount'>> & {\n payload?: string\n payloadUndefined?: boolean\n}\n\nexport function hydrateRedisJob(data: string, overlay?: string | null | false): JobData {\n const jobData = JSON.parse(data) as JobData\n const parsedOverlay = overlay ? (JSON.parse(overlay) as RedisJobOverlay) : undefined\n\n if (parsedOverlay?.payloadUndefined) {\n jobData.payload = undefined\n } else if (parsedOverlay?.payload !== undefined) {\n jobData.payload = JSON.parse(parsedOverlay.payload)\n }\n\n return {\n ...jobData,\n ...(parsedOverlay?.attempts !== undefined ? { attempts: parsedOverlay.attempts } : {}),\n ...(parsedOverlay?.stalledCount !== undefined\n ? { stalledCount: parsedOverlay.stalledCount }\n : {}),\n }\n}\n\nexport function encodeRedisJobPayloadOverlay(payload: JobData['payload']): [string, string] {\n const encoded = JSON.stringify(payload)\n\n return encoded === undefined ? ['', '1'] : [encoded, '0']\n}\n\nexport const REDIS_JOB_STORAGE_LUA = `\n local function read_job_overlay(overlay_key, job_id)\n local overlay_json = redis.call('HGET', overlay_key, job_id)\n if overlay_json then\n return cjson.decode(overlay_json)\n end\n return {}\n end\n\n local function write_job_overlay(overlay_key, job_id, overlay)\n local has_overlay = false\n for _ in pairs(overlay) do\n has_overlay = true\n break\n end\n\n if has_overlay then\n redis.call('HSET', overlay_key, job_id, cjson.encode(overlay))\n else\n redis.call('HDEL', overlay_key, job_id)\n end\n end\n\n local function store_job_data(data_key, overlay_key, job_id, job_data)\n redis.call('HDEL', overlay_key, job_id)\n redis.call('HSET', data_key, job_id, job_data)\n end\n\n local function delete_job_data(data_key, overlay_key, job_id)\n redis.call('HDEL', data_key, job_id)\n redis.call('HDEL', overlay_key, job_id)\n end\n\n local function delete_jobs_data(data_key, overlay_key, job_ids)\n if #job_ids > 0 then\n redis.call('HDEL', data_key, unpack(job_ids))\n redis.call('HDEL', overlay_key, unpack(job_ids))\n end\n end\n\n local function encode_job_result(job_data, overlay_key, job_id, result)\n local encoded = result or {}\n encoded.data = job_data\n\n local overlay_json = redis.call('HGET', overlay_key, job_id)\n if overlay_json then\n encoded.overlay = overlay_json\n end\n\n return cjson.encode(encoded)\n end\n\n local function apply_payload_overlay(overlay, payload_data, payload_is_undefined)\n if payload_is_undefined == 1 then\n overlay.payload = nil\n overlay.payloadUndefined = true\n else\n overlay.payload = payload_data\n overlay.payloadUndefined = nil\n end\n end\n`\n\nexport const REDIS_DEDUP_LUA = `\n local function resolve_dedup_existing_job(\n data_key,\n state_key_a,\n state_key_b,\n overlay_key,\n dedup_key,\n extend,\n replace,\n payload_data,\n payload_is_undefined\n )\n local existing = redis.call('GET', dedup_key)\n if not existing then\n return nil\n end\n\n local existing_data = redis.call('HGET', data_key, existing)\n if not existing_data then\n return nil\n end\n\n local in_state_a = redis.call('ZSCORE', state_key_a, existing)\n local in_state_b = redis.call('ZSCORE', state_key_b, existing)\n local replaceable = in_state_a or in_state_b\n local ok, existing_job = pcall(cjson.decode, existing_data)\n local original_ttl = nil\n\n if ok and type(existing_job) == 'table' and existing_job.dedup then\n original_ttl = tonumber(existing_job.dedup.ttl)\n end\n\n if replace == 1 and replaceable then\n if ok and existing_job then\n local overlay = read_job_overlay(overlay_key, existing)\n apply_payload_overlay(overlay, payload_data, payload_is_undefined)\n write_job_overlay(overlay_key, existing, overlay)\n\n if extend == 1 and original_ttl and original_ttl > 0 then\n redis.call('PEXPIRE', dedup_key, original_ttl)\n end\n\n return {'replaced', existing}\n end\n\n return {'skipped', existing}\n end\n\n if extend == 1 and original_ttl and original_ttl > 0 then\n redis.call('PEXPIRE', dedup_key, original_ttl)\n return {'extended', existing}\n end\n\n return {'skipped', existing}\n end\n`\n"],"mappings":";AAYO,SAAS,gBAAgB,MAAc,SAA0C;AACtF,QAAM,UAAU,KAAK,MAAM,IAAI;AAC/B,QAAM,gBAAgB,UAAW,KAAK,MAAM,OAAO,IAAwB;AAE3E,MAAI,eAAe,kBAAkB;AACnC,YAAQ,UAAU;AAAA,EACpB,WAAW,eAAe,YAAY,QAAW;AAC/C,YAAQ,UAAU,KAAK,MAAM,cAAc,OAAO;AAAA,EACpD;AAEA,SAAO;AAAA,IACL,GAAG;AAAA,IACH,GAAI,eAAe,aAAa,SAAY,EAAE,UAAU,cAAc,SAAS,IAAI,CAAC;AAAA,IACpF,GAAI,eAAe,iBAAiB,SAChC,EAAE,cAAc,cAAc,aAAa,IAC3C,CAAC;AAAA,EACP;AACF;AAEO,SAAS,6BAA6B,SAA+C;AAC1F,QAAM,UAAU,KAAK,UAAU,OAAO;AAEtC,SAAO,YAAY,SAAY,CAAC,IAAI,GAAG,IAAI,CAAC,SAAS,GAAG;AAC1D;AAEO,IAAM,wBAAwB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AA+D9B,IAAM,kBAAkB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;","names":[]}