alf: the atproto Latency Fabric alf.fly.dev/
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add recurring schedules feature

Introduces a schedules system that lets clients define recurring posts driven by a recurrence rule (daily, weekly, monthly). When a draft publishes, the scheduler automatically chains to the next occurrence.

Core changes:
- New `schedules` table (id, user_did, collection, record|content_url, recurrence_rule, status, fire_count, next_draft_uri, …)
- `DraftsTable` gains `schedule_id`, `trigger_key_hash`, `trigger_key_encrypted` columns
- 5 new XRPC endpoints: createSchedule, listSchedules, getSchedule, updateSchedule, deleteSchedule
- Scheduler: `handleScheduleChaining` — on publish, increments fire_count, computes next occurrence via @newpublic/recurrence, creates next draft
- Dynamic schedules: fetch `content_url?fireCount=N&scheduledAt=T` at publish time
- pause/resume: cancel pending draft; create new next draft + wake scheduler
- `@newpublic/recurrence` local package (luxon-based) — computeNextOccurrence + getOccurrenceRecord
- New lexicons for all 5 schedule endpoints + updated defs.json
- Full test coverage for storage, scheduler, server, schema
- Updated README and docs/api.md with schedule API reference and lifecycle diagram

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

+9059 -86
+55
README.md
··· 170 170 }); 171 171 ``` 172 172 173 + ### Webhook triggers 174 + 175 + Add `x-trigger: webhook` to any write call and ALF returns a one-time secret URL. POST to it later to publish the draft — no auth required, the URL is the credential. 176 + 177 + ```typescript 178 + const response = await fetch('https://alf.example.com/xrpc/com.atproto.repo.createRecord', { 179 + method: 'POST', 180 + headers: { 181 + 'Authorization': `Bearer ${accessToken}`, 182 + 'Content-Type': 'application/json', 183 + 'x-trigger': 'webhook', 184 + }, 185 + body: JSON.stringify({ repo: userDid, collection: 'app.bsky.feed.post', record: post }), 186 + }); 187 + 188 + const { uri, triggerUrl } = await response.json(); 189 + // triggerUrl = "https://alf.example.com/triggers/some-one-time-key" 190 + 191 + // Later, from anywhere, publish the draft: 192 + await fetch(triggerUrl, { method: 'POST' }); 193 + ``` 194 + 195 + Calling the trigger URL a second time returns a `409 TriggerAlreadyFired` error. 196 + 197 + ### Recurring schedules 198 + 199 + Create a schedule with a recurrence rule and ALF will automatically queue and publish one draft per occurrence. Static records reuse the same content every time; dynamic schedules fetch a `contentUrl` at publish time. 200 + 201 + ```typescript 202 + const response = await fetch('https://alf.example.com/xrpc/town.roundabout.scheduledPosts.createSchedule', { 203 + method: 'POST', 204 + headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, 205 + body: JSON.stringify({ 206 + collection: 'app.bsky.feed.post', 207 + timezone: 'America/New_York', 208 + recurrenceRule: { 209 + rule: { 210 + type: 'weekly', 211 + daysOfWeek: [1, 3, 5], // Mon/Wed/Fri 212 + time: { type: 'wall_time', hour: 9, minute: 0, timezone: 'America/New_York' }, 213 + }, 214 + }, 215 + record: { 216 + $type: 'app.bsky.feed.post', 217 + text: 'Good morning!', 218 + createdAt: new Date().toISOString(), 219 + }, 220 + }), 221 + }); 222 + 223 + const { schedule } = await response.json(); 224 + ``` 225 + 226 + Supported rule types: `once`, `daily`, `weekly`, `monthly_on_day`, `monthly_nth_weekday`, `monthly_last_business_day`, `yearly_on_month_day`, `yearly_nth_weekday`, `quarterly_last_weekday`. All rules support `startDate`, `endDate`, `count`, per-occurrence exceptions (`cancel`, `move`, `override_time`, `override_payload`), and DST-aware wall-clock scheduling via IANA timezone names. 227 + 173 228 ## API reference 174 229 175 230 See [docs/api.md](docs/api.md) for the full API reference.
+373 -39
demo/public/index.html
··· 173 173 } 174 174 175 175 input[type="text"], 176 + input[type="number"], 176 177 input[type="datetime-local"], 178 + select, 177 179 textarea { 178 180 width: 100%; 179 181 padding: 0.5rem 0.65rem; ··· 189 191 } 190 192 191 193 input[type="text"]:focus, 194 + input[type="number"]:focus, 192 195 input[type="datetime-local"]:focus, 196 + select:focus, 193 197 textarea:focus { border-color: var(--indigo); } 194 198 199 + input[type="text"]:disabled, 200 + input[type="datetime-local"]:disabled { background: var(--bg-page); color: var(--text-faint); } 201 + 195 202 textarea { resize: vertical; min-height: 80px; } 203 + 204 + select { appearance: auto; cursor: pointer; } 196 205 197 206 .image-upload-area { 198 207 border: 1px dashed var(--border); ··· 309 318 .banner-warn { background: #fffbeb; color: #92400e; border-color: #f59e0b; } 310 319 .banner-demo { background: #fefce8; color: #713f12; border-color: #ca8a04; font-size: 0.8rem; margin-bottom: 1.25rem; line-height: 1.5; } 311 320 312 - 313 321 .status-row { 314 322 display: flex; 315 323 align-items: center; ··· 334 342 .badge-draft { background: #f3f4f6; color: #4b5563; } 335 343 .badge-failed { background: #fee2e2; color: #991b1b; } 336 344 .badge-pending { background: #f3f4f6; color: #4b5563; } 345 + .badge-recurring { background: #fef3c7; color: #92400e; } 346 + 347 + /* ── Trigger toggle ──────────────────────────────────────────────── */ 348 + 349 + .trigger-toggle { 350 + display: flex; 351 + align-items: center; 352 + gap: 0.6rem; 353 + margin-bottom: 0.8rem; 354 + padding: 0.5rem 0.65rem; 355 + border: 1px solid var(--border); 356 + background: var(--bg-page); 357 + cursor: pointer; 358 + user-select: none; 359 + } 360 + .trigger-toggle:hover { border-color: #c7d2fe; background: var(--indigo-light); } 361 + .trigger-toggle input[type="checkbox"] { 362 + width: auto; 363 + margin: 0; 364 + accent-color: var(--indigo); 365 + cursor: pointer; 366 + flex-shrink: 0; 367 + } 368 + .trigger-toggle-text { 369 + font-size: 0.82rem; 370 + font-weight: 600; 371 + color: var(--text); 372 + } 373 + .trigger-toggle-desc { 374 + font-size: 0.72rem; 375 + color: var(--text-faint); 376 + margin-left: auto; 377 + } 378 + 379 + /* ── Trigger URL display in post cards ──────────────────────────── */ 380 + 381 + .trigger-url-box { 382 + background: #f0fdf4; 383 + border: 1px solid #bbf7d0; 384 + padding: 0.4rem 0.55rem; 385 + margin-bottom: 0.5rem; 386 + display: flex; 387 + align-items: flex-start; 388 + gap: 0.5rem; 389 + flex-wrap: wrap; 390 + } 391 + .trigger-url-label { 392 + font-size: 0.72rem; 393 + font-weight: 600; 394 + color: #166534; 395 + flex-shrink: 0; 396 + margin-top: 2px; 397 + } 398 + .trigger-url-value { 399 + font-family: monospace; 400 + font-size: 0.72rem; 401 + color: #166534; 402 + word-break: break-all; 403 + flex: 1; 404 + background: transparent; 405 + border: none; 406 + padding: 0; 407 + } 408 + 409 + /* ── Tab bar ─────────────────────────────────────────────────────── */ 410 + 411 + .tab-bar { 412 + display: flex; 413 + gap: 0.1rem; 414 + border-bottom: 2px solid var(--border); 415 + margin-bottom: 1.25rem; 416 + } 417 + .tab { 418 + padding: 0.45rem 1rem; 419 + font-size: 0.82rem; 420 + font-weight: 600; 421 + background: none; 422 + border: none; 423 + border-bottom: 2px solid transparent; 424 + margin-bottom: -2px; 425 + cursor: pointer; 426 + color: var(--text-muted); 427 + transition: color 0.15s, border-color 0.15s; 428 + border-radius: 4px 4px 0 0; 429 + } 430 + .tab:hover { color: var(--indigo); background: var(--indigo-light); } 431 + .tab.active { color: var(--indigo); border-bottom-color: var(--indigo); } 337 432 338 433 /* ── Post list ───────────────────────────────────────────────────── */ 339 434 ··· 370 465 } 371 466 .post-bsky-link:hover { text-decoration: underline; } 372 467 373 - .edit-fields { margin-bottom: 0.5rem; } 468 + /* ── Schedule items ──────────────────────────────────────────────── */ 469 + 470 + .schedule-item { 471 + border-top: 1px solid var(--border); 472 + padding: 0.85rem 0; 473 + } 474 + .schedule-item:last-child { border-bottom: 1px solid var(--border); } 475 + 476 + /* ── Day-of-week checkboxes ──────────────────────────────────────── */ 477 + 478 + .days-checkboxes { 479 + display: flex; 480 + flex-wrap: wrap; 481 + gap: 0.35rem; 482 + margin-bottom: 0.8rem; 483 + } 484 + .inline-check { 485 + display: flex; 486 + align-items: center; 487 + gap: 0.25rem; 488 + font-size: 0.8rem; 489 + font-weight: 500; 490 + color: var(--text-muted); 491 + cursor: pointer; 492 + } 493 + .inline-check input[type="checkbox"] { 494 + width: auto; 495 + margin: 0; 496 + accent-color: var(--indigo); 497 + } 498 + 499 + /* ── Time row for schedule form ──────────────────────────────────── */ 500 + 501 + .time-row { 502 + display: flex; 503 + gap: 0.5rem; 504 + } 505 + .time-row > div { flex: 1; } 506 + .time-row input[type="number"] { width: 100%; } 374 507 375 508 .hidden { display: none !important; } 376 509 ··· 386 519 background: var(--indigo-light); 387 520 } 388 521 389 - #posts-list .empty-state { 522 + #posts-list .empty-state, 523 + #schedules-list .empty-state { 390 524 color: var(--text-faint); 391 525 font-size: 0.85rem; 392 526 padding: 1.5rem 0; ··· 614 748 <span>Signed in as</span> 615 749 <span class="user-label" id="did-label-3"></span> 616 750 <span class="badge badge-published">authorized ✓</span> 617 - <button class="btn btn-danger" id="delete-account-btn" style="margin-left:auto;" title="Cancel all drafts and remove ALF's authorization to post on your behalf">Delete account data</button> 751 + <button class="btn btn-outline" id="reauth-btn" style="margin-left:auto;" title="Re-authorize ALF to post on your behalf (fixes token issues, your drafts are preserved)">Reconnect</button> 752 + <button class="btn btn-outline" id="signout-btn" title="Sign out and re-authorize (your drafts are preserved)">Sign out</button> 753 + <button class="btn btn-danger" id="delete-account-btn" title="Cancel all drafts and remove ALF's authorization to post on your behalf">Delete account data</button> 618 754 </div> 619 755 </div> 620 756 621 - <div class="form-section"> 622 - <div class="form-section-title" id="form-title">Schedule a Post</div> 757 + <!-- Outer tab bar: Create Post | Drafts --> 758 + <div class="tab-bar" style="margin-bottom:0;"> 759 + <button class="tab active" data-outer-tab="create">Create Post</button> 760 + <button class="tab" data-outer-tab="drafts">Drafts</button> 761 + <button class="tab" data-outer-tab="delivered">Delivered</button> 762 + </div> 623 763 624 - <label for="post-text">Post text</label> 625 - <textarea id="post-text" placeholder="What's on your mind?"></textarea> 764 + <!-- Create Post pane --> 765 + <div id="outer-pane-create" class="form-section" style="border-top:none;margin-top:0;"> 766 + <div id="form-title" class="hidden" style="font-size:0.78rem;font-weight:600;color:var(--indigo);margin-bottom:0.75rem;"></div> 626 767 627 - <label for="scheduled-at">Schedule for</label> 628 - <div class="time-presets"> 629 - <button type="button" class="btn-preset" id="preset-90s">+90s</button> 630 - <button type="button" class="btn-preset" id="preset-1hr">+1 hr</button> 631 - <button type="button" class="btn-preset" id="preset-tomorrow">Tomorrow 9am</button> 632 - <button type="button" class="btn-preset" id="preset-next-week">Next week</button> 633 - <button type="button" class="btn-preset" id="preset-draft">Draft</button> 768 + <div class="tab-bar"> 769 + <button class="tab active" data-tab="timed">Timed</button> 770 + <button class="tab" data-tab="webhook">Webhook</button> 771 + <button class="tab" data-tab="recurring">Recurring</button> 634 772 </div> 635 - <input type="datetime-local" id="scheduled-at" /> 636 773 637 - <label>Image (optional)</label> 638 - <div class="image-upload-area"> 639 - <input type="file" id="image-input" accept="image/jpeg,image/png,image/gif,image/webp" /> 640 - <div id="image-preview-wrap" class="hidden"> 641 - <img id="image-preview" alt="" /> 642 - <input type="text" id="image-alt" placeholder="Alt text (describe the image)" /> 643 - <button type="button" id="image-clear-btn" class="btn btn-secondary" style="align-self:flex-start;padding:0.25rem 0.6rem;font-size:0.78rem;">Remove image</button> 774 + <!-- Post form fields (Timed + Webhook tabs) --> 775 + <div id="post-form-fields"> 776 + <label for="post-text">Post text</label> 777 + <textarea id="post-text" placeholder="What's on your mind?"></textarea> 778 + 779 + <!-- Timed: datetime picker --> 780 + <div id="timed-schedule-section"> 781 + <label for="scheduled-at">Schedule for</label> 782 + <div class="time-presets"> 783 + <button type="button" class="btn-preset" id="preset-90s">+90s</button> 784 + <button type="button" class="btn-preset" id="preset-1hr">+1 hr</button> 785 + <button type="button" class="btn-preset" id="preset-tomorrow">Tomorrow 9am</button> 786 + <button type="button" class="btn-preset" id="preset-next-week">Next week</button> 787 + <button type="button" class="btn-preset" id="preset-draft">Draft</button> 788 + </div> 789 + <input type="datetime-local" id="scheduled-at" /> 644 790 </div> 791 + 792 + <!-- Webhook: info note --> 793 + <p id="webhook-note" class="hidden" style="font-size:0.82rem;color:#4338ca;background:var(--indigo-light);border:1px solid #c7d2fe;border-radius:6px;padding:0.6rem 0.75rem;margin-bottom:0.75rem;"> 794 + A one-time secret URL will be generated. Call it with <code>POST</code> to publish on demand — no schedule needed. 795 + </p> 796 + 797 + <label>Image (optional)</label> 798 + <div class="image-upload-area"> 799 + <input type="file" id="image-input" accept="image/jpeg,image/png,image/gif,image/webp" /> 800 + <div id="image-preview-wrap" class="hidden"> 801 + <img id="image-preview" alt="" /> 802 + <input type="text" id="image-alt" placeholder="Alt text (describe the image)" /> 803 + <button type="button" id="image-clear-btn" class="btn btn-secondary" style="align-self:flex-start;padding:0.25rem 0.6rem;font-size:0.78rem;">Remove image</button> 804 + </div> 805 + </div> 806 + 807 + <div style="display:flex;gap:0.5rem;flex-wrap:wrap;"> 808 + <button class="btn btn-primary" id="schedule-btn">Schedule Post</button> 809 + <button class="btn btn-secondary hidden" id="cancel-edit-btn">Cancel</button> 810 + </div> 811 + <div id="schedule-success" class="banner banner-success hidden" style="margin-top:0.75rem;"></div> 812 + <p id="schedule-error" style="color:#ef4444;font-size:0.82rem;margin-top:0.5rem;" class="hidden"></p> 645 813 </div> 646 814 647 - <div style="display:flex;gap:0.5rem;flex-wrap:wrap;"> 648 - <button class="btn btn-primary" id="schedule-btn">Schedule Post</button> 649 - <button class="btn btn-secondary hidden" id="cancel-edit-btn">Cancel</button> 815 + <!-- Recurring section (Recurring tab only) --> 816 + <div id="recurring-section" class="hidden"> 817 + <label for="sched-text">Post text</label> 818 + <textarea id="sched-text" placeholder="Text posted on each occurrence…" style="min-height:60px;"></textarea> 819 + 820 + <!-- Frequency type + interval --> 821 + <div class="time-row"> 822 + <div> 823 + <label for="sched-type">Frequency</label> 824 + <select id="sched-type"> 825 + <option value="daily">Daily</option> 826 + <option value="weekly">Weekly</option> 827 + <option value="monthly">Monthly</option> 828 + <option value="quarterly">Quarterly</option> 829 + <option value="yearly">Yearly</option> 830 + </select> 831 + </div> 832 + <div> 833 + <label for="sched-interval">Every</label> 834 + <div style="display:flex;align-items:center;gap:0.4rem;"> 835 + <input type="number" id="sched-interval" min="1" value="1" style="width:56px;" /> 836 + <span id="sched-interval-unit" style="font-size:0.82rem;color:var(--text-muted);white-space:nowrap;">days</span> 837 + </div> 838 + </div> 839 + </div> 840 + 841 + <!-- Weekly: days of week --> 842 + <div id="sched-weekly-opts" class="hidden"> 843 + <label>Days of week</label> 844 + <div class="days-checkboxes"> 845 + <label class="inline-check"><input type="checkbox" name="sched-day" value="1" checked /> Mon</label> 846 + <label class="inline-check"><input type="checkbox" name="sched-day" value="2" /> Tue</label> 847 + <label class="inline-check"><input type="checkbox" name="sched-day" value="3" /> Wed</label> 848 + <label class="inline-check"><input type="checkbox" name="sched-day" value="4" /> Thu</label> 849 + <label class="inline-check"><input type="checkbox" name="sched-day" value="5" /> Fri</label> 850 + <label class="inline-check"><input type="checkbox" name="sched-day" value="6" /> Sat</label> 851 + <label class="inline-check"><input type="checkbox" name="sched-day" value="0" /> Sun</label> 852 + </div> 853 + </div> 854 + 855 + <!-- Monthly: sub-pattern --> 856 + <div id="sched-monthly-opts" class="hidden"> 857 + <label for="sched-monthly-pattern">On</label> 858 + <select id="sched-monthly-pattern"> 859 + <option value="on_day">a specific day of the month</option> 860 + <option value="nth_weekday">the Nth weekday</option> 861 + <option value="last_business_day">the last business day</option> 862 + </select> 863 + </div> 864 + 865 + <!-- Yearly: sub-pattern --> 866 + <div id="sched-yearly-opts" class="hidden"> 867 + <label for="sched-yearly-pattern">On</label> 868 + <select id="sched-yearly-pattern"> 869 + <option value="on_month_day">a specific month and day</option> 870 + <option value="nth_weekday">the Nth weekday of a month</option> 871 + </select> 872 + </div> 873 + 874 + <!-- Shared: month selector (yearly patterns) --> 875 + <div id="sched-month-row" class="hidden"> 876 + <label for="sched-month">Month</label> 877 + <select id="sched-month"> 878 + <option value="1">January</option> 879 + <option value="2">February</option> 880 + <option value="3">March</option> 881 + <option value="4">April</option> 882 + <option value="5">May</option> 883 + <option value="6">June</option> 884 + <option value="7">July</option> 885 + <option value="8">August</option> 886 + <option value="9">September</option> 887 + <option value="10">October</option> 888 + <option value="11">November</option> 889 + <option value="12">December</option> 890 + </select> 891 + </div> 892 + 893 + <!-- Shared: day of month (monthly on_day, yearly on_month_day) --> 894 + <div id="sched-dom-row" class="hidden"> 895 + <label for="sched-dom">Day of month <span style="color:var(--text-muted);font-size:0.78rem;">(1–31, clamped to month end)</span></label> 896 + <input type="number" id="sched-dom" min="1" max="31" value="1" style="width:72px;" /> 897 + </div> 898 + 899 + <!-- Shared: Nth ordinal + weekday (monthly/yearly nth_weekday, quarterly) --> 900 + <div id="sched-nth-weekday-row" class="hidden"> 901 + <div class="time-row" style="margin-top:0;"> 902 + <div id="sched-nth-col"> 903 + <label for="sched-nth">Which</label> 904 + <select id="sched-nth"> 905 + <option value="1">1st</option> 906 + <option value="2">2nd</option> 907 + <option value="3">3rd</option> 908 + <option value="4">4th</option> 909 + <option value="-1">Last</option> 910 + </select> 911 + </div> 912 + <div> 913 + <label for="sched-weekday">Weekday</label> 914 + <select id="sched-weekday"> 915 + <option value="1">Monday</option> 916 + <option value="2">Tuesday</option> 917 + <option value="3">Wednesday</option> 918 + <option value="4">Thursday</option> 919 + <option value="5">Friday</option> 920 + <option value="6">Saturday</option> 921 + <option value="0">Sunday</option> 922 + </select> 923 + </div> 924 + </div> 925 + </div> 926 + 927 + <!-- Time of day + timezone --> 928 + <div class="time-row"> 929 + <div> 930 + <label for="sched-hour">Hour (0–23)</label> 931 + <input type="number" id="sched-hour" min="0" max="23" value="9" /> 932 + </div> 933 + <div> 934 + <label for="sched-minute">Minute</label> 935 + <input type="number" id="sched-minute" min="0" max="59" value="0" /> 936 + </div> 937 + </div> 938 + 939 + <label for="sched-tz">Timezone</label> 940 + <select id="sched-tz"> 941 + <option value="UTC">UTC</option> 942 + <option value="America/New_York">America/New_York (ET)</option> 943 + <option value="America/Chicago">America/Chicago (CT)</option> 944 + <option value="America/Denver">America/Denver (MT)</option> 945 + <option value="America/Los_Angeles">America/Los_Angeles (PT)</option> 946 + <option value="Europe/London">Europe/London</option> 947 + <option value="Europe/Paris">Europe/Paris</option> 948 + <option value="Asia/Tokyo">Asia/Tokyo</option> 949 + <option value="Australia/Sydney">Australia/Sydney</option> 950 + </select> 951 + 952 + <button class="btn btn-primary" id="create-schedule-btn">Create Schedule</button> 953 + <div id="sched-success" class="banner banner-success hidden" style="margin-top:0.75rem;"></div> 954 + <p id="sched-error" style="color:#ef4444;font-size:0.82rem;margin-top:0.5rem;" class="hidden"></p> 955 + 956 + <div id="schedules-list" style="margin-top:1rem;"> 957 + <div class="empty-state">No recurring schedules yet.</div> 958 + </div> 650 959 </div> 651 - <div id="schedule-success" class="banner banner-success hidden" style="margin-top:0.75rem;"></div> 652 - <p id="schedule-error" style="color:#ef4444;font-size:0.82rem;margin-top:0.5rem;" class="hidden"></p> 653 960 </div> 654 961 655 - <div class="form-section" style="border-bottom:none;margin-bottom:0;padding-bottom:0;"> 656 - <div class="form-section-title"> 657 - Drafts 658 - <span style="font-size:0.68rem;color:var(--text-faint);font-weight:400;">(live)</span> 659 - </div> 962 + <!-- Drafts pane (hidden by default) --> 963 + <div id="outer-pane-drafts" class="form-section hidden" style="border-bottom:none;margin-bottom:0;padding-bottom:0;"> 660 964 <div id="posts-list"> 661 965 <div class="empty-state">Loading...</div> 662 966 </div> 663 967 </div> 664 968 969 + <!-- Delivered pane (hidden by default) --> 970 + <div id="outer-pane-delivered" class="form-section hidden" style="border-bottom:none;margin-bottom:0;padding-bottom:0;"> 971 + <div id="delivered-list"> 972 + <div class="empty-state">No delivered posts yet.</div> 973 + </div> 974 + </div> 975 + 665 976 </div><!-- /view-authorized --> 666 977 </div><!-- /demo-panel --> 667 978 ··· 674 985 atproto Latency Fabric is an XRPC proxy draft scheduler for <a href="https://atproto.com" style="color:var(--indigo);text-decoration:none;font-weight:600;">atproto</a> records. Writes go in now, records appear in the feed later — at exactly the time you specify. 675 986 </p> 676 987 <p> 677 - Drop it in front of any atproto client: point writes at the proxy instead of directly at the PDS to get scheduled posting, drafts, and queued writes without changing your data model. 988 + Drop it in front of any atproto client: point writes at the proxy instead of directly at the PDS to get scheduled posting, drafts, recurring schedules, and webhook-triggered publishing without changing your data model. 678 989 </p> 679 990 </div> 680 991 ··· 688 999 </div> 689 1000 <div class="arch-edge"> 690 1001 <span class="arch-edge-arrow">↓</span> 691 - <span class="arch-edge-label">XRPC write + x-scheduled-at</span> 1002 + <span class="arch-edge-label">XRPC write + x-scheduled-at / x-trigger: webhook</span> 692 1003 </div> 693 1004 <div class="arch-node arch-node-alf"> 694 1005 <span class="arch-node-title">atproto Latency Fabric</span> 695 - <span class="arch-node-sub">transparent proxy · stores draft</span> 1006 + <span class="arch-node-sub">transparent proxy · stores draft · schedules recurrences</span> 696 1007 </div> 697 1008 <div class="arch-edge"> 698 1009 <span class="arch-edge-arrow">↓</span> 699 - <span class="arch-edge-label">at scheduled time</span> 1010 + <span class="arch-edge-label">at scheduled time · on trigger call · on recurrence fire</span> 700 1011 </div> 701 1012 <div class="arch-node"> 702 1013 <span class="arch-node-title">PDS</span> ··· 711 1022 </li> 712 1023 <li> 713 1024 <span class="step-num">2</span> 714 - <span><strong style="color:var(--text);">Redirect your writes</strong> — point XRPC write calls at the proxy. Add the <code>x-scheduled-at</code> header to set the publish time.</span> 1025 + <span><strong style="color:var(--text);">Redirect your writes</strong> — point XRPC write calls at the proxy. Add <code>x-scheduled-at</code> to set a time, or <code>x-trigger: webhook</code> to get a one-time publish URL.</span> 715 1026 </li> 716 1027 <li> 717 1028 <span class="step-num">3</span> ··· 719 1030 </li> 720 1031 <li> 721 1032 <span class="step-num">4</span> 722 - <span><strong style="color:var(--text);">Auto-publish</strong> — the built-in scheduler wakes up exactly at the scheduled time and commits the record to your PDS.</span> 1033 + <span><strong style="color:var(--text);">Auto-publish</strong> — the built-in scheduler wakes up at the scheduled time, or publishes when the trigger URL is called.</span> 723 1034 </li> 724 1035 <li> 725 1036 <span class="step-num">5</span> 726 1037 <span><strong style="color:var(--text);">Manage drafts</strong> — list, reschedule, publish immediately, or cancel via <code>town.roundabout.scheduledPosts.*</code> XRPC methods.</span> 727 1038 </li> 728 1039 </ol> 1040 + </div> 1041 + 1042 + <div class="info-section"> 1043 + <h2>Webhook Triggers</h2> 1044 + <p> 1045 + Add <code>x-trigger: webhook</code> to any write request. ALF generates a one-time secret URL and returns it in the response. Anyone with the URL can call <code>POST {triggerUrl}</code> (no auth required) to immediately publish the draft. 1046 + </p> 1047 + <p> 1048 + The URL is the secret — share it with Zapier, a bot, or any external system. The key is never stored in plaintext: ALF keeps an HMAC-SHA256 hash for O(1) lookup and an AES-256-GCM encrypted copy for retrieval. Calling the URL a second time returns <code>409 TriggerAlreadyFired</code>. 1049 + </p> 1050 + </div> 1051 + 1052 + <div class="info-section"> 1053 + <h2>Recurring Schedules</h2> 1054 + <p> 1055 + Create a schedule with a recurrence rule and ALF will automatically create and publish one draft per occurrence. Each fired occurrence is stored as a permanent draft record — full publish history, no data loss. 1056 + </p> 1057 + <p> 1058 + Schedules support <strong>static content</strong> (same post every time) or <strong>dynamic content</strong> via a <code>contentUrl</code> fetched at publish time with <code>?fireCount=N&amp;scheduledAt=T</code> query params. Pause, resume, or delete schedules at any time. DST transitions are handled automatically using IANA timezone names. 1059 + </p> 1060 + <p> 1061 + Rule types: <strong>once</strong>, <strong>daily</strong>, <strong>weekly</strong>, <strong>monthly on day</strong>, <strong>monthly last business day</strong>, <strong>yearly on month/day</strong>, <strong>quarterly last weekday</strong>, and more. Per-occurrence exceptions let you cancel, reschedule, or override the content of any individual firing. 1062 + </p> 729 1063 </div> 730 1064 731 1065 </div><!-- /info-panel -->
+287 -5
docs/api.md
··· 14 14 15 15 ### `com.atproto.repo.createRecord` 16 16 17 - Create a draft record. If `x-scheduled-at` is provided, the draft is immediately scheduled. 17 + Create a draft record. If `x-scheduled-at` is provided, the draft is immediately scheduled via a `once` recurrence schedule. If `x-trigger: webhook` is provided, a one-time secret URL is returned that publishes the draft on demand. 18 18 19 19 **Request headers:** 20 20 21 21 | Header | Required | Description | 22 22 |--------|----------|-------------| 23 23 | `Authorization` | Yes | `Bearer <access-token>` | 24 - | `x-scheduled-at` | No | ISO 8601 datetime. If set, draft is created with status `scheduled`. | 24 + | `x-scheduled-at` | No | ISO 8601 datetime. Creates a `once` schedule; draft gets a `scheduleId`. | 25 + | `x-trigger` | No | Set to `webhook` to generate a one-time trigger URL instead of a fixed schedule. | 25 26 26 27 **Request body:** 27 28 ··· 40 41 { 41 42 "uri": "at://did:plc:alice/app.bsky.feed.post/3kw9mts3abc", 42 43 "cid": "bafyreib...", 43 - "validationStatus": "unknown" 44 + "validationStatus": "unknown", 45 + "triggerUrl": "https://alf.example.com/triggers/..." // Only when x-trigger: webhook 44 46 } 45 47 ``` 46 48 ··· 58 60 59 61 Create a draft for a `putRecord` (create-or-update) operation. 60 62 61 - **Request headers:** Same as `createRecord`. 63 + **Request headers:** Same as `createRecord` (including `x-scheduled-at` and `x-trigger`). 62 64 63 65 **Request body:** 64 66 ··· 79 81 80 82 Create a draft for a `deleteRecord` operation. No record content is needed. 81 83 82 - **Request headers:** Same as `createRecord`. 84 + **Request headers:** Same as `createRecord` (including `x-scheduled-at` and `x-trigger`). 83 85 84 86 **Request body:** 85 87 ··· 251 253 252 254 --- 253 255 256 + ## Schedule management methods 257 + 258 + Recurring schedules fire on a recurrence rule and automatically create a new draft for each occurrence. The draft is published at the scheduled time, then the next draft is queued. 259 + 260 + ### `town.roundabout.scheduledPosts.createSchedule` 261 + 262 + Create a recurring schedule. ALF computes the first occurrence immediately and creates a draft for it. 263 + 264 + **Request body:** 265 + 266 + ```jsonc 267 + { 268 + "collection": "app.bsky.feed.post", 269 + "recurrenceRule": { /* RecurrenceRule */ }, 270 + "timezone": "America/New_York", 271 + "record": { ... }, // Static post content (mutually exclusive with contentUrl) 272 + "contentUrl": "https://..." // Dynamic content URL (mutually exclusive with record) 273 + } 274 + ``` 275 + 276 + **Response (200):** 277 + 278 + ```jsonc 279 + { 280 + "schedule": { /* ScheduleView */ } 281 + } 282 + ``` 283 + 284 + **Errors:** 285 + 286 + | Code | Description | 287 + |------|-------------| 288 + | `InvalidRequest` (400) | `record` and `contentUrl` both provided; rule produces no future occurrences | 289 + | `AuthRequired` (401) | Missing or invalid Bearer token | 290 + 291 + --- 292 + 293 + ### `town.roundabout.scheduledPosts.listSchedules` 294 + 295 + List schedules for a user. 296 + 297 + **Query parameters:** 298 + 299 + | Parameter | Required | Description | 300 + |-----------|----------|-------------| 301 + | `repo` | Yes | DID of the user. Must match the authenticated user. | 302 + | `status` | No | Filter by status: `active`, `paused`, `cancelled`, `completed`, `error` | 303 + | `limit` | No | Number of results (1–100, default 50) | 304 + | `cursor` | No | Pagination cursor from a previous response | 305 + 306 + **Response (200):** 307 + 308 + ```jsonc 309 + { 310 + "schedules": [ { /* ScheduleView */ } ], 311 + "cursor": "..." 312 + } 313 + ``` 314 + 315 + --- 316 + 317 + ### `town.roundabout.scheduledPosts.getSchedule` 318 + 319 + Get a single schedule by ID. 320 + 321 + **Query parameters:** 322 + 323 + | Parameter | Required | Description | 324 + |-----------|----------|-------------| 325 + | `id` | Yes | Schedule UUID | 326 + 327 + **Response (200):** A `ScheduleView` object. 328 + 329 + **Errors:** 330 + 331 + | Code | Description | 332 + |------|-------------| 333 + | `NotFound` (400) | No schedule with this ID | 334 + | `AuthRequired` (401) | Schedule belongs to a different user | 335 + 336 + --- 337 + 338 + ### `town.roundabout.scheduledPosts.updateSchedule` 339 + 340 + Pause or resume a schedule. Pausing cancels the pending next draft; resuming immediately computes and queues the next occurrence. 341 + 342 + **Request body:** 343 + 344 + ```jsonc 345 + { 346 + "id": "550e8400-e29b-41d4-a716-446655440000", 347 + "status": "paused" // "paused" or "active" 348 + } 349 + ``` 350 + 351 + **Response (200):** The updated `ScheduleView`. 352 + 353 + **Errors:** 354 + 355 + | Code | Description | 356 + |------|-------------| 357 + | `NotFound` (400) | Schedule not found | 358 + | `AuthRequired` (401) | Schedule belongs to a different user | 359 + 360 + --- 361 + 362 + ### `town.roundabout.scheduledPosts.deleteSchedule` 363 + 364 + Delete a schedule and cancel its pending draft. This is permanent. 365 + 366 + **Request body:** 367 + 368 + ```jsonc 369 + { 370 + "id": "550e8400-e29b-41d4-a716-446655440000" 371 + } 372 + ``` 373 + 374 + **Response (200):** 375 + 376 + ```jsonc 377 + {} 378 + ``` 379 + 380 + **Errors:** 381 + 382 + | Code | Description | 383 + |------|-------------| 384 + | `NotFound` (400) | Schedule not found | 385 + | `AuthRequired` (401) | Schedule belongs to a different user | 386 + 387 + --- 388 + 254 389 ## REST endpoints 255 390 256 391 ### `POST /blob` ··· 298 433 299 434 --- 300 435 436 + ### `POST /triggers/:key` 437 + 438 + Fire a webhook trigger draft immediately. No authentication required — the URL itself is the secret. 439 + 440 + **Response (200):** 441 + 442 + ```jsonc 443 + { 444 + "published": true, 445 + "uri": "at://did:plc:alice/app.bsky.feed.post/3kw9mts3abc" 446 + } 447 + ``` 448 + 449 + **Errors:** 450 + 451 + | Code | Description | 452 + |------|-------------| 453 + | `NotFound` (404) | Trigger key not found | 454 + | `TriggerAlreadyFired` (409) | Draft already published, failed, or cancelled | 455 + 456 + --- 457 + 301 458 ### `GET /oauth/status` 302 459 303 460 Check whether the authenticated user has authorized ALF to publish on their behalf. ··· 363 520 createdAt: string; // ISO 8601 datetime 364 521 failureReason?: string; // Present only when status is "failed" 365 522 record?: object; // Record content; absent for deleteRecord drafts 523 + scheduleId?: string; // UUID of the parent schedule, if this draft was created by one 524 + triggerUrl?: string; // One-time webhook URL; only present on drafts with x-trigger: webhook 366 525 } 367 526 ``` 368 527 ··· 377 536 | `failed` | Failed to publish after all retry attempts. | 378 537 | `cancelled` | Cancelled by the user via `deletePost`. | 379 538 539 + --- 540 + 541 + ## ScheduleView object 542 + 543 + Schedule management endpoints return a `ScheduleView`: 544 + 545 + ```typescript 546 + { 547 + id: string; // UUID 548 + collection: string; // NSID, e.g. "app.bsky.feed.post" 549 + status: "active" | "paused" | "cancelled" | "completed" | "error"; 550 + recurrenceRule: RecurrenceRule; // Full rule object (see below) 551 + timezone: string; // IANA timezone 552 + fireCount: number; // Number of times this schedule has fired 553 + createdAt: string; // ISO 8601 554 + lastFiredAt?: string; // ISO 8601; present after first firing 555 + nextDraftUri?: string; // AT-URI of the pending next draft 556 + record?: object; // Static post content, if applicable 557 + contentUrl?: string; // Dynamic content URL, if applicable 558 + } 559 + ``` 560 + 561 + ### Schedule statuses 562 + 563 + | Status | Description | 564 + |--------|-------------| 565 + | `active` | Running normally; a pending draft exists. | 566 + | `paused` | Paused by the user; no pending draft. | 567 + | `cancelled` | Deleted by the user. | 568 + | `completed` | Series naturally exhausted (e.g., a `once` schedule that has fired). | 569 + | `error` | An unrecoverable error occurred during chaining or publishing. | 570 + 571 + --- 572 + 573 + ## RecurrenceRule object 574 + 575 + A `RecurrenceRule` is a JSON object passed to `createSchedule`. It contains a core rule plus optional bounds and exception lists. 576 + 577 + ```typescript 578 + { 579 + rule: RecurrenceRuleCore; // The core firing pattern (see below) 580 + startDate?: string; // YYYY-MM-DD: first occurrence must be on or after this date 581 + endDate?: string; // YYYY-MM-DD: no occurrences after this date 582 + count?: number; // Maximum number of total firings 583 + revisions?: RecurrenceRevision[]; // Time-spec changes taking effect from a given date 584 + exceptions?: RecurrenceException[]; // Per-occurrence overrides (cancel, move, override_time, override_payload) 585 + } 586 + ``` 587 + 588 + ### Core rule types 589 + 590 + | Type | Description | Extra fields | 591 + |------|-------------|--------------| 592 + | `once` | Fires exactly once at the given UTC datetime. | `datetime: string` (ISO 8601 UTC) | 593 + | `daily` | Every N days at the given time. | `interval?: number`, `time: TimeSpec` | 594 + | `weekly` | Every N weeks on the specified days of the week. | `interval?: number`, `daysOfWeek: number[]` (0=Sun–6=Sat), `time: TimeSpec` | 595 + | `monthly_on_day` | Nth day of every N-th month; clamps to last day if month is shorter. | `interval?: number`, `dayOfMonth: number` (1–31), `time: TimeSpec` | 596 + | `monthly_nth_weekday` | Nth weekday of every N-th month (nth=-1 means last). | `interval?: number`, `nth: number` (1–4 or -1), `weekday: number` (0=Sun–6=Sat), `time: TimeSpec` | 597 + | `monthly_last_business_day` | Last Mon–Fri of every N-th month. | `interval?: number`, `time: TimeSpec` | 598 + | `yearly_on_month_day` | Specific month and day each year; clamps Feb 29 in non-leap years. | `interval?: number`, `month: number` (1–12), `dayOfMonth: number` (1–31), `time: TimeSpec` | 599 + | `yearly_nth_weekday` | Nth weekday of a specific month each year. | `interval?: number`, `month: number`, `nth: number`, `weekday: number`, `time: TimeSpec` | 600 + | `quarterly_last_weekday` | Last occurrence of a weekday in each quarter-end month (Mar, Jun, Sep, Dec). | `interval?: number` (quarters between fires, default 1), `weekday: number`, `time: TimeSpec` | 601 + 602 + ### TimeSpec 603 + 604 + All repeating rule types include a `time` field that is one of: 605 + 606 + ```typescript 607 + // Wall-clock time in a named timezone (DST-aware) 608 + { type: "wall_time", hour: number, minute: number, second?: number, timezone: string } 609 + 610 + // Fixed UTC offset (does not adjust for DST) 611 + { type: "fixed_instant", utcOffsetMinutes: number, hour: number, minute: number, second?: number } 612 + ``` 613 + 614 + ### Exception types 615 + 616 + Exceptions are matched by their `date` field (a `YYYY-MM-DD` string in the rule's timezone). Multiple exceptions for different dates can coexist. 617 + 618 + | Type | Effect | Fields | 619 + |------|--------|--------| 620 + | `cancel` | Skip this occurrence entirely. | `date: string` | 621 + | `move` | Publish at a different UTC datetime instead. | `date: string`, `newDatetime: string` (ISO 8601 UTC) | 622 + | `override_time` | Use a different time spec for this occurrence only. | `date: string`, `time: TimeSpec` | 623 + | `override_payload` | Publish a different record for this occurrence. Resolved at publish time, not schedule time. | `date: string`, `record: object` | 624 + 625 + ### Example: daily post at 9 AM ET, skipping a holiday 626 + 627 + ```jsonc 628 + { 629 + "rule": { 630 + "type": "daily", 631 + "time": { "type": "wall_time", "hour": 9, "minute": 0, "timezone": "America/New_York" } 632 + }, 633 + "exceptions": [ 634 + { "type": "cancel", "date": "2025-07-04" }, 635 + { "type": "override_payload", "date": "2025-12-25", "record": { 636 + "$type": "app.bsky.feed.post", "text": "Happy holidays!", "createdAt": "2025-12-25T00:00:00Z" 637 + }} 638 + ] 639 + } 640 + ``` 641 + 642 + ### Example: once schedule 643 + 644 + ```jsonc 645 + { 646 + "rule": { "type": "once", "datetime": "2025-06-01T14:00:00Z" } 647 + } 648 + ``` 649 + 650 + > **Note:** Using `x-scheduled-at` on `createRecord` or `putRecord` automatically creates a `once` schedule behind the scenes. The returned draft will have a `scheduleId` pointing to it. 651 + 652 + ### Dynamic content schedules 653 + 654 + If `contentUrl` is provided instead of `record`, ALF fetches the URL at publish time with two query parameters: 655 + 656 + | Parameter | Description | 657 + |-----------|-------------| 658 + | `fireCount` | 1-based count of how many times this schedule has fired (including this firing) | 659 + | `scheduledAt` | ISO 8601 datetime of the scheduled occurrence | 660 + 661 + The response must be a JSON object that is the record to publish.
+53
lexicons/town/roundabout/scheduledPosts/createSchedule.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "town.roundabout.scheduledPosts.createSchedule", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Create a recurring schedule that automatically creates and publishes drafts", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["collection", "recurrenceRule", "timezone"], 13 + "properties": { 14 + "collection": { 15 + "type": "string", 16 + "format": "nsid", 17 + "description": "The NSID of the record collection" 18 + }, 19 + "recurrenceRule": { 20 + "type": "unknown", 21 + "description": "The recurrence rule JSON object" 22 + }, 23 + "timezone": { 24 + "type": "string", 25 + "description": "IANA timezone for the schedule (extracted from rule for convenience)" 26 + }, 27 + "record": { 28 + "type": "unknown", 29 + "description": "Static record content (mutually exclusive with contentUrl)" 30 + }, 31 + "contentUrl": { 32 + "type": "string", 33 + "description": "URL to fetch at publish time for dynamic record content (mutually exclusive with record)" 34 + } 35 + } 36 + } 37 + }, 38 + "output": { 39 + "encoding": "application/json", 40 + "schema": { 41 + "type": "object", 42 + "required": ["schedule"], 43 + "properties": { 44 + "schedule": { 45 + "type": "ref", 46 + "ref": "town.roundabout.scheduledPosts.defs#scheduleView" 47 + } 48 + } 49 + } 50 + } 51 + } 52 + } 53 + }
+75
lexicons/town/roundabout/scheduledPosts/defs.json
··· 63 63 "type": "string", 64 64 "description": "Error message if the draft failed to publish", 65 65 "maxLength": 500 66 + }, 67 + "triggerUrl": { 68 + "type": "string", 69 + "description": "One-time webhook URL that immediately publishes this draft when called (only present for webhook-triggered drafts)" 70 + }, 71 + "scheduleId": { 72 + "type": "string", 73 + "description": "ID of the recurring schedule that created this draft, if any" 74 + } 75 + } 76 + }, 77 + "scheduleView": { 78 + "type": "object", 79 + "description": "A recurring schedule that creates drafts on a recurrence rule", 80 + "required": [ 81 + "id", 82 + "collection", 83 + "status", 84 + "recurrenceRule", 85 + "timezone", 86 + "fireCount", 87 + "createdAt", 88 + "updatedAt" 89 + ], 90 + "properties": { 91 + "id": { 92 + "type": "string", 93 + "description": "UUID identifier for this schedule" 94 + }, 95 + "collection": { 96 + "type": "string", 97 + "format": "nsid", 98 + "description": "The NSID of the record collection" 99 + }, 100 + "status": { 101 + "type": "string", 102 + "knownValues": ["active", "paused", "cancelled", "error"], 103 + "description": "Current status of the schedule" 104 + }, 105 + "recurrenceRule": { 106 + "type": "unknown", 107 + "description": "The recurrence rule JSON object" 108 + }, 109 + "timezone": { 110 + "type": "string", 111 + "description": "IANA timezone for the schedule" 112 + }, 113 + "fireCount": { 114 + "type": "integer", 115 + "description": "Number of times this schedule has fired" 116 + }, 117 + "createdAt": { 118 + "type": "string", 119 + "format": "datetime" 120 + }, 121 + "updatedAt": { 122 + "type": "string", 123 + "format": "datetime" 124 + }, 125 + "lastFiredAt": { 126 + "type": "string", 127 + "format": "datetime" 128 + }, 129 + "nextDraftUri": { 130 + "type": "string", 131 + "format": "at-uri", 132 + "description": "AT-URI of the pending draft for the next occurrence" 133 + }, 134 + "contentUrl": { 135 + "type": "string", 136 + "description": "URL fetched at publish time for dynamic content (mutually exclusive with record)" 137 + }, 138 + "record": { 139 + "type": "unknown", 140 + "description": "Static record content (mutually exclusive with contentUrl)" 66 141 } 67 142 } 68 143 }
+30
lexicons/town/roundabout/scheduledPosts/deleteSchedule.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "town.roundabout.scheduledPosts.deleteSchedule", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Cancel and delete a recurring schedule and its pending draft", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["id"], 13 + "properties": { 14 + "id": { 15 + "type": "string", 16 + "description": "The UUID of the schedule to delete" 17 + } 18 + } 19 + } 20 + }, 21 + "output": { 22 + "encoding": "application/json", 23 + "schema": { 24 + "type": "object", 25 + "properties": {} 26 + } 27 + } 28 + } 29 + } 30 + }
+33
lexicons/town/roundabout/scheduledPosts/getSchedule.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "town.roundabout.scheduledPosts.getSchedule", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get a single recurring schedule by ID", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["id"], 11 + "properties": { 12 + "id": { 13 + "type": "string", 14 + "description": "The UUID of the schedule" 15 + } 16 + } 17 + }, 18 + "output": { 19 + "encoding": "application/json", 20 + "schema": { 21 + "type": "object", 22 + "required": ["schedule"], 23 + "properties": { 24 + "schedule": { 25 + "type": "ref", 26 + "ref": "town.roundabout.scheduledPosts.defs#scheduleView" 27 + } 28 + } 29 + } 30 + } 31 + } 32 + } 33 + }
+53
lexicons/town/roundabout/scheduledPosts/listSchedules.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "town.roundabout.scheduledPosts.listSchedules", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "List recurring schedules for a user", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["repo"], 11 + "properties": { 12 + "repo": { 13 + "type": "string", 14 + "format": "did", 15 + "description": "The DID of the user" 16 + }, 17 + "status": { 18 + "type": "string", 19 + "description": "Filter by status (active, paused, cancelled, error)" 20 + }, 21 + "limit": { 22 + "type": "integer", 23 + "default": 50, 24 + "minimum": 1, 25 + "maximum": 100 26 + }, 27 + "cursor": { 28 + "type": "string" 29 + } 30 + } 31 + }, 32 + "output": { 33 + "encoding": "application/json", 34 + "schema": { 35 + "type": "object", 36 + "required": ["schedules"], 37 + "properties": { 38 + "schedules": { 39 + "type": "array", 40 + "items": { 41 + "type": "ref", 42 + "ref": "town.roundabout.scheduledPosts.defs#scheduleView" 43 + } 44 + }, 45 + "cursor": { 46 + "type": "string" 47 + } 48 + } 49 + } 50 + } 51 + } 52 + } 53 + }
+57
lexicons/town/roundabout/scheduledPosts/updateSchedule.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "town.roundabout.scheduledPosts.updateSchedule", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Update a recurring schedule (modify rule, content, pause/resume)", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["id"], 13 + "properties": { 14 + "id": { 15 + "type": "string", 16 + "description": "The UUID of the schedule to update" 17 + }, 18 + "recurrenceRule": { 19 + "type": "unknown", 20 + "description": "New recurrence rule (replaces existing)" 21 + }, 22 + "timezone": { 23 + "type": "string", 24 + "description": "New IANA timezone" 25 + }, 26 + "record": { 27 + "type": "unknown", 28 + "description": "New static record content" 29 + }, 30 + "contentUrl": { 31 + "type": "string", 32 + "description": "New dynamic content URL" 33 + }, 34 + "status": { 35 + "type": "string", 36 + "knownValues": ["active", "paused"], 37 + "description": "Set to 'paused' to pause or 'active' to resume the schedule" 38 + } 39 + } 40 + } 41 + }, 42 + "output": { 43 + "encoding": "application/json", 44 + "schema": { 45 + "type": "object", 46 + "required": ["schedule"], 47 + "properties": { 48 + "schedule": { 49 + "type": "ref", 50 + "ref": "town.roundabout.scheduledPosts.defs#scheduleView" 51 + } 52 + } 53 + } 54 + } 55 + } 56 + } 57 + }
+39
package-lock.json
··· 7 7 "": { 8 8 "name": "alf", 9 9 "version": "1.0.0", 10 + "license": "Apache-2.0", 10 11 "dependencies": { 11 12 "@atproto/api": "^0.19.0", 12 13 "@atproto/common": "^0.5.13", ··· 16 17 "@atproto/oauth-client-node": "^0.3.17", 17 18 "@atproto/repo": "^0.8.12", 18 19 "@atproto/xrpc-server": "^0.10.14", 20 + "@newpublic/recurrence": "file:packages/recurrence", 21 + "@types/luxon": "^3.7.1", 19 22 "better-sqlite3": "^12.5.0", 20 23 "cors": "^2.8.5", 21 24 "dotenv": "^16.0.3", 22 25 "express": "^4.21.2", 23 26 "jose": "^5.9.6", 24 27 "kysely": "^0.27.4", 28 + "luxon": "^3.7.2", 25 29 "multiformats": "^9.9.0", 26 30 "pg": "^8.13.1", 27 31 "pino": "^8.21.0", ··· 915 919 "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", 916 920 "dev": true, 917 921 "license": "MIT", 922 + "peer": true, 918 923 "dependencies": { 919 924 "@babel/code-frame": "^7.28.6", 920 925 "@babel/parser": "^7.28.6", ··· 2308 2313 "langium": "^4.0.0" 2309 2314 } 2310 2315 }, 2316 + "node_modules/@newpublic/recurrence": { 2317 + "resolved": "packages/recurrence", 2318 + "link": true 2319 + }, 2311 2320 "node_modules/@noble/curves": { 2312 2321 "version": "1.9.7", 2313 2322 "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", ··· 3108 3117 "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", 3109 3118 "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", 3110 3119 "dev": true, 3120 + "license": "MIT" 3121 + }, 3122 + "node_modules/@types/luxon": { 3123 + "version": "3.7.1", 3124 + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.1.tgz", 3125 + "integrity": "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==", 3111 3126 "license": "MIT" 3112 3127 }, 3113 3128 "node_modules/@types/methods": { ··· 8155 8170 "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", 8156 8171 "license": "ISC" 8157 8172 }, 8173 + "node_modules/luxon": { 8174 + "version": "3.7.2", 8175 + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", 8176 + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", 8177 + "license": "MIT", 8178 + "engines": { 8179 + "node": ">=12" 8180 + } 8181 + }, 8158 8182 "node_modules/make-dir": { 8159 8183 "version": "4.0.0", 8160 8184 "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", ··· 11539 11563 "license": "MIT", 11540 11564 "funding": { 11541 11565 "url": "https://github.com/sponsors/colinhacks" 11566 + } 11567 + }, 11568 + "packages/recurrence": { 11569 + "name": "@newpublic/recurrence", 11570 + "version": "0.1.0", 11571 + "license": "Apache-2.0", 11572 + "dependencies": { 11573 + "luxon": "^3.5.0" 11574 + }, 11575 + "devDependencies": { 11576 + "@types/jest": "^29.5.14", 11577 + "@types/luxon": "^3.4.2", 11578 + "jest": "^29.7.0", 11579 + "ts-jest": "^29.4.6", 11580 + "typescript": "^5.6.0" 11542 11581 } 11543 11582 } 11544 11583 }
+3
package.json
··· 19 19 "diagrams": "d2 --layout elk assets/diagram-lifecycle.d2 assets/diagram-lifecycle.svg && d2 --layout elk --scale 2 assets/diagram-flow.d2 assets/diagram-flow.png" 20 20 }, 21 21 "dependencies": { 22 + "@newpublic/recurrence": "file:packages/recurrence", 22 23 "@atproto/api": "^0.19.0", 23 24 "@atproto/common": "^0.5.13", 24 25 "@atproto/crypto": "^0.4.5", ··· 27 28 "@atproto/oauth-client-node": "^0.3.17", 28 29 "@atproto/repo": "^0.8.12", 29 30 "@atproto/xrpc-server": "^0.10.14", 31 + "@types/luxon": "^3.7.1", 30 32 "better-sqlite3": "^12.5.0", 31 33 "cors": "^2.8.5", 32 34 "dotenv": "^16.0.3", 33 35 "express": "^4.21.2", 34 36 "jose": "^5.9.6", 35 37 "kysely": "^0.27.4", 38 + "luxon": "^3.7.2", 36 39 "multiformats": "^9.9.0", 37 40 "pg": "^8.13.1", 38 41 "pino": "^8.21.0",
+9
packages/recurrence/jest.config.js
··· 1 + /** @type {import('jest').Config} */ 2 + module.exports = { 3 + preset: 'ts-jest', 4 + testEnvironment: 'node', 5 + testMatch: ['**/src/__tests__/**/*.test.ts'], 6 + moduleNameMapper: { 7 + '^(.*)\\.js$': '$1', 8 + }, 9 + };
+3869
packages/recurrence/package-lock.json
··· 1 + { 2 + "name": "@newpublic/recurrence", 3 + "version": "0.1.0", 4 + "lockfileVersion": 3, 5 + "requires": true, 6 + "packages": { 7 + "": { 8 + "name": "@newpublic/recurrence", 9 + "version": "0.1.0", 10 + "license": "Apache-2.0", 11 + "dependencies": { 12 + "luxon": "^3.5.0" 13 + }, 14 + "devDependencies": { 15 + "@types/jest": "^29.5.14", 16 + "@types/luxon": "^3.4.2", 17 + "jest": "^29.7.0", 18 + "ts-jest": "^29.4.6", 19 + "typescript": "^5.6.0" 20 + } 21 + }, 22 + "node_modules/@babel/code-frame": { 23 + "version": "7.29.0", 24 + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", 25 + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", 26 + "dev": true, 27 + "license": "MIT", 28 + "dependencies": { 29 + "@babel/helper-validator-identifier": "^7.28.5", 30 + "js-tokens": "^4.0.0", 31 + "picocolors": "^1.1.1" 32 + }, 33 + "engines": { 34 + "node": ">=6.9.0" 35 + } 36 + }, 37 + "node_modules/@babel/compat-data": { 38 + "version": "7.29.0", 39 + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", 40 + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", 41 + "dev": true, 42 + "license": "MIT", 43 + "engines": { 44 + "node": ">=6.9.0" 45 + } 46 + }, 47 + "node_modules/@babel/core": { 48 + "version": "7.29.0", 49 + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", 50 + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", 51 + "dev": true, 52 + "license": "MIT", 53 + "dependencies": { 54 + "@babel/code-frame": "^7.29.0", 55 + "@babel/generator": "^7.29.0", 56 + "@babel/helper-compilation-targets": "^7.28.6", 57 + "@babel/helper-module-transforms": "^7.28.6", 58 + "@babel/helpers": "^7.28.6", 59 + "@babel/parser": "^7.29.0", 60 + "@babel/template": "^7.28.6", 61 + "@babel/traverse": "^7.29.0", 62 + "@babel/types": "^7.29.0", 63 + "@jridgewell/remapping": "^2.3.5", 64 + "convert-source-map": "^2.0.0", 65 + "debug": "^4.1.0", 66 + "gensync": "^1.0.0-beta.2", 67 + "json5": "^2.2.3", 68 + "semver": "^6.3.1" 69 + }, 70 + "engines": { 71 + "node": ">=6.9.0" 72 + }, 73 + "funding": { 74 + "type": "opencollective", 75 + "url": "https://opencollective.com/babel" 76 + } 77 + }, 78 + "node_modules/@babel/generator": { 79 + "version": "7.29.1", 80 + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", 81 + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", 82 + "dev": true, 83 + "license": "MIT", 84 + "dependencies": { 85 + "@babel/parser": "^7.29.0", 86 + "@babel/types": "^7.29.0", 87 + "@jridgewell/gen-mapping": "^0.3.12", 88 + "@jridgewell/trace-mapping": "^0.3.28", 89 + "jsesc": "^3.0.2" 90 + }, 91 + "engines": { 92 + "node": ">=6.9.0" 93 + } 94 + }, 95 + "node_modules/@babel/helper-compilation-targets": { 96 + "version": "7.28.6", 97 + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", 98 + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", 99 + "dev": true, 100 + "license": "MIT", 101 + "dependencies": { 102 + "@babel/compat-data": "^7.28.6", 103 + "@babel/helper-validator-option": "^7.27.1", 104 + "browserslist": "^4.24.0", 105 + "lru-cache": "^5.1.1", 106 + "semver": "^6.3.1" 107 + }, 108 + "engines": { 109 + "node": ">=6.9.0" 110 + } 111 + }, 112 + "node_modules/@babel/helper-globals": { 113 + "version": "7.28.0", 114 + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", 115 + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", 116 + "dev": true, 117 + "license": "MIT", 118 + "engines": { 119 + "node": ">=6.9.0" 120 + } 121 + }, 122 + "node_modules/@babel/helper-module-imports": { 123 + "version": "7.28.6", 124 + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", 125 + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", 126 + "dev": true, 127 + "license": "MIT", 128 + "dependencies": { 129 + "@babel/traverse": "^7.28.6", 130 + "@babel/types": "^7.28.6" 131 + }, 132 + "engines": { 133 + "node": ">=6.9.0" 134 + } 135 + }, 136 + "node_modules/@babel/helper-module-transforms": { 137 + "version": "7.28.6", 138 + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", 139 + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", 140 + "dev": true, 141 + "license": "MIT", 142 + "dependencies": { 143 + "@babel/helper-module-imports": "^7.28.6", 144 + "@babel/helper-validator-identifier": "^7.28.5", 145 + "@babel/traverse": "^7.28.6" 146 + }, 147 + "engines": { 148 + "node": ">=6.9.0" 149 + }, 150 + "peerDependencies": { 151 + "@babel/core": "^7.0.0" 152 + } 153 + }, 154 + "node_modules/@babel/helper-plugin-utils": { 155 + "version": "7.28.6", 156 + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", 157 + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", 158 + "dev": true, 159 + "license": "MIT", 160 + "engines": { 161 + "node": ">=6.9.0" 162 + } 163 + }, 164 + "node_modules/@babel/helper-string-parser": { 165 + "version": "7.27.1", 166 + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", 167 + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", 168 + "dev": true, 169 + "license": "MIT", 170 + "engines": { 171 + "node": ">=6.9.0" 172 + } 173 + }, 174 + "node_modules/@babel/helper-validator-identifier": { 175 + "version": "7.28.5", 176 + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", 177 + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", 178 + "dev": true, 179 + "license": "MIT", 180 + "engines": { 181 + "node": ">=6.9.0" 182 + } 183 + }, 184 + "node_modules/@babel/helper-validator-option": { 185 + "version": "7.27.1", 186 + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", 187 + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", 188 + "dev": true, 189 + "license": "MIT", 190 + "engines": { 191 + "node": ">=6.9.0" 192 + } 193 + }, 194 + "node_modules/@babel/helpers": { 195 + "version": "7.28.6", 196 + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", 197 + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", 198 + "dev": true, 199 + "license": "MIT", 200 + "dependencies": { 201 + "@babel/template": "^7.28.6", 202 + "@babel/types": "^7.28.6" 203 + }, 204 + "engines": { 205 + "node": ">=6.9.0" 206 + } 207 + }, 208 + "node_modules/@babel/parser": { 209 + "version": "7.29.0", 210 + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", 211 + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", 212 + "dev": true, 213 + "license": "MIT", 214 + "dependencies": { 215 + "@babel/types": "^7.29.0" 216 + }, 217 + "bin": { 218 + "parser": "bin/babel-parser.js" 219 + }, 220 + "engines": { 221 + "node": ">=6.0.0" 222 + } 223 + }, 224 + "node_modules/@babel/plugin-syntax-async-generators": { 225 + "version": "7.8.4", 226 + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", 227 + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", 228 + "dev": true, 229 + "license": "MIT", 230 + "dependencies": { 231 + "@babel/helper-plugin-utils": "^7.8.0" 232 + }, 233 + "peerDependencies": { 234 + "@babel/core": "^7.0.0-0" 235 + } 236 + }, 237 + "node_modules/@babel/plugin-syntax-bigint": { 238 + "version": "7.8.3", 239 + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", 240 + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", 241 + "dev": true, 242 + "license": "MIT", 243 + "dependencies": { 244 + "@babel/helper-plugin-utils": "^7.8.0" 245 + }, 246 + "peerDependencies": { 247 + "@babel/core": "^7.0.0-0" 248 + } 249 + }, 250 + "node_modules/@babel/plugin-syntax-class-properties": { 251 + "version": "7.12.13", 252 + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", 253 + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", 254 + "dev": true, 255 + "license": "MIT", 256 + "dependencies": { 257 + "@babel/helper-plugin-utils": "^7.12.13" 258 + }, 259 + "peerDependencies": { 260 + "@babel/core": "^7.0.0-0" 261 + } 262 + }, 263 + "node_modules/@babel/plugin-syntax-class-static-block": { 264 + "version": "7.14.5", 265 + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", 266 + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", 267 + "dev": true, 268 + "license": "MIT", 269 + "dependencies": { 270 + "@babel/helper-plugin-utils": "^7.14.5" 271 + }, 272 + "engines": { 273 + "node": ">=6.9.0" 274 + }, 275 + "peerDependencies": { 276 + "@babel/core": "^7.0.0-0" 277 + } 278 + }, 279 + "node_modules/@babel/plugin-syntax-import-attributes": { 280 + "version": "7.28.6", 281 + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", 282 + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", 283 + "dev": true, 284 + "license": "MIT", 285 + "dependencies": { 286 + "@babel/helper-plugin-utils": "^7.28.6" 287 + }, 288 + "engines": { 289 + "node": ">=6.9.0" 290 + }, 291 + "peerDependencies": { 292 + "@babel/core": "^7.0.0-0" 293 + } 294 + }, 295 + "node_modules/@babel/plugin-syntax-import-meta": { 296 + "version": "7.10.4", 297 + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", 298 + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", 299 + "dev": true, 300 + "license": "MIT", 301 + "dependencies": { 302 + "@babel/helper-plugin-utils": "^7.10.4" 303 + }, 304 + "peerDependencies": { 305 + "@babel/core": "^7.0.0-0" 306 + } 307 + }, 308 + "node_modules/@babel/plugin-syntax-json-strings": { 309 + "version": "7.8.3", 310 + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", 311 + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", 312 + "dev": true, 313 + "license": "MIT", 314 + "dependencies": { 315 + "@babel/helper-plugin-utils": "^7.8.0" 316 + }, 317 + "peerDependencies": { 318 + "@babel/core": "^7.0.0-0" 319 + } 320 + }, 321 + "node_modules/@babel/plugin-syntax-jsx": { 322 + "version": "7.28.6", 323 + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", 324 + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", 325 + "dev": true, 326 + "license": "MIT", 327 + "dependencies": { 328 + "@babel/helper-plugin-utils": "^7.28.6" 329 + }, 330 + "engines": { 331 + "node": ">=6.9.0" 332 + }, 333 + "peerDependencies": { 334 + "@babel/core": "^7.0.0-0" 335 + } 336 + }, 337 + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { 338 + "version": "7.10.4", 339 + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", 340 + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", 341 + "dev": true, 342 + "license": "MIT", 343 + "dependencies": { 344 + "@babel/helper-plugin-utils": "^7.10.4" 345 + }, 346 + "peerDependencies": { 347 + "@babel/core": "^7.0.0-0" 348 + } 349 + }, 350 + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { 351 + "version": "7.8.3", 352 + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", 353 + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", 354 + "dev": true, 355 + "license": "MIT", 356 + "dependencies": { 357 + "@babel/helper-plugin-utils": "^7.8.0" 358 + }, 359 + "peerDependencies": { 360 + "@babel/core": "^7.0.0-0" 361 + } 362 + }, 363 + "node_modules/@babel/plugin-syntax-numeric-separator": { 364 + "version": "7.10.4", 365 + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", 366 + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", 367 + "dev": true, 368 + "license": "MIT", 369 + "dependencies": { 370 + "@babel/helper-plugin-utils": "^7.10.4" 371 + }, 372 + "peerDependencies": { 373 + "@babel/core": "^7.0.0-0" 374 + } 375 + }, 376 + "node_modules/@babel/plugin-syntax-object-rest-spread": { 377 + "version": "7.8.3", 378 + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", 379 + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", 380 + "dev": true, 381 + "license": "MIT", 382 + "dependencies": { 383 + "@babel/helper-plugin-utils": "^7.8.0" 384 + }, 385 + "peerDependencies": { 386 + "@babel/core": "^7.0.0-0" 387 + } 388 + }, 389 + "node_modules/@babel/plugin-syntax-optional-catch-binding": { 390 + "version": "7.8.3", 391 + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", 392 + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", 393 + "dev": true, 394 + "license": "MIT", 395 + "dependencies": { 396 + "@babel/helper-plugin-utils": "^7.8.0" 397 + }, 398 + "peerDependencies": { 399 + "@babel/core": "^7.0.0-0" 400 + } 401 + }, 402 + "node_modules/@babel/plugin-syntax-optional-chaining": { 403 + "version": "7.8.3", 404 + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", 405 + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", 406 + "dev": true, 407 + "license": "MIT", 408 + "dependencies": { 409 + "@babel/helper-plugin-utils": "^7.8.0" 410 + }, 411 + "peerDependencies": { 412 + "@babel/core": "^7.0.0-0" 413 + } 414 + }, 415 + "node_modules/@babel/plugin-syntax-private-property-in-object": { 416 + "version": "7.14.5", 417 + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", 418 + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", 419 + "dev": true, 420 + "license": "MIT", 421 + "dependencies": { 422 + "@babel/helper-plugin-utils": "^7.14.5" 423 + }, 424 + "engines": { 425 + "node": ">=6.9.0" 426 + }, 427 + "peerDependencies": { 428 + "@babel/core": "^7.0.0-0" 429 + } 430 + }, 431 + "node_modules/@babel/plugin-syntax-top-level-await": { 432 + "version": "7.14.5", 433 + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", 434 + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", 435 + "dev": true, 436 + "license": "MIT", 437 + "dependencies": { 438 + "@babel/helper-plugin-utils": "^7.14.5" 439 + }, 440 + "engines": { 441 + "node": ">=6.9.0" 442 + }, 443 + "peerDependencies": { 444 + "@babel/core": "^7.0.0-0" 445 + } 446 + }, 447 + "node_modules/@babel/plugin-syntax-typescript": { 448 + "version": "7.28.6", 449 + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", 450 + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", 451 + "dev": true, 452 + "license": "MIT", 453 + "dependencies": { 454 + "@babel/helper-plugin-utils": "^7.28.6" 455 + }, 456 + "engines": { 457 + "node": ">=6.9.0" 458 + }, 459 + "peerDependencies": { 460 + "@babel/core": "^7.0.0-0" 461 + } 462 + }, 463 + "node_modules/@babel/template": { 464 + "version": "7.28.6", 465 + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", 466 + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", 467 + "dev": true, 468 + "license": "MIT", 469 + "dependencies": { 470 + "@babel/code-frame": "^7.28.6", 471 + "@babel/parser": "^7.28.6", 472 + "@babel/types": "^7.28.6" 473 + }, 474 + "engines": { 475 + "node": ">=6.9.0" 476 + } 477 + }, 478 + "node_modules/@babel/traverse": { 479 + "version": "7.29.0", 480 + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", 481 + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", 482 + "dev": true, 483 + "license": "MIT", 484 + "dependencies": { 485 + "@babel/code-frame": "^7.29.0", 486 + "@babel/generator": "^7.29.0", 487 + "@babel/helper-globals": "^7.28.0", 488 + "@babel/parser": "^7.29.0", 489 + "@babel/template": "^7.28.6", 490 + "@babel/types": "^7.29.0", 491 + "debug": "^4.3.1" 492 + }, 493 + "engines": { 494 + "node": ">=6.9.0" 495 + } 496 + }, 497 + "node_modules/@babel/types": { 498 + "version": "7.29.0", 499 + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", 500 + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", 501 + "dev": true, 502 + "license": "MIT", 503 + "dependencies": { 504 + "@babel/helper-string-parser": "^7.27.1", 505 + "@babel/helper-validator-identifier": "^7.28.5" 506 + }, 507 + "engines": { 508 + "node": ">=6.9.0" 509 + } 510 + }, 511 + "node_modules/@bcoe/v8-coverage": { 512 + "version": "0.2.3", 513 + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", 514 + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", 515 + "dev": true, 516 + "license": "MIT" 517 + }, 518 + "node_modules/@istanbuljs/load-nyc-config": { 519 + "version": "1.1.0", 520 + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", 521 + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", 522 + "dev": true, 523 + "license": "ISC", 524 + "dependencies": { 525 + "camelcase": "^5.3.1", 526 + "find-up": "^4.1.0", 527 + "get-package-type": "^0.1.0", 528 + "js-yaml": "^3.13.1", 529 + "resolve-from": "^5.0.0" 530 + }, 531 + "engines": { 532 + "node": ">=8" 533 + } 534 + }, 535 + "node_modules/@istanbuljs/schema": { 536 + "version": "0.1.3", 537 + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", 538 + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", 539 + "dev": true, 540 + "license": "MIT", 541 + "engines": { 542 + "node": ">=8" 543 + } 544 + }, 545 + "node_modules/@jest/console": { 546 + "version": "29.7.0", 547 + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", 548 + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", 549 + "dev": true, 550 + "license": "MIT", 551 + "dependencies": { 552 + "@jest/types": "^29.6.3", 553 + "@types/node": "*", 554 + "chalk": "^4.0.0", 555 + "jest-message-util": "^29.7.0", 556 + "jest-util": "^29.7.0", 557 + "slash": "^3.0.0" 558 + }, 559 + "engines": { 560 + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 561 + } 562 + }, 563 + "node_modules/@jest/core": { 564 + "version": "29.7.0", 565 + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", 566 + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", 567 + "dev": true, 568 + "license": "MIT", 569 + "dependencies": { 570 + "@jest/console": "^29.7.0", 571 + "@jest/reporters": "^29.7.0", 572 + "@jest/test-result": "^29.7.0", 573 + "@jest/transform": "^29.7.0", 574 + "@jest/types": "^29.6.3", 575 + "@types/node": "*", 576 + "ansi-escapes": "^4.2.1", 577 + "chalk": "^4.0.0", 578 + "ci-info": "^3.2.0", 579 + "exit": "^0.1.2", 580 + "graceful-fs": "^4.2.9", 581 + "jest-changed-files": "^29.7.0", 582 + "jest-config": "^29.7.0", 583 + "jest-haste-map": "^29.7.0", 584 + "jest-message-util": "^29.7.0", 585 + "jest-regex-util": "^29.6.3", 586 + "jest-resolve": "^29.7.0", 587 + "jest-resolve-dependencies": "^29.7.0", 588 + "jest-runner": "^29.7.0", 589 + "jest-runtime": "^29.7.0", 590 + "jest-snapshot": "^29.7.0", 591 + "jest-util": "^29.7.0", 592 + "jest-validate": "^29.7.0", 593 + "jest-watcher": "^29.7.0", 594 + "micromatch": "^4.0.4", 595 + "pretty-format": "^29.7.0", 596 + "slash": "^3.0.0", 597 + "strip-ansi": "^6.0.0" 598 + }, 599 + "engines": { 600 + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 601 + }, 602 + "peerDependencies": { 603 + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" 604 + }, 605 + "peerDependenciesMeta": { 606 + "node-notifier": { 607 + "optional": true 608 + } 609 + } 610 + }, 611 + "node_modules/@jest/environment": { 612 + "version": "29.7.0", 613 + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", 614 + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", 615 + "dev": true, 616 + "license": "MIT", 617 + "dependencies": { 618 + "@jest/fake-timers": "^29.7.0", 619 + "@jest/types": "^29.6.3", 620 + "@types/node": "*", 621 + "jest-mock": "^29.7.0" 622 + }, 623 + "engines": { 624 + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 625 + } 626 + }, 627 + "node_modules/@jest/expect": { 628 + "version": "29.7.0", 629 + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", 630 + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", 631 + "dev": true, 632 + "license": "MIT", 633 + "dependencies": { 634 + "expect": "^29.7.0", 635 + "jest-snapshot": "^29.7.0" 636 + }, 637 + "engines": { 638 + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 639 + } 640 + }, 641 + "node_modules/@jest/expect-utils": { 642 + "version": "29.7.0", 643 + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", 644 + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", 645 + "dev": true, 646 + "license": "MIT", 647 + "dependencies": { 648 + "jest-get-type": "^29.6.3" 649 + }, 650 + "engines": { 651 + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 652 + } 653 + }, 654 + "node_modules/@jest/fake-timers": { 655 + "version": "29.7.0", 656 + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", 657 + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", 658 + "dev": true, 659 + "license": "MIT", 660 + "dependencies": { 661 + "@jest/types": "^29.6.3", 662 + "@sinonjs/fake-timers": "^10.0.2", 663 + "@types/node": "*", 664 + "jest-message-util": "^29.7.0", 665 + "jest-mock": "^29.7.0", 666 + "jest-util": "^29.7.0" 667 + }, 668 + "engines": { 669 + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 670 + } 671 + }, 672 + "node_modules/@jest/globals": { 673 + "version": "29.7.0", 674 + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", 675 + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", 676 + "dev": true, 677 + "license": "MIT", 678 + "dependencies": { 679 + "@jest/environment": "^29.7.0", 680 + "@jest/expect": "^29.7.0", 681 + "@jest/types": "^29.6.3", 682 + "jest-mock": "^29.7.0" 683 + }, 684 + "engines": { 685 + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 686 + } 687 + }, 688 + "node_modules/@jest/reporters": { 689 + "version": "29.7.0", 690 + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", 691 + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", 692 + "dev": true, 693 + "license": "MIT", 694 + "dependencies": { 695 + "@bcoe/v8-coverage": "^0.2.3", 696 + "@jest/console": "^29.7.0", 697 + "@jest/test-result": "^29.7.0", 698 + "@jest/transform": "^29.7.0", 699 + "@jest/types": "^29.6.3", 700 + "@jridgewell/trace-mapping": "^0.3.18", 701 + "@types/node": "*", 702 + "chalk": "^4.0.0", 703 + "collect-v8-coverage": "^1.0.0", 704 + "exit": "^0.1.2", 705 + "glob": "^7.1.3", 706 + "graceful-fs": "^4.2.9", 707 + "istanbul-lib-coverage": "^3.0.0", 708 + "istanbul-lib-instrument": "^6.0.0", 709 + "istanbul-lib-report": "^3.0.0", 710 + "istanbul-lib-source-maps": "^4.0.0", 711 + "istanbul-reports": "^3.1.3", 712 + "jest-message-util": "^29.7.0", 713 + "jest-util": "^29.7.0", 714 + "jest-worker": "^29.7.0", 715 + "slash": "^3.0.0", 716 + "string-length": "^4.0.1", 717 + "strip-ansi": "^6.0.0", 718 + "v8-to-istanbul": "^9.0.1" 719 + }, 720 + "engines": { 721 + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 722 + }, 723 + "peerDependencies": { 724 + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" 725 + }, 726 + "peerDependenciesMeta": { 727 + "node-notifier": { 728 + "optional": true 729 + } 730 + } 731 + }, 732 + "node_modules/@jest/schemas": { 733 + "version": "29.6.3", 734 + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", 735 + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", 736 + "dev": true, 737 + "license": "MIT", 738 + "dependencies": { 739 + "@sinclair/typebox": "^0.27.8" 740 + }, 741 + "engines": { 742 + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 743 + } 744 + }, 745 + "node_modules/@jest/source-map": { 746 + "version": "29.6.3", 747 + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", 748 + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", 749 + "dev": true, 750 + "license": "MIT", 751 + "dependencies": { 752 + "@jridgewell/trace-mapping": "^0.3.18", 753 + "callsites": "^3.0.0", 754 + "graceful-fs": "^4.2.9" 755 + }, 756 + "engines": { 757 + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 758 + } 759 + }, 760 + "node_modules/@jest/test-result": { 761 + "version": "29.7.0", 762 + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", 763 + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", 764 + "dev": true, 765 + "license": "MIT", 766 + "dependencies": { 767 + "@jest/console": "^29.7.0", 768 + "@jest/types": "^29.6.3", 769 + "@types/istanbul-lib-coverage": "^2.0.0", 770 + "collect-v8-coverage": "^1.0.0" 771 + }, 772 + "engines": { 773 + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 774 + } 775 + }, 776 + "node_modules/@jest/test-sequencer": { 777 + "version": "29.7.0", 778 + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", 779 + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", 780 + "dev": true, 781 + "license": "MIT", 782 + "dependencies": { 783 + "@jest/test-result": "^29.7.0", 784 + "graceful-fs": "^4.2.9", 785 + "jest-haste-map": "^29.7.0", 786 + "slash": "^3.0.0" 787 + }, 788 + "engines": { 789 + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 790 + } 791 + }, 792 + "node_modules/@jest/transform": { 793 + "version": "29.7.0", 794 + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", 795 + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", 796 + "dev": true, 797 + "license": "MIT", 798 + "dependencies": { 799 + "@babel/core": "^7.11.6", 800 + "@jest/types": "^29.6.3", 801 + "@jridgewell/trace-mapping": "^0.3.18", 802 + "babel-plugin-istanbul": "^6.1.1", 803 + "chalk": "^4.0.0", 804 + "convert-source-map": "^2.0.0", 805 + "fast-json-stable-stringify": "^2.1.0", 806 + "graceful-fs": "^4.2.9", 807 + "jest-haste-map": "^29.7.0", 808 + "jest-regex-util": "^29.6.3", 809 + "jest-util": "^29.7.0", 810 + "micromatch": "^4.0.4", 811 + "pirates": "^4.0.4", 812 + "slash": "^3.0.0", 813 + "write-file-atomic": "^4.0.2" 814 + }, 815 + "engines": { 816 + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 817 + } 818 + }, 819 + "node_modules/@jest/types": { 820 + "version": "29.6.3", 821 + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", 822 + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", 823 + "dev": true, 824 + "license": "MIT", 825 + "dependencies": { 826 + "@jest/schemas": "^29.6.3", 827 + "@types/istanbul-lib-coverage": "^2.0.0", 828 + "@types/istanbul-reports": "^3.0.0", 829 + "@types/node": "*", 830 + "@types/yargs": "^17.0.8", 831 + "chalk": "^4.0.0" 832 + }, 833 + "engines": { 834 + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 835 + } 836 + }, 837 + "node_modules/@jridgewell/gen-mapping": { 838 + "version": "0.3.13", 839 + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", 840 + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", 841 + "dev": true, 842 + "license": "MIT", 843 + "dependencies": { 844 + "@jridgewell/sourcemap-codec": "^1.5.0", 845 + "@jridgewell/trace-mapping": "^0.3.24" 846 + } 847 + }, 848 + "node_modules/@jridgewell/remapping": { 849 + "version": "2.3.5", 850 + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", 851 + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", 852 + "dev": true, 853 + "license": "MIT", 854 + "dependencies": { 855 + "@jridgewell/gen-mapping": "^0.3.5", 856 + "@jridgewell/trace-mapping": "^0.3.24" 857 + } 858 + }, 859 + "node_modules/@jridgewell/resolve-uri": { 860 + "version": "3.1.2", 861 + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", 862 + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", 863 + "dev": true, 864 + "license": "MIT", 865 + "engines": { 866 + "node": ">=6.0.0" 867 + } 868 + }, 869 + "node_modules/@jridgewell/sourcemap-codec": { 870 + "version": "1.5.5", 871 + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", 872 + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", 873 + "dev": true, 874 + "license": "MIT" 875 + }, 876 + "node_modules/@jridgewell/trace-mapping": { 877 + "version": "0.3.31", 878 + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", 879 + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", 880 + "dev": true, 881 + "license": "MIT", 882 + "dependencies": { 883 + "@jridgewell/resolve-uri": "^3.1.0", 884 + "@jridgewell/sourcemap-codec": "^1.4.14" 885 + } 886 + }, 887 + "node_modules/@sinclair/typebox": { 888 + "version": "0.27.10", 889 + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", 890 + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", 891 + "dev": true, 892 + "license": "MIT" 893 + }, 894 + "node_modules/@sinonjs/commons": { 895 + "version": "3.0.1", 896 + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", 897 + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", 898 + "dev": true, 899 + "license": "BSD-3-Clause", 900 + "dependencies": { 901 + "type-detect": "4.0.8" 902 + } 903 + }, 904 + "node_modules/@sinonjs/fake-timers": { 905 + "version": "10.3.0", 906 + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", 907 + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", 908 + "dev": true, 909 + "license": "BSD-3-Clause", 910 + "dependencies": { 911 + "@sinonjs/commons": "^3.0.0" 912 + } 913 + }, 914 + "node_modules/@types/babel__core": { 915 + "version": "7.20.5", 916 + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", 917 + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", 918 + "dev": true, 919 + "license": "MIT", 920 + "dependencies": { 921 + "@babel/parser": "^7.20.7", 922 + "@babel/types": "^7.20.7", 923 + "@types/babel__generator": "*", 924 + "@types/babel__template": "*", 925 + "@types/babel__traverse": "*" 926 + } 927 + }, 928 + "node_modules/@types/babel__generator": { 929 + "version": "7.27.0", 930 + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", 931 + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", 932 + "dev": true, 933 + "license": "MIT", 934 + "dependencies": { 935 + "@babel/types": "^7.0.0" 936 + } 937 + }, 938 + "node_modules/@types/babel__template": { 939 + "version": "7.4.4", 940 + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", 941 + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", 942 + "dev": true, 943 + "license": "MIT", 944 + "dependencies": { 945 + "@babel/parser": "^7.1.0", 946 + "@babel/types": "^7.0.0" 947 + } 948 + }, 949 + "node_modules/@types/babel__traverse": { 950 + "version": "7.28.0", 951 + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", 952 + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", 953 + "dev": true, 954 + "license": "MIT", 955 + "dependencies": { 956 + "@babel/types": "^7.28.2" 957 + } 958 + }, 959 + "node_modules/@types/graceful-fs": { 960 + "version": "4.1.9", 961 + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", 962 + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", 963 + "dev": true, 964 + "license": "MIT", 965 + "dependencies": { 966 + "@types/node": "*" 967 + } 968 + }, 969 + "node_modules/@types/istanbul-lib-coverage": { 970 + "version": "2.0.6", 971 + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", 972 + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", 973 + "dev": true, 974 + "license": "MIT" 975 + }, 976 + "node_modules/@types/istanbul-lib-report": { 977 + "version": "3.0.3", 978 + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", 979 + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", 980 + "dev": true, 981 + "license": "MIT", 982 + "dependencies": { 983 + "@types/istanbul-lib-coverage": "*" 984 + } 985 + }, 986 + "node_modules/@types/istanbul-reports": { 987 + "version": "3.0.4", 988 + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", 989 + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", 990 + "dev": true, 991 + "license": "MIT", 992 + "dependencies": { 993 + "@types/istanbul-lib-report": "*" 994 + } 995 + }, 996 + "node_modules/@types/jest": { 997 + "version": "29.5.14", 998 + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", 999 + "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", 1000 + "dev": true, 1001 + "license": "MIT", 1002 + "dependencies": { 1003 + "expect": "^29.0.0", 1004 + "pretty-format": "^29.0.0" 1005 + } 1006 + }, 1007 + "node_modules/@types/luxon": { 1008 + "version": "3.7.1", 1009 + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.1.tgz", 1010 + "integrity": "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==", 1011 + "dev": true, 1012 + "license": "MIT" 1013 + }, 1014 + "node_modules/@types/node": { 1015 + "version": "25.3.3", 1016 + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz", 1017 + "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==", 1018 + "dev": true, 1019 + "license": "MIT", 1020 + "dependencies": { 1021 + "undici-types": "~7.18.0" 1022 + } 1023 + }, 1024 + "node_modules/@types/stack-utils": { 1025 + "version": "2.0.3", 1026 + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", 1027 + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", 1028 + "dev": true, 1029 + "license": "MIT" 1030 + }, 1031 + "node_modules/@types/yargs": { 1032 + "version": "17.0.35", 1033 + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", 1034 + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", 1035 + "dev": true, 1036 + "license": "MIT", 1037 + "dependencies": { 1038 + "@types/yargs-parser": "*" 1039 + } 1040 + }, 1041 + "node_modules/@types/yargs-parser": { 1042 + "version": "21.0.3", 1043 + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", 1044 + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", 1045 + "dev": true, 1046 + "license": "MIT" 1047 + }, 1048 + "node_modules/ansi-escapes": { 1049 + "version": "4.3.2", 1050 + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", 1051 + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", 1052 + "dev": true, 1053 + "license": "MIT", 1054 + "dependencies": { 1055 + "type-fest": "^0.21.3" 1056 + }, 1057 + "engines": { 1058 + "node": ">=8" 1059 + }, 1060 + "funding": { 1061 + "url": "https://github.com/sponsors/sindresorhus" 1062 + } 1063 + }, 1064 + "node_modules/ansi-regex": { 1065 + "version": "5.0.1", 1066 + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", 1067 + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", 1068 + "dev": true, 1069 + "license": "MIT", 1070 + "engines": { 1071 + "node": ">=8" 1072 + } 1073 + }, 1074 + "node_modules/ansi-styles": { 1075 + "version": "4.3.0", 1076 + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", 1077 + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", 1078 + "dev": true, 1079 + "license": "MIT", 1080 + "dependencies": { 1081 + "color-convert": "^2.0.1" 1082 + }, 1083 + "engines": { 1084 + "node": ">=8" 1085 + }, 1086 + "funding": { 1087 + "url": "https://github.com/chalk/ansi-styles?sponsor=1" 1088 + } 1089 + }, 1090 + "node_modules/anymatch": { 1091 + "version": "3.1.3", 1092 + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", 1093 + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", 1094 + "dev": true, 1095 + "license": "ISC", 1096 + "dependencies": { 1097 + "normalize-path": "^3.0.0", 1098 + "picomatch": "^2.0.4" 1099 + }, 1100 + "engines": { 1101 + "node": ">= 8" 1102 + } 1103 + }, 1104 + "node_modules/argparse": { 1105 + "version": "1.0.10", 1106 + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", 1107 + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", 1108 + "dev": true, 1109 + "license": "MIT", 1110 + "dependencies": { 1111 + "sprintf-js": "~1.0.2" 1112 + } 1113 + }, 1114 + "node_modules/babel-jest": { 1115 + "version": "29.7.0", 1116 + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", 1117 + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", 1118 + "dev": true, 1119 + "license": "MIT", 1120 + "dependencies": { 1121 + "@jest/transform": "^29.7.0", 1122 + "@types/babel__core": "^7.1.14", 1123 + "babel-plugin-istanbul": "^6.1.1", 1124 + "babel-preset-jest": "^29.6.3", 1125 + "chalk": "^4.0.0", 1126 + "graceful-fs": "^4.2.9", 1127 + "slash": "^3.0.0" 1128 + }, 1129 + "engines": { 1130 + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 1131 + }, 1132 + "peerDependencies": { 1133 + "@babel/core": "^7.8.0" 1134 + } 1135 + }, 1136 + "node_modules/babel-plugin-istanbul": { 1137 + "version": "6.1.1", 1138 + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", 1139 + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", 1140 + "dev": true, 1141 + "license": "BSD-3-Clause", 1142 + "dependencies": { 1143 + "@babel/helper-plugin-utils": "^7.0.0", 1144 + "@istanbuljs/load-nyc-config": "^1.0.0", 1145 + "@istanbuljs/schema": "^0.1.2", 1146 + "istanbul-lib-instrument": "^5.0.4", 1147 + "test-exclude": "^6.0.0" 1148 + }, 1149 + "engines": { 1150 + "node": ">=8" 1151 + } 1152 + }, 1153 + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { 1154 + "version": "5.2.1", 1155 + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", 1156 + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", 1157 + "dev": true, 1158 + "license": "BSD-3-Clause", 1159 + "dependencies": { 1160 + "@babel/core": "^7.12.3", 1161 + "@babel/parser": "^7.14.7", 1162 + "@istanbuljs/schema": "^0.1.2", 1163 + "istanbul-lib-coverage": "^3.2.0", 1164 + "semver": "^6.3.0" 1165 + }, 1166 + "engines": { 1167 + "node": ">=8" 1168 + } 1169 + }, 1170 + "node_modules/babel-plugin-jest-hoist": { 1171 + "version": "29.6.3", 1172 + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", 1173 + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", 1174 + "dev": true, 1175 + "license": "MIT", 1176 + "dependencies": { 1177 + "@babel/template": "^7.3.3", 1178 + "@babel/types": "^7.3.3", 1179 + "@types/babel__core": "^7.1.14", 1180 + "@types/babel__traverse": "^7.0.6" 1181 + }, 1182 + "engines": { 1183 + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 1184 + } 1185 + }, 1186 + "node_modules/babel-preset-current-node-syntax": { 1187 + "version": "1.2.0", 1188 + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", 1189 + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", 1190 + "dev": true, 1191 + "license": "MIT", 1192 + "dependencies": { 1193 + "@babel/plugin-syntax-async-generators": "^7.8.4", 1194 + "@babel/plugin-syntax-bigint": "^7.8.3", 1195 + "@babel/plugin-syntax-class-properties": "^7.12.13", 1196 + "@babel/plugin-syntax-class-static-block": "^7.14.5", 1197 + "@babel/plugin-syntax-import-attributes": "^7.24.7", 1198 + "@babel/plugin-syntax-import-meta": "^7.10.4", 1199 + "@babel/plugin-syntax-json-strings": "^7.8.3", 1200 + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", 1201 + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", 1202 + "@babel/plugin-syntax-numeric-separator": "^7.10.4", 1203 + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", 1204 + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", 1205 + "@babel/plugin-syntax-optional-chaining": "^7.8.3", 1206 + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", 1207 + "@babel/plugin-syntax-top-level-await": "^7.14.5" 1208 + }, 1209 + "peerDependencies": { 1210 + "@babel/core": "^7.0.0 || ^8.0.0-0" 1211 + } 1212 + }, 1213 + "node_modules/babel-preset-jest": { 1214 + "version": "29.6.3", 1215 + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", 1216 + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", 1217 + "dev": true, 1218 + "license": "MIT", 1219 + "dependencies": { 1220 + "babel-plugin-jest-hoist": "^29.6.3", 1221 + "babel-preset-current-node-syntax": "^1.0.0" 1222 + }, 1223 + "engines": { 1224 + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 1225 + }, 1226 + "peerDependencies": { 1227 + "@babel/core": "^7.0.0" 1228 + } 1229 + }, 1230 + "node_modules/balanced-match": { 1231 + "version": "1.0.2", 1232 + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", 1233 + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", 1234 + "dev": true, 1235 + "license": "MIT" 1236 + }, 1237 + "node_modules/baseline-browser-mapping": { 1238 + "version": "2.10.0", 1239 + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", 1240 + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", 1241 + "dev": true, 1242 + "license": "Apache-2.0", 1243 + "bin": { 1244 + "baseline-browser-mapping": "dist/cli.cjs" 1245 + }, 1246 + "engines": { 1247 + "node": ">=6.0.0" 1248 + } 1249 + }, 1250 + "node_modules/brace-expansion": { 1251 + "version": "1.1.12", 1252 + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", 1253 + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", 1254 + "dev": true, 1255 + "license": "MIT", 1256 + "dependencies": { 1257 + "balanced-match": "^1.0.0", 1258 + "concat-map": "0.0.1" 1259 + } 1260 + }, 1261 + "node_modules/braces": { 1262 + "version": "3.0.3", 1263 + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", 1264 + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", 1265 + "dev": true, 1266 + "license": "MIT", 1267 + "dependencies": { 1268 + "fill-range": "^7.1.1" 1269 + }, 1270 + "engines": { 1271 + "node": ">=8" 1272 + } 1273 + }, 1274 + "node_modules/browserslist": { 1275 + "version": "4.28.1", 1276 + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", 1277 + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", 1278 + "dev": true, 1279 + "funding": [ 1280 + { 1281 + "type": "opencollective", 1282 + "url": "https://opencollective.com/browserslist" 1283 + }, 1284 + { 1285 + "type": "tidelift", 1286 + "url": "https://tidelift.com/funding/github/npm/browserslist" 1287 + }, 1288 + { 1289 + "type": "github", 1290 + "url": "https://github.com/sponsors/ai" 1291 + } 1292 + ], 1293 + "license": "MIT", 1294 + "peer": true, 1295 + "dependencies": { 1296 + "baseline-browser-mapping": "^2.9.0", 1297 + "caniuse-lite": "^1.0.30001759", 1298 + "electron-to-chromium": "^1.5.263", 1299 + "node-releases": "^2.0.27", 1300 + "update-browserslist-db": "^1.2.0" 1301 + }, 1302 + "bin": { 1303 + "browserslist": "cli.js" 1304 + }, 1305 + "engines": { 1306 + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" 1307 + } 1308 + }, 1309 + "node_modules/bs-logger": { 1310 + "version": "0.2.6", 1311 + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", 1312 + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", 1313 + "dev": true, 1314 + "license": "MIT", 1315 + "dependencies": { 1316 + "fast-json-stable-stringify": "2.x" 1317 + }, 1318 + "engines": { 1319 + "node": ">= 6" 1320 + } 1321 + }, 1322 + "node_modules/bser": { 1323 + "version": "2.1.1", 1324 + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", 1325 + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", 1326 + "dev": true, 1327 + "license": "Apache-2.0", 1328 + "dependencies": { 1329 + "node-int64": "^0.4.0" 1330 + } 1331 + }, 1332 + "node_modules/buffer-from": { 1333 + "version": "1.1.2", 1334 + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", 1335 + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", 1336 + "dev": true, 1337 + "license": "MIT" 1338 + }, 1339 + "node_modules/callsites": { 1340 + "version": "3.1.0", 1341 + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", 1342 + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", 1343 + "dev": true, 1344 + "license": "MIT", 1345 + "engines": { 1346 + "node": ">=6" 1347 + } 1348 + }, 1349 + "node_modules/camelcase": { 1350 + "version": "5.3.1", 1351 + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", 1352 + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", 1353 + "dev": true, 1354 + "license": "MIT", 1355 + "engines": { 1356 + "node": ">=6" 1357 + } 1358 + }, 1359 + "node_modules/caniuse-lite": { 1360 + "version": "1.0.30001775", 1361 + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001775.tgz", 1362 + "integrity": "sha512-s3Qv7Lht9zbVKE9XoTyRG6wVDCKdtOFIjBGg3+Yhn6JaytuNKPIjBMTMIY1AnOH3seL5mvF+x33oGAyK3hVt3A==", 1363 + "dev": true, 1364 + "funding": [ 1365 + { 1366 + "type": "opencollective", 1367 + "url": "https://opencollective.com/browserslist" 1368 + }, 1369 + { 1370 + "type": "tidelift", 1371 + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" 1372 + }, 1373 + { 1374 + "type": "github", 1375 + "url": "https://github.com/sponsors/ai" 1376 + } 1377 + ], 1378 + "license": "CC-BY-4.0" 1379 + }, 1380 + "node_modules/chalk": { 1381 + "version": "4.1.2", 1382 + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", 1383 + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", 1384 + "dev": true, 1385 + "license": "MIT", 1386 + "dependencies": { 1387 + "ansi-styles": "^4.1.0", 1388 + "supports-color": "^7.1.0" 1389 + }, 1390 + "engines": { 1391 + "node": ">=10" 1392 + }, 1393 + "funding": { 1394 + "url": "https://github.com/chalk/chalk?sponsor=1" 1395 + } 1396 + }, 1397 + "node_modules/char-regex": { 1398 + "version": "1.0.2", 1399 + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", 1400 + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", 1401 + "dev": true, 1402 + "license": "MIT", 1403 + "engines": { 1404 + "node": ">=10" 1405 + } 1406 + }, 1407 + "node_modules/ci-info": { 1408 + "version": "3.9.0", 1409 + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", 1410 + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", 1411 + "dev": true, 1412 + "funding": [ 1413 + { 1414 + "type": "github", 1415 + "url": "https://github.com/sponsors/sibiraj-s" 1416 + } 1417 + ], 1418 + "license": "MIT", 1419 + "engines": { 1420 + "node": ">=8" 1421 + } 1422 + }, 1423 + "node_modules/cjs-module-lexer": { 1424 + "version": "1.4.3", 1425 + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", 1426 + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", 1427 + "dev": true, 1428 + "license": "MIT" 1429 + }, 1430 + "node_modules/cliui": { 1431 + "version": "8.0.1", 1432 + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", 1433 + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", 1434 + "dev": true, 1435 + "license": "ISC", 1436 + "dependencies": { 1437 + "string-width": "^4.2.0", 1438 + "strip-ansi": "^6.0.1", 1439 + "wrap-ansi": "^7.0.0" 1440 + }, 1441 + "engines": { 1442 + "node": ">=12" 1443 + } 1444 + }, 1445 + "node_modules/co": { 1446 + "version": "4.6.0", 1447 + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", 1448 + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", 1449 + "dev": true, 1450 + "license": "MIT", 1451 + "engines": { 1452 + "iojs": ">= 1.0.0", 1453 + "node": ">= 0.12.0" 1454 + } 1455 + }, 1456 + "node_modules/collect-v8-coverage": { 1457 + "version": "1.0.3", 1458 + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", 1459 + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", 1460 + "dev": true, 1461 + "license": "MIT" 1462 + }, 1463 + "node_modules/color-convert": { 1464 + "version": "2.0.1", 1465 + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", 1466 + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", 1467 + "dev": true, 1468 + "license": "MIT", 1469 + "dependencies": { 1470 + "color-name": "~1.1.4" 1471 + }, 1472 + "engines": { 1473 + "node": ">=7.0.0" 1474 + } 1475 + }, 1476 + "node_modules/color-name": { 1477 + "version": "1.1.4", 1478 + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", 1479 + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", 1480 + "dev": true, 1481 + "license": "MIT" 1482 + }, 1483 + "node_modules/concat-map": { 1484 + "version": "0.0.1", 1485 + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", 1486 + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", 1487 + "dev": true, 1488 + "license": "MIT" 1489 + }, 1490 + "node_modules/convert-source-map": { 1491 + "version": "2.0.0", 1492 + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", 1493 + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", 1494 + "dev": true, 1495 + "license": "MIT" 1496 + }, 1497 + "node_modules/create-jest": { 1498 + "version": "29.7.0", 1499 + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", 1500 + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", 1501 + "dev": true, 1502 + "license": "MIT", 1503 + "dependencies": { 1504 + "@jest/types": "^29.6.3", 1505 + "chalk": "^4.0.0", 1506 + "exit": "^0.1.2", 1507 + "graceful-fs": "^4.2.9", 1508 + "jest-config": "^29.7.0", 1509 + "jest-util": "^29.7.0", 1510 + "prompts": "^2.0.1" 1511 + }, 1512 + "bin": { 1513 + "create-jest": "bin/create-jest.js" 1514 + }, 1515 + "engines": { 1516 + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 1517 + } 1518 + }, 1519 + "node_modules/cross-spawn": { 1520 + "version": "7.0.6", 1521 + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", 1522 + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", 1523 + "dev": true, 1524 + "license": "MIT", 1525 + "dependencies": { 1526 + "path-key": "^3.1.0", 1527 + "shebang-command": "^2.0.0", 1528 + "which": "^2.0.1" 1529 + }, 1530 + "engines": { 1531 + "node": ">= 8" 1532 + } 1533 + }, 1534 + "node_modules/debug": { 1535 + "version": "4.4.3", 1536 + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", 1537 + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", 1538 + "dev": true, 1539 + "license": "MIT", 1540 + "dependencies": { 1541 + "ms": "^2.1.3" 1542 + }, 1543 + "engines": { 1544 + "node": ">=6.0" 1545 + }, 1546 + "peerDependenciesMeta": { 1547 + "supports-color": { 1548 + "optional": true 1549 + } 1550 + } 1551 + }, 1552 + "node_modules/dedent": { 1553 + "version": "1.7.1", 1554 + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", 1555 + "integrity": "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==", 1556 + "dev": true, 1557 + "license": "MIT", 1558 + "peerDependencies": { 1559 + "babel-plugin-macros": "^3.1.0" 1560 + }, 1561 + "peerDependenciesMeta": { 1562 + "babel-plugin-macros": { 1563 + "optional": true 1564 + } 1565 + } 1566 + }, 1567 + "node_modules/deepmerge": { 1568 + "version": "4.3.1", 1569 + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", 1570 + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", 1571 + "dev": true, 1572 + "license": "MIT", 1573 + "engines": { 1574 + "node": ">=0.10.0" 1575 + } 1576 + }, 1577 + "node_modules/detect-newline": { 1578 + "version": "3.1.0", 1579 + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", 1580 + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", 1581 + "dev": true, 1582 + "license": "MIT", 1583 + "engines": { 1584 + "node": ">=8" 1585 + } 1586 + }, 1587 + "node_modules/diff-sequences": { 1588 + "version": "29.6.3", 1589 + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", 1590 + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", 1591 + "dev": true, 1592 + "license": "MIT", 1593 + "engines": { 1594 + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 1595 + } 1596 + }, 1597 + "node_modules/electron-to-chromium": { 1598 + "version": "1.5.302", 1599 + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", 1600 + "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", 1601 + "dev": true, 1602 + "license": "ISC" 1603 + }, 1604 + "node_modules/emittery": { 1605 + "version": "0.13.1", 1606 + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", 1607 + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", 1608 + "dev": true, 1609 + "license": "MIT", 1610 + "engines": { 1611 + "node": ">=12" 1612 + }, 1613 + "funding": { 1614 + "url": "https://github.com/sindresorhus/emittery?sponsor=1" 1615 + } 1616 + }, 1617 + "node_modules/emoji-regex": { 1618 + "version": "8.0.0", 1619 + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", 1620 + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", 1621 + "dev": true, 1622 + "license": "MIT" 1623 + }, 1624 + "node_modules/error-ex": { 1625 + "version": "1.3.4", 1626 + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", 1627 + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", 1628 + "dev": true, 1629 + "license": "MIT", 1630 + "dependencies": { 1631 + "is-arrayish": "^0.2.1" 1632 + } 1633 + }, 1634 + "node_modules/escalade": { 1635 + "version": "3.2.0", 1636 + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", 1637 + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", 1638 + "dev": true, 1639 + "license": "MIT", 1640 + "engines": { 1641 + "node": ">=6" 1642 + } 1643 + }, 1644 + "node_modules/escape-string-regexp": { 1645 + "version": "2.0.0", 1646 + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", 1647 + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", 1648 + "dev": true, 1649 + "license": "MIT", 1650 + "engines": { 1651 + "node": ">=8" 1652 + } 1653 + }, 1654 + "node_modules/esprima": { 1655 + "version": "4.0.1", 1656 + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", 1657 + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", 1658 + "dev": true, 1659 + "license": "BSD-2-Clause", 1660 + "bin": { 1661 + "esparse": "bin/esparse.js", 1662 + "esvalidate": "bin/esvalidate.js" 1663 + }, 1664 + "engines": { 1665 + "node": ">=4" 1666 + } 1667 + }, 1668 + "node_modules/execa": { 1669 + "version": "5.1.1", 1670 + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", 1671 + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", 1672 + "dev": true, 1673 + "license": "MIT", 1674 + "dependencies": { 1675 + "cross-spawn": "^7.0.3", 1676 + "get-stream": "^6.0.0", 1677 + "human-signals": "^2.1.0", 1678 + "is-stream": "^2.0.0", 1679 + "merge-stream": "^2.0.0", 1680 + "npm-run-path": "^4.0.1", 1681 + "onetime": "^5.1.2", 1682 + "signal-exit": "^3.0.3", 1683 + "strip-final-newline": "^2.0.0" 1684 + }, 1685 + "engines": { 1686 + "node": ">=10" 1687 + }, 1688 + "funding": { 1689 + "url": "https://github.com/sindresorhus/execa?sponsor=1" 1690 + } 1691 + }, 1692 + "node_modules/exit": { 1693 + "version": "0.1.2", 1694 + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", 1695 + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", 1696 + "dev": true, 1697 + "engines": { 1698 + "node": ">= 0.8.0" 1699 + } 1700 + }, 1701 + "node_modules/expect": { 1702 + "version": "29.7.0", 1703 + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", 1704 + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", 1705 + "dev": true, 1706 + "license": "MIT", 1707 + "dependencies": { 1708 + "@jest/expect-utils": "^29.7.0", 1709 + "jest-get-type": "^29.6.3", 1710 + "jest-matcher-utils": "^29.7.0", 1711 + "jest-message-util": "^29.7.0", 1712 + "jest-util": "^29.7.0" 1713 + }, 1714 + "engines": { 1715 + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 1716 + } 1717 + }, 1718 + "node_modules/fast-json-stable-stringify": { 1719 + "version": "2.1.0", 1720 + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", 1721 + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", 1722 + "dev": true, 1723 + "license": "MIT" 1724 + }, 1725 + "node_modules/fb-watchman": { 1726 + "version": "2.0.2", 1727 + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", 1728 + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", 1729 + "dev": true, 1730 + "license": "Apache-2.0", 1731 + "dependencies": { 1732 + "bser": "2.1.1" 1733 + } 1734 + }, 1735 + "node_modules/fill-range": { 1736 + "version": "7.1.1", 1737 + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", 1738 + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", 1739 + "dev": true, 1740 + "license": "MIT", 1741 + "dependencies": { 1742 + "to-regex-range": "^5.0.1" 1743 + }, 1744 + "engines": { 1745 + "node": ">=8" 1746 + } 1747 + }, 1748 + "node_modules/find-up": { 1749 + "version": "4.1.0", 1750 + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", 1751 + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", 1752 + "dev": true, 1753 + "license": "MIT", 1754 + "dependencies": { 1755 + "locate-path": "^5.0.0", 1756 + "path-exists": "^4.0.0" 1757 + }, 1758 + "engines": { 1759 + "node": ">=8" 1760 + } 1761 + }, 1762 + "node_modules/fs.realpath": { 1763 + "version": "1.0.0", 1764 + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", 1765 + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", 1766 + "dev": true, 1767 + "license": "ISC" 1768 + }, 1769 + "node_modules/fsevents": { 1770 + "version": "2.3.3", 1771 + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", 1772 + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", 1773 + "dev": true, 1774 + "hasInstallScript": true, 1775 + "license": "MIT", 1776 + "optional": true, 1777 + "os": [ 1778 + "darwin" 1779 + ], 1780 + "engines": { 1781 + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 1782 + } 1783 + }, 1784 + "node_modules/function-bind": { 1785 + "version": "1.1.2", 1786 + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", 1787 + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", 1788 + "dev": true, 1789 + "license": "MIT", 1790 + "funding": { 1791 + "url": "https://github.com/sponsors/ljharb" 1792 + } 1793 + }, 1794 + "node_modules/gensync": { 1795 + "version": "1.0.0-beta.2", 1796 + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", 1797 + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", 1798 + "dev": true, 1799 + "license": "MIT", 1800 + "engines": { 1801 + "node": ">=6.9.0" 1802 + } 1803 + }, 1804 + "node_modules/get-caller-file": { 1805 + "version": "2.0.5", 1806 + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", 1807 + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", 1808 + "dev": true, 1809 + "license": "ISC", 1810 + "engines": { 1811 + "node": "6.* || 8.* || >= 10.*" 1812 + } 1813 + }, 1814 + "node_modules/get-package-type": { 1815 + "version": "0.1.0", 1816 + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", 1817 + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", 1818 + "dev": true, 1819 + "license": "MIT", 1820 + "engines": { 1821 + "node": ">=8.0.0" 1822 + } 1823 + }, 1824 + "node_modules/get-stream": { 1825 + "version": "6.0.1", 1826 + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", 1827 + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", 1828 + "dev": true, 1829 + "license": "MIT", 1830 + "engines": { 1831 + "node": ">=10" 1832 + }, 1833 + "funding": { 1834 + "url": "https://github.com/sponsors/sindresorhus" 1835 + } 1836 + }, 1837 + "node_modules/glob": { 1838 + "version": "7.2.3", 1839 + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", 1840 + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", 1841 + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", 1842 + "dev": true, 1843 + "license": "ISC", 1844 + "dependencies": { 1845 + "fs.realpath": "^1.0.0", 1846 + "inflight": "^1.0.4", 1847 + "inherits": "2", 1848 + "minimatch": "^3.1.1", 1849 + "once": "^1.3.0", 1850 + "path-is-absolute": "^1.0.0" 1851 + }, 1852 + "engines": { 1853 + "node": "*" 1854 + }, 1855 + "funding": { 1856 + "url": "https://github.com/sponsors/isaacs" 1857 + } 1858 + }, 1859 + "node_modules/graceful-fs": { 1860 + "version": "4.2.11", 1861 + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", 1862 + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", 1863 + "dev": true, 1864 + "license": "ISC" 1865 + }, 1866 + "node_modules/handlebars": { 1867 + "version": "4.7.8", 1868 + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", 1869 + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", 1870 + "dev": true, 1871 + "license": "MIT", 1872 + "dependencies": { 1873 + "minimist": "^1.2.5", 1874 + "neo-async": "^2.6.2", 1875 + "source-map": "^0.6.1", 1876 + "wordwrap": "^1.0.0" 1877 + }, 1878 + "bin": { 1879 + "handlebars": "bin/handlebars" 1880 + }, 1881 + "engines": { 1882 + "node": ">=0.4.7" 1883 + }, 1884 + "optionalDependencies": { 1885 + "uglify-js": "^3.1.4" 1886 + } 1887 + }, 1888 + "node_modules/has-flag": { 1889 + "version": "4.0.0", 1890 + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", 1891 + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", 1892 + "dev": true, 1893 + "license": "MIT", 1894 + "engines": { 1895 + "node": ">=8" 1896 + } 1897 + }, 1898 + "node_modules/hasown": { 1899 + "version": "2.0.2", 1900 + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", 1901 + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", 1902 + "dev": true, 1903 + "license": "MIT", 1904 + "dependencies": { 1905 + "function-bind": "^1.1.2" 1906 + }, 1907 + "engines": { 1908 + "node": ">= 0.4" 1909 + } 1910 + }, 1911 + "node_modules/html-escaper": { 1912 + "version": "2.0.2", 1913 + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", 1914 + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", 1915 + "dev": true, 1916 + "license": "MIT" 1917 + }, 1918 + "node_modules/human-signals": { 1919 + "version": "2.1.0", 1920 + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", 1921 + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", 1922 + "dev": true, 1923 + "license": "Apache-2.0", 1924 + "engines": { 1925 + "node": ">=10.17.0" 1926 + } 1927 + }, 1928 + "node_modules/import-local": { 1929 + "version": "3.2.0", 1930 + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", 1931 + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", 1932 + "dev": true, 1933 + "license": "MIT", 1934 + "dependencies": { 1935 + "pkg-dir": "^4.2.0", 1936 + "resolve-cwd": "^3.0.0" 1937 + }, 1938 + "bin": { 1939 + "import-local-fixture": "fixtures/cli.js" 1940 + }, 1941 + "engines": { 1942 + "node": ">=8" 1943 + }, 1944 + "funding": { 1945 + "url": "https://github.com/sponsors/sindresorhus" 1946 + } 1947 + }, 1948 + "node_modules/imurmurhash": { 1949 + "version": "0.1.4", 1950 + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", 1951 + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", 1952 + "dev": true, 1953 + "license": "MIT", 1954 + "engines": { 1955 + "node": ">=0.8.19" 1956 + } 1957 + }, 1958 + "node_modules/inflight": { 1959 + "version": "1.0.6", 1960 + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", 1961 + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", 1962 + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", 1963 + "dev": true, 1964 + "license": "ISC", 1965 + "dependencies": { 1966 + "once": "^1.3.0", 1967 + "wrappy": "1" 1968 + } 1969 + }, 1970 + "node_modules/inherits": { 1971 + "version": "2.0.4", 1972 + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", 1973 + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", 1974 + "dev": true, 1975 + "license": "ISC" 1976 + }, 1977 + "node_modules/is-arrayish": { 1978 + "version": "0.2.1", 1979 + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", 1980 + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", 1981 + "dev": true, 1982 + "license": "MIT" 1983 + }, 1984 + "node_modules/is-core-module": { 1985 + "version": "2.16.1", 1986 + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", 1987 + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", 1988 + "dev": true, 1989 + "license": "MIT", 1990 + "dependencies": { 1991 + "hasown": "^2.0.2" 1992 + }, 1993 + "engines": { 1994 + "node": ">= 0.4" 1995 + }, 1996 + "funding": { 1997 + "url": "https://github.com/sponsors/ljharb" 1998 + } 1999 + }, 2000 + "node_modules/is-fullwidth-code-point": { 2001 + "version": "3.0.0", 2002 + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", 2003 + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", 2004 + "dev": true, 2005 + "license": "MIT", 2006 + "engines": { 2007 + "node": ">=8" 2008 + } 2009 + }, 2010 + "node_modules/is-generator-fn": { 2011 + "version": "2.1.0", 2012 + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", 2013 + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", 2014 + "dev": true, 2015 + "license": "MIT", 2016 + "engines": { 2017 + "node": ">=6" 2018 + } 2019 + }, 2020 + "node_modules/is-number": { 2021 + "version": "7.0.0", 2022 + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", 2023 + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", 2024 + "dev": true, 2025 + "license": "MIT", 2026 + "engines": { 2027 + "node": ">=0.12.0" 2028 + } 2029 + }, 2030 + "node_modules/is-stream": { 2031 + "version": "2.0.1", 2032 + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", 2033 + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", 2034 + "dev": true, 2035 + "license": "MIT", 2036 + "engines": { 2037 + "node": ">=8" 2038 + }, 2039 + "funding": { 2040 + "url": "https://github.com/sponsors/sindresorhus" 2041 + } 2042 + }, 2043 + "node_modules/isexe": { 2044 + "version": "2.0.0", 2045 + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", 2046 + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", 2047 + "dev": true, 2048 + "license": "ISC" 2049 + }, 2050 + "node_modules/istanbul-lib-coverage": { 2051 + "version": "3.2.2", 2052 + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", 2053 + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", 2054 + "dev": true, 2055 + "license": "BSD-3-Clause", 2056 + "engines": { 2057 + "node": ">=8" 2058 + } 2059 + }, 2060 + "node_modules/istanbul-lib-instrument": { 2061 + "version": "6.0.3", 2062 + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", 2063 + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", 2064 + "dev": true, 2065 + "license": "BSD-3-Clause", 2066 + "dependencies": { 2067 + "@babel/core": "^7.23.9", 2068 + "@babel/parser": "^7.23.9", 2069 + "@istanbuljs/schema": "^0.1.3", 2070 + "istanbul-lib-coverage": "^3.2.0", 2071 + "semver": "^7.5.4" 2072 + }, 2073 + "engines": { 2074 + "node": ">=10" 2075 + } 2076 + }, 2077 + "node_modules/istanbul-lib-instrument/node_modules/semver": { 2078 + "version": "7.7.4", 2079 + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", 2080 + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", 2081 + "dev": true, 2082 + "license": "ISC", 2083 + "bin": { 2084 + "semver": "bin/semver.js" 2085 + }, 2086 + "engines": { 2087 + "node": ">=10" 2088 + } 2089 + }, 2090 + "node_modules/istanbul-lib-report": { 2091 + "version": "3.0.1", 2092 + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", 2093 + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", 2094 + "dev": true, 2095 + "license": "BSD-3-Clause", 2096 + "dependencies": { 2097 + "istanbul-lib-coverage": "^3.0.0", 2098 + "make-dir": "^4.0.0", 2099 + "supports-color": "^7.1.0" 2100 + }, 2101 + "engines": { 2102 + "node": ">=10" 2103 + } 2104 + }, 2105 + "node_modules/istanbul-lib-source-maps": { 2106 + "version": "4.0.1", 2107 + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", 2108 + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", 2109 + "dev": true, 2110 + "license": "BSD-3-Clause", 2111 + "dependencies": { 2112 + "debug": "^4.1.1", 2113 + "istanbul-lib-coverage": "^3.0.0", 2114 + "source-map": "^0.6.1" 2115 + }, 2116 + "engines": { 2117 + "node": ">=10" 2118 + } 2119 + }, 2120 + "node_modules/istanbul-reports": { 2121 + "version": "3.2.0", 2122 + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", 2123 + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", 2124 + "dev": true, 2125 + "license": "BSD-3-Clause", 2126 + "dependencies": { 2127 + "html-escaper": "^2.0.0", 2128 + "istanbul-lib-report": "^3.0.0" 2129 + }, 2130 + "engines": { 2131 + "node": ">=8" 2132 + } 2133 + }, 2134 + "node_modules/jest": { 2135 + "version": "29.7.0", 2136 + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", 2137 + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", 2138 + "dev": true, 2139 + "license": "MIT", 2140 + "peer": true, 2141 + "dependencies": { 2142 + "@jest/core": "^29.7.0", 2143 + "@jest/types": "^29.6.3", 2144 + "import-local": "^3.0.2", 2145 + "jest-cli": "^29.7.0" 2146 + }, 2147 + "bin": { 2148 + "jest": "bin/jest.js" 2149 + }, 2150 + "engines": { 2151 + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 2152 + }, 2153 + "peerDependencies": { 2154 + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" 2155 + }, 2156 + "peerDependenciesMeta": { 2157 + "node-notifier": { 2158 + "optional": true 2159 + } 2160 + } 2161 + }, 2162 + "node_modules/jest-changed-files": { 2163 + "version": "29.7.0", 2164 + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", 2165 + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", 2166 + "dev": true, 2167 + "license": "MIT", 2168 + "dependencies": { 2169 + "execa": "^5.0.0", 2170 + "jest-util": "^29.7.0", 2171 + "p-limit": "^3.1.0" 2172 + }, 2173 + "engines": { 2174 + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 2175 + } 2176 + }, 2177 + "node_modules/jest-circus": { 2178 + "version": "29.7.0", 2179 + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", 2180 + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", 2181 + "dev": true, 2182 + "license": "MIT", 2183 + "dependencies": { 2184 + "@jest/environment": "^29.7.0", 2185 + "@jest/expect": "^29.7.0", 2186 + "@jest/test-result": "^29.7.0", 2187 + "@jest/types": "^29.6.3", 2188 + "@types/node": "*", 2189 + "chalk": "^4.0.0", 2190 + "co": "^4.6.0", 2191 + "dedent": "^1.0.0", 2192 + "is-generator-fn": "^2.0.0", 2193 + "jest-each": "^29.7.0", 2194 + "jest-matcher-utils": "^29.7.0", 2195 + "jest-message-util": "^29.7.0", 2196 + "jest-runtime": "^29.7.0", 2197 + "jest-snapshot": "^29.7.0", 2198 + "jest-util": "^29.7.0", 2199 + "p-limit": "^3.1.0", 2200 + "pretty-format": "^29.7.0", 2201 + "pure-rand": "^6.0.0", 2202 + "slash": "^3.0.0", 2203 + "stack-utils": "^2.0.3" 2204 + }, 2205 + "engines": { 2206 + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 2207 + } 2208 + }, 2209 + "node_modules/jest-cli": { 2210 + "version": "29.7.0", 2211 + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", 2212 + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", 2213 + "dev": true, 2214 + "license": "MIT", 2215 + "dependencies": { 2216 + "@jest/core": "^29.7.0", 2217 + "@jest/test-result": "^29.7.0", 2218 + "@jest/types": "^29.6.3", 2219 + "chalk": "^4.0.0", 2220 + "create-jest": "^29.7.0", 2221 + "exit": "^0.1.2", 2222 + "import-local": "^3.0.2", 2223 + "jest-config": "^29.7.0", 2224 + "jest-util": "^29.7.0", 2225 + "jest-validate": "^29.7.0", 2226 + "yargs": "^17.3.1" 2227 + }, 2228 + "bin": { 2229 + "jest": "bin/jest.js" 2230 + }, 2231 + "engines": { 2232 + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 2233 + }, 2234 + "peerDependencies": { 2235 + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" 2236 + }, 2237 + "peerDependenciesMeta": { 2238 + "node-notifier": { 2239 + "optional": true 2240 + } 2241 + } 2242 + }, 2243 + "node_modules/jest-config": { 2244 + "version": "29.7.0", 2245 + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", 2246 + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", 2247 + "dev": true, 2248 + "license": "MIT", 2249 + "dependencies": { 2250 + "@babel/core": "^7.11.6", 2251 + "@jest/test-sequencer": "^29.7.0", 2252 + "@jest/types": "^29.6.3", 2253 + "babel-jest": "^29.7.0", 2254 + "chalk": "^4.0.0", 2255 + "ci-info": "^3.2.0", 2256 + "deepmerge": "^4.2.2", 2257 + "glob": "^7.1.3", 2258 + "graceful-fs": "^4.2.9", 2259 + "jest-circus": "^29.7.0", 2260 + "jest-environment-node": "^29.7.0", 2261 + "jest-get-type": "^29.6.3", 2262 + "jest-regex-util": "^29.6.3", 2263 + "jest-resolve": "^29.7.0", 2264 + "jest-runner": "^29.7.0", 2265 + "jest-util": "^29.7.0", 2266 + "jest-validate": "^29.7.0", 2267 + "micromatch": "^4.0.4", 2268 + "parse-json": "^5.2.0", 2269 + "pretty-format": "^29.7.0", 2270 + "slash": "^3.0.0", 2271 + "strip-json-comments": "^3.1.1" 2272 + }, 2273 + "engines": { 2274 + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 2275 + }, 2276 + "peerDependencies": { 2277 + "@types/node": "*", 2278 + "ts-node": ">=9.0.0" 2279 + }, 2280 + "peerDependenciesMeta": { 2281 + "@types/node": { 2282 + "optional": true 2283 + }, 2284 + "ts-node": { 2285 + "optional": true 2286 + } 2287 + } 2288 + }, 2289 + "node_modules/jest-diff": { 2290 + "version": "29.7.0", 2291 + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", 2292 + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", 2293 + "dev": true, 2294 + "license": "MIT", 2295 + "dependencies": { 2296 + "chalk": "^4.0.0", 2297 + "diff-sequences": "^29.6.3", 2298 + "jest-get-type": "^29.6.3", 2299 + "pretty-format": "^29.7.0" 2300 + }, 2301 + "engines": { 2302 + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 2303 + } 2304 + }, 2305 + "node_modules/jest-docblock": { 2306 + "version": "29.7.0", 2307 + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", 2308 + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", 2309 + "dev": true, 2310 + "license": "MIT", 2311 + "dependencies": { 2312 + "detect-newline": "^3.0.0" 2313 + }, 2314 + "engines": { 2315 + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 2316 + } 2317 + }, 2318 + "node_modules/jest-each": { 2319 + "version": "29.7.0", 2320 + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", 2321 + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", 2322 + "dev": true, 2323 + "license": "MIT", 2324 + "dependencies": { 2325 + "@jest/types": "^29.6.3", 2326 + "chalk": "^4.0.0", 2327 + "jest-get-type": "^29.6.3", 2328 + "jest-util": "^29.7.0", 2329 + "pretty-format": "^29.7.0" 2330 + }, 2331 + "engines": { 2332 + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 2333 + } 2334 + }, 2335 + "node_modules/jest-environment-node": { 2336 + "version": "29.7.0", 2337 + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", 2338 + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", 2339 + "dev": true, 2340 + "license": "MIT", 2341 + "dependencies": { 2342 + "@jest/environment": "^29.7.0", 2343 + "@jest/fake-timers": "^29.7.0", 2344 + "@jest/types": "^29.6.3", 2345 + "@types/node": "*", 2346 + "jest-mock": "^29.7.0", 2347 + "jest-util": "^29.7.0" 2348 + }, 2349 + "engines": { 2350 + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 2351 + } 2352 + }, 2353 + "node_modules/jest-get-type": { 2354 + "version": "29.6.3", 2355 + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", 2356 + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", 2357 + "dev": true, 2358 + "license": "MIT", 2359 + "engines": { 2360 + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 2361 + } 2362 + }, 2363 + "node_modules/jest-haste-map": { 2364 + "version": "29.7.0", 2365 + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", 2366 + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", 2367 + "dev": true, 2368 + "license": "MIT", 2369 + "dependencies": { 2370 + "@jest/types": "^29.6.3", 2371 + "@types/graceful-fs": "^4.1.3", 2372 + "@types/node": "*", 2373 + "anymatch": "^3.0.3", 2374 + "fb-watchman": "^2.0.0", 2375 + "graceful-fs": "^4.2.9", 2376 + "jest-regex-util": "^29.6.3", 2377 + "jest-util": "^29.7.0", 2378 + "jest-worker": "^29.7.0", 2379 + "micromatch": "^4.0.4", 2380 + "walker": "^1.0.8" 2381 + }, 2382 + "engines": { 2383 + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 2384 + }, 2385 + "optionalDependencies": { 2386 + "fsevents": "^2.3.2" 2387 + } 2388 + }, 2389 + "node_modules/jest-leak-detector": { 2390 + "version": "29.7.0", 2391 + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", 2392 + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", 2393 + "dev": true, 2394 + "license": "MIT", 2395 + "dependencies": { 2396 + "jest-get-type": "^29.6.3", 2397 + "pretty-format": "^29.7.0" 2398 + }, 2399 + "engines": { 2400 + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 2401 + } 2402 + }, 2403 + "node_modules/jest-matcher-utils": { 2404 + "version": "29.7.0", 2405 + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", 2406 + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", 2407 + "dev": true, 2408 + "license": "MIT", 2409 + "dependencies": { 2410 + "chalk": "^4.0.0", 2411 + "jest-diff": "^29.7.0", 2412 + "jest-get-type": "^29.6.3", 2413 + "pretty-format": "^29.7.0" 2414 + }, 2415 + "engines": { 2416 + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 2417 + } 2418 + }, 2419 + "node_modules/jest-message-util": { 2420 + "version": "29.7.0", 2421 + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", 2422 + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", 2423 + "dev": true, 2424 + "license": "MIT", 2425 + "dependencies": { 2426 + "@babel/code-frame": "^7.12.13", 2427 + "@jest/types": "^29.6.3", 2428 + "@types/stack-utils": "^2.0.0", 2429 + "chalk": "^4.0.0", 2430 + "graceful-fs": "^4.2.9", 2431 + "micromatch": "^4.0.4", 2432 + "pretty-format": "^29.7.0", 2433 + "slash": "^3.0.0", 2434 + "stack-utils": "^2.0.3" 2435 + }, 2436 + "engines": { 2437 + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 2438 + } 2439 + }, 2440 + "node_modules/jest-mock": { 2441 + "version": "29.7.0", 2442 + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", 2443 + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", 2444 + "dev": true, 2445 + "license": "MIT", 2446 + "dependencies": { 2447 + "@jest/types": "^29.6.3", 2448 + "@types/node": "*", 2449 + "jest-util": "^29.7.0" 2450 + }, 2451 + "engines": { 2452 + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 2453 + } 2454 + }, 2455 + "node_modules/jest-pnp-resolver": { 2456 + "version": "1.2.3", 2457 + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", 2458 + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", 2459 + "dev": true, 2460 + "license": "MIT", 2461 + "engines": { 2462 + "node": ">=6" 2463 + }, 2464 + "peerDependencies": { 2465 + "jest-resolve": "*" 2466 + }, 2467 + "peerDependenciesMeta": { 2468 + "jest-resolve": { 2469 + "optional": true 2470 + } 2471 + } 2472 + }, 2473 + "node_modules/jest-regex-util": { 2474 + "version": "29.6.3", 2475 + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", 2476 + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", 2477 + "dev": true, 2478 + "license": "MIT", 2479 + "engines": { 2480 + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 2481 + } 2482 + }, 2483 + "node_modules/jest-resolve": { 2484 + "version": "29.7.0", 2485 + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", 2486 + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", 2487 + "dev": true, 2488 + "license": "MIT", 2489 + "dependencies": { 2490 + "chalk": "^4.0.0", 2491 + "graceful-fs": "^4.2.9", 2492 + "jest-haste-map": "^29.7.0", 2493 + "jest-pnp-resolver": "^1.2.2", 2494 + "jest-util": "^29.7.0", 2495 + "jest-validate": "^29.7.0", 2496 + "resolve": "^1.20.0", 2497 + "resolve.exports": "^2.0.0", 2498 + "slash": "^3.0.0" 2499 + }, 2500 + "engines": { 2501 + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 2502 + } 2503 + }, 2504 + "node_modules/jest-resolve-dependencies": { 2505 + "version": "29.7.0", 2506 + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", 2507 + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", 2508 + "dev": true, 2509 + "license": "MIT", 2510 + "dependencies": { 2511 + "jest-regex-util": "^29.6.3", 2512 + "jest-snapshot": "^29.7.0" 2513 + }, 2514 + "engines": { 2515 + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 2516 + } 2517 + }, 2518 + "node_modules/jest-runner": { 2519 + "version": "29.7.0", 2520 + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", 2521 + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", 2522 + "dev": true, 2523 + "license": "MIT", 2524 + "dependencies": { 2525 + "@jest/console": "^29.7.0", 2526 + "@jest/environment": "^29.7.0", 2527 + "@jest/test-result": "^29.7.0", 2528 + "@jest/transform": "^29.7.0", 2529 + "@jest/types": "^29.6.3", 2530 + "@types/node": "*", 2531 + "chalk": "^4.0.0", 2532 + "emittery": "^0.13.1", 2533 + "graceful-fs": "^4.2.9", 2534 + "jest-docblock": "^29.7.0", 2535 + "jest-environment-node": "^29.7.0", 2536 + "jest-haste-map": "^29.7.0", 2537 + "jest-leak-detector": "^29.7.0", 2538 + "jest-message-util": "^29.7.0", 2539 + "jest-resolve": "^29.7.0", 2540 + "jest-runtime": "^29.7.0", 2541 + "jest-util": "^29.7.0", 2542 + "jest-watcher": "^29.7.0", 2543 + "jest-worker": "^29.7.0", 2544 + "p-limit": "^3.1.0", 2545 + "source-map-support": "0.5.13" 2546 + }, 2547 + "engines": { 2548 + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 2549 + } 2550 + }, 2551 + "node_modules/jest-runtime": { 2552 + "version": "29.7.0", 2553 + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", 2554 + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", 2555 + "dev": true, 2556 + "license": "MIT", 2557 + "dependencies": { 2558 + "@jest/environment": "^29.7.0", 2559 + "@jest/fake-timers": "^29.7.0", 2560 + "@jest/globals": "^29.7.0", 2561 + "@jest/source-map": "^29.6.3", 2562 + "@jest/test-result": "^29.7.0", 2563 + "@jest/transform": "^29.7.0", 2564 + "@jest/types": "^29.6.3", 2565 + "@types/node": "*", 2566 + "chalk": "^4.0.0", 2567 + "cjs-module-lexer": "^1.0.0", 2568 + "collect-v8-coverage": "^1.0.0", 2569 + "glob": "^7.1.3", 2570 + "graceful-fs": "^4.2.9", 2571 + "jest-haste-map": "^29.7.0", 2572 + "jest-message-util": "^29.7.0", 2573 + "jest-mock": "^29.7.0", 2574 + "jest-regex-util": "^29.6.3", 2575 + "jest-resolve": "^29.7.0", 2576 + "jest-snapshot": "^29.7.0", 2577 + "jest-util": "^29.7.0", 2578 + "slash": "^3.0.0", 2579 + "strip-bom": "^4.0.0" 2580 + }, 2581 + "engines": { 2582 + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 2583 + } 2584 + }, 2585 + "node_modules/jest-snapshot": { 2586 + "version": "29.7.0", 2587 + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", 2588 + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", 2589 + "dev": true, 2590 + "license": "MIT", 2591 + "dependencies": { 2592 + "@babel/core": "^7.11.6", 2593 + "@babel/generator": "^7.7.2", 2594 + "@babel/plugin-syntax-jsx": "^7.7.2", 2595 + "@babel/plugin-syntax-typescript": "^7.7.2", 2596 + "@babel/types": "^7.3.3", 2597 + "@jest/expect-utils": "^29.7.0", 2598 + "@jest/transform": "^29.7.0", 2599 + "@jest/types": "^29.6.3", 2600 + "babel-preset-current-node-syntax": "^1.0.0", 2601 + "chalk": "^4.0.0", 2602 + "expect": "^29.7.0", 2603 + "graceful-fs": "^4.2.9", 2604 + "jest-diff": "^29.7.0", 2605 + "jest-get-type": "^29.6.3", 2606 + "jest-matcher-utils": "^29.7.0", 2607 + "jest-message-util": "^29.7.0", 2608 + "jest-util": "^29.7.0", 2609 + "natural-compare": "^1.4.0", 2610 + "pretty-format": "^29.7.0", 2611 + "semver": "^7.5.3" 2612 + }, 2613 + "engines": { 2614 + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 2615 + } 2616 + }, 2617 + "node_modules/jest-snapshot/node_modules/semver": { 2618 + "version": "7.7.4", 2619 + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", 2620 + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", 2621 + "dev": true, 2622 + "license": "ISC", 2623 + "bin": { 2624 + "semver": "bin/semver.js" 2625 + }, 2626 + "engines": { 2627 + "node": ">=10" 2628 + } 2629 + }, 2630 + "node_modules/jest-util": { 2631 + "version": "29.7.0", 2632 + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", 2633 + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", 2634 + "dev": true, 2635 + "license": "MIT", 2636 + "dependencies": { 2637 + "@jest/types": "^29.6.3", 2638 + "@types/node": "*", 2639 + "chalk": "^4.0.0", 2640 + "ci-info": "^3.2.0", 2641 + "graceful-fs": "^4.2.9", 2642 + "picomatch": "^2.2.3" 2643 + }, 2644 + "engines": { 2645 + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 2646 + } 2647 + }, 2648 + "node_modules/jest-validate": { 2649 + "version": "29.7.0", 2650 + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", 2651 + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", 2652 + "dev": true, 2653 + "license": "MIT", 2654 + "dependencies": { 2655 + "@jest/types": "^29.6.3", 2656 + "camelcase": "^6.2.0", 2657 + "chalk": "^4.0.0", 2658 + "jest-get-type": "^29.6.3", 2659 + "leven": "^3.1.0", 2660 + "pretty-format": "^29.7.0" 2661 + }, 2662 + "engines": { 2663 + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 2664 + } 2665 + }, 2666 + "node_modules/jest-validate/node_modules/camelcase": { 2667 + "version": "6.3.0", 2668 + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", 2669 + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", 2670 + "dev": true, 2671 + "license": "MIT", 2672 + "engines": { 2673 + "node": ">=10" 2674 + }, 2675 + "funding": { 2676 + "url": "https://github.com/sponsors/sindresorhus" 2677 + } 2678 + }, 2679 + "node_modules/jest-watcher": { 2680 + "version": "29.7.0", 2681 + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", 2682 + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", 2683 + "dev": true, 2684 + "license": "MIT", 2685 + "dependencies": { 2686 + "@jest/test-result": "^29.7.0", 2687 + "@jest/types": "^29.6.3", 2688 + "@types/node": "*", 2689 + "ansi-escapes": "^4.2.1", 2690 + "chalk": "^4.0.0", 2691 + "emittery": "^0.13.1", 2692 + "jest-util": "^29.7.0", 2693 + "string-length": "^4.0.1" 2694 + }, 2695 + "engines": { 2696 + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 2697 + } 2698 + }, 2699 + "node_modules/jest-worker": { 2700 + "version": "29.7.0", 2701 + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", 2702 + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", 2703 + "dev": true, 2704 + "license": "MIT", 2705 + "dependencies": { 2706 + "@types/node": "*", 2707 + "jest-util": "^29.7.0", 2708 + "merge-stream": "^2.0.0", 2709 + "supports-color": "^8.0.0" 2710 + }, 2711 + "engines": { 2712 + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 2713 + } 2714 + }, 2715 + "node_modules/jest-worker/node_modules/supports-color": { 2716 + "version": "8.1.1", 2717 + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", 2718 + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", 2719 + "dev": true, 2720 + "license": "MIT", 2721 + "dependencies": { 2722 + "has-flag": "^4.0.0" 2723 + }, 2724 + "engines": { 2725 + "node": ">=10" 2726 + }, 2727 + "funding": { 2728 + "url": "https://github.com/chalk/supports-color?sponsor=1" 2729 + } 2730 + }, 2731 + "node_modules/js-tokens": { 2732 + "version": "4.0.0", 2733 + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", 2734 + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", 2735 + "dev": true, 2736 + "license": "MIT" 2737 + }, 2738 + "node_modules/js-yaml": { 2739 + "version": "3.14.2", 2740 + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", 2741 + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", 2742 + "dev": true, 2743 + "license": "MIT", 2744 + "dependencies": { 2745 + "argparse": "^1.0.7", 2746 + "esprima": "^4.0.0" 2747 + }, 2748 + "bin": { 2749 + "js-yaml": "bin/js-yaml.js" 2750 + } 2751 + }, 2752 + "node_modules/jsesc": { 2753 + "version": "3.1.0", 2754 + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", 2755 + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", 2756 + "dev": true, 2757 + "license": "MIT", 2758 + "bin": { 2759 + "jsesc": "bin/jsesc" 2760 + }, 2761 + "engines": { 2762 + "node": ">=6" 2763 + } 2764 + }, 2765 + "node_modules/json-parse-even-better-errors": { 2766 + "version": "2.3.1", 2767 + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", 2768 + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", 2769 + "dev": true, 2770 + "license": "MIT" 2771 + }, 2772 + "node_modules/json5": { 2773 + "version": "2.2.3", 2774 + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", 2775 + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", 2776 + "dev": true, 2777 + "license": "MIT", 2778 + "bin": { 2779 + "json5": "lib/cli.js" 2780 + }, 2781 + "engines": { 2782 + "node": ">=6" 2783 + } 2784 + }, 2785 + "node_modules/kleur": { 2786 + "version": "3.0.3", 2787 + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", 2788 + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", 2789 + "dev": true, 2790 + "license": "MIT", 2791 + "engines": { 2792 + "node": ">=6" 2793 + } 2794 + }, 2795 + "node_modules/leven": { 2796 + "version": "3.1.0", 2797 + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", 2798 + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", 2799 + "dev": true, 2800 + "license": "MIT", 2801 + "engines": { 2802 + "node": ">=6" 2803 + } 2804 + }, 2805 + "node_modules/lines-and-columns": { 2806 + "version": "1.2.4", 2807 + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", 2808 + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", 2809 + "dev": true, 2810 + "license": "MIT" 2811 + }, 2812 + "node_modules/locate-path": { 2813 + "version": "5.0.0", 2814 + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", 2815 + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", 2816 + "dev": true, 2817 + "license": "MIT", 2818 + "dependencies": { 2819 + "p-locate": "^4.1.0" 2820 + }, 2821 + "engines": { 2822 + "node": ">=8" 2823 + } 2824 + }, 2825 + "node_modules/lodash.memoize": { 2826 + "version": "4.1.2", 2827 + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", 2828 + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", 2829 + "dev": true, 2830 + "license": "MIT" 2831 + }, 2832 + "node_modules/lru-cache": { 2833 + "version": "5.1.1", 2834 + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", 2835 + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", 2836 + "dev": true, 2837 + "license": "ISC", 2838 + "dependencies": { 2839 + "yallist": "^3.0.2" 2840 + } 2841 + }, 2842 + "node_modules/luxon": { 2843 + "version": "3.7.2", 2844 + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", 2845 + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", 2846 + "license": "MIT", 2847 + "engines": { 2848 + "node": ">=12" 2849 + } 2850 + }, 2851 + "node_modules/make-dir": { 2852 + "version": "4.0.0", 2853 + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", 2854 + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", 2855 + "dev": true, 2856 + "license": "MIT", 2857 + "dependencies": { 2858 + "semver": "^7.5.3" 2859 + }, 2860 + "engines": { 2861 + "node": ">=10" 2862 + }, 2863 + "funding": { 2864 + "url": "https://github.com/sponsors/sindresorhus" 2865 + } 2866 + }, 2867 + "node_modules/make-dir/node_modules/semver": { 2868 + "version": "7.7.4", 2869 + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", 2870 + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", 2871 + "dev": true, 2872 + "license": "ISC", 2873 + "bin": { 2874 + "semver": "bin/semver.js" 2875 + }, 2876 + "engines": { 2877 + "node": ">=10" 2878 + } 2879 + }, 2880 + "node_modules/make-error": { 2881 + "version": "1.3.6", 2882 + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", 2883 + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", 2884 + "dev": true, 2885 + "license": "ISC" 2886 + }, 2887 + "node_modules/makeerror": { 2888 + "version": "1.0.12", 2889 + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", 2890 + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", 2891 + "dev": true, 2892 + "license": "BSD-3-Clause", 2893 + "dependencies": { 2894 + "tmpl": "1.0.5" 2895 + } 2896 + }, 2897 + "node_modules/merge-stream": { 2898 + "version": "2.0.0", 2899 + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", 2900 + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", 2901 + "dev": true, 2902 + "license": "MIT" 2903 + }, 2904 + "node_modules/micromatch": { 2905 + "version": "4.0.8", 2906 + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", 2907 + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", 2908 + "dev": true, 2909 + "license": "MIT", 2910 + "dependencies": { 2911 + "braces": "^3.0.3", 2912 + "picomatch": "^2.3.1" 2913 + }, 2914 + "engines": { 2915 + "node": ">=8.6" 2916 + } 2917 + }, 2918 + "node_modules/mimic-fn": { 2919 + "version": "2.1.0", 2920 + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", 2921 + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", 2922 + "dev": true, 2923 + "license": "MIT", 2924 + "engines": { 2925 + "node": ">=6" 2926 + } 2927 + }, 2928 + "node_modules/minimatch": { 2929 + "version": "3.1.5", 2930 + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", 2931 + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", 2932 + "dev": true, 2933 + "license": "ISC", 2934 + "dependencies": { 2935 + "brace-expansion": "^1.1.7" 2936 + }, 2937 + "engines": { 2938 + "node": "*" 2939 + } 2940 + }, 2941 + "node_modules/minimist": { 2942 + "version": "1.2.8", 2943 + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", 2944 + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", 2945 + "dev": true, 2946 + "license": "MIT", 2947 + "funding": { 2948 + "url": "https://github.com/sponsors/ljharb" 2949 + } 2950 + }, 2951 + "node_modules/ms": { 2952 + "version": "2.1.3", 2953 + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", 2954 + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", 2955 + "dev": true, 2956 + "license": "MIT" 2957 + }, 2958 + "node_modules/natural-compare": { 2959 + "version": "1.4.0", 2960 + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", 2961 + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", 2962 + "dev": true, 2963 + "license": "MIT" 2964 + }, 2965 + "node_modules/neo-async": { 2966 + "version": "2.6.2", 2967 + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", 2968 + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", 2969 + "dev": true, 2970 + "license": "MIT" 2971 + }, 2972 + "node_modules/node-int64": { 2973 + "version": "0.4.0", 2974 + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", 2975 + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", 2976 + "dev": true, 2977 + "license": "MIT" 2978 + }, 2979 + "node_modules/node-releases": { 2980 + "version": "2.0.27", 2981 + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", 2982 + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", 2983 + "dev": true, 2984 + "license": "MIT" 2985 + }, 2986 + "node_modules/normalize-path": { 2987 + "version": "3.0.0", 2988 + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", 2989 + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", 2990 + "dev": true, 2991 + "license": "MIT", 2992 + "engines": { 2993 + "node": ">=0.10.0" 2994 + } 2995 + }, 2996 + "node_modules/npm-run-path": { 2997 + "version": "4.0.1", 2998 + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", 2999 + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", 3000 + "dev": true, 3001 + "license": "MIT", 3002 + "dependencies": { 3003 + "path-key": "^3.0.0" 3004 + }, 3005 + "engines": { 3006 + "node": ">=8" 3007 + } 3008 + }, 3009 + "node_modules/once": { 3010 + "version": "1.4.0", 3011 + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", 3012 + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", 3013 + "dev": true, 3014 + "license": "ISC", 3015 + "dependencies": { 3016 + "wrappy": "1" 3017 + } 3018 + }, 3019 + "node_modules/onetime": { 3020 + "version": "5.1.2", 3021 + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", 3022 + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", 3023 + "dev": true, 3024 + "license": "MIT", 3025 + "dependencies": { 3026 + "mimic-fn": "^2.1.0" 3027 + }, 3028 + "engines": { 3029 + "node": ">=6" 3030 + }, 3031 + "funding": { 3032 + "url": "https://github.com/sponsors/sindresorhus" 3033 + } 3034 + }, 3035 + "node_modules/p-limit": { 3036 + "version": "3.1.0", 3037 + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", 3038 + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", 3039 + "dev": true, 3040 + "license": "MIT", 3041 + "dependencies": { 3042 + "yocto-queue": "^0.1.0" 3043 + }, 3044 + "engines": { 3045 + "node": ">=10" 3046 + }, 3047 + "funding": { 3048 + "url": "https://github.com/sponsors/sindresorhus" 3049 + } 3050 + }, 3051 + "node_modules/p-locate": { 3052 + "version": "4.1.0", 3053 + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", 3054 + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", 3055 + "dev": true, 3056 + "license": "MIT", 3057 + "dependencies": { 3058 + "p-limit": "^2.2.0" 3059 + }, 3060 + "engines": { 3061 + "node": ">=8" 3062 + } 3063 + }, 3064 + "node_modules/p-locate/node_modules/p-limit": { 3065 + "version": "2.3.0", 3066 + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", 3067 + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", 3068 + "dev": true, 3069 + "license": "MIT", 3070 + "dependencies": { 3071 + "p-try": "^2.0.0" 3072 + }, 3073 + "engines": { 3074 + "node": ">=6" 3075 + }, 3076 + "funding": { 3077 + "url": "https://github.com/sponsors/sindresorhus" 3078 + } 3079 + }, 3080 + "node_modules/p-try": { 3081 + "version": "2.2.0", 3082 + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", 3083 + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", 3084 + "dev": true, 3085 + "license": "MIT", 3086 + "engines": { 3087 + "node": ">=6" 3088 + } 3089 + }, 3090 + "node_modules/parse-json": { 3091 + "version": "5.2.0", 3092 + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", 3093 + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", 3094 + "dev": true, 3095 + "license": "MIT", 3096 + "dependencies": { 3097 + "@babel/code-frame": "^7.0.0", 3098 + "error-ex": "^1.3.1", 3099 + "json-parse-even-better-errors": "^2.3.0", 3100 + "lines-and-columns": "^1.1.6" 3101 + }, 3102 + "engines": { 3103 + "node": ">=8" 3104 + }, 3105 + "funding": { 3106 + "url": "https://github.com/sponsors/sindresorhus" 3107 + } 3108 + }, 3109 + "node_modules/path-exists": { 3110 + "version": "4.0.0", 3111 + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", 3112 + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", 3113 + "dev": true, 3114 + "license": "MIT", 3115 + "engines": { 3116 + "node": ">=8" 3117 + } 3118 + }, 3119 + "node_modules/path-is-absolute": { 3120 + "version": "1.0.1", 3121 + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", 3122 + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", 3123 + "dev": true, 3124 + "license": "MIT", 3125 + "engines": { 3126 + "node": ">=0.10.0" 3127 + } 3128 + }, 3129 + "node_modules/path-key": { 3130 + "version": "3.1.1", 3131 + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", 3132 + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", 3133 + "dev": true, 3134 + "license": "MIT", 3135 + "engines": { 3136 + "node": ">=8" 3137 + } 3138 + }, 3139 + "node_modules/path-parse": { 3140 + "version": "1.0.7", 3141 + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", 3142 + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", 3143 + "dev": true, 3144 + "license": "MIT" 3145 + }, 3146 + "node_modules/picocolors": { 3147 + "version": "1.1.1", 3148 + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", 3149 + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", 3150 + "dev": true, 3151 + "license": "ISC" 3152 + }, 3153 + "node_modules/picomatch": { 3154 + "version": "2.3.1", 3155 + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", 3156 + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", 3157 + "dev": true, 3158 + "license": "MIT", 3159 + "engines": { 3160 + "node": ">=8.6" 3161 + }, 3162 + "funding": { 3163 + "url": "https://github.com/sponsors/jonschlinkert" 3164 + } 3165 + }, 3166 + "node_modules/pirates": { 3167 + "version": "4.0.7", 3168 + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", 3169 + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", 3170 + "dev": true, 3171 + "license": "MIT", 3172 + "engines": { 3173 + "node": ">= 6" 3174 + } 3175 + }, 3176 + "node_modules/pkg-dir": { 3177 + "version": "4.2.0", 3178 + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", 3179 + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", 3180 + "dev": true, 3181 + "license": "MIT", 3182 + "dependencies": { 3183 + "find-up": "^4.0.0" 3184 + }, 3185 + "engines": { 3186 + "node": ">=8" 3187 + } 3188 + }, 3189 + "node_modules/pretty-format": { 3190 + "version": "29.7.0", 3191 + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", 3192 + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", 3193 + "dev": true, 3194 + "license": "MIT", 3195 + "dependencies": { 3196 + "@jest/schemas": "^29.6.3", 3197 + "ansi-styles": "^5.0.0", 3198 + "react-is": "^18.0.0" 3199 + }, 3200 + "engines": { 3201 + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 3202 + } 3203 + }, 3204 + "node_modules/pretty-format/node_modules/ansi-styles": { 3205 + "version": "5.2.0", 3206 + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", 3207 + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", 3208 + "dev": true, 3209 + "license": "MIT", 3210 + "engines": { 3211 + "node": ">=10" 3212 + }, 3213 + "funding": { 3214 + "url": "https://github.com/chalk/ansi-styles?sponsor=1" 3215 + } 3216 + }, 3217 + "node_modules/prompts": { 3218 + "version": "2.4.2", 3219 + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", 3220 + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", 3221 + "dev": true, 3222 + "license": "MIT", 3223 + "dependencies": { 3224 + "kleur": "^3.0.3", 3225 + "sisteransi": "^1.0.5" 3226 + }, 3227 + "engines": { 3228 + "node": ">= 6" 3229 + } 3230 + }, 3231 + "node_modules/pure-rand": { 3232 + "version": "6.1.0", 3233 + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", 3234 + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", 3235 + "dev": true, 3236 + "funding": [ 3237 + { 3238 + "type": "individual", 3239 + "url": "https://github.com/sponsors/dubzzz" 3240 + }, 3241 + { 3242 + "type": "opencollective", 3243 + "url": "https://opencollective.com/fast-check" 3244 + } 3245 + ], 3246 + "license": "MIT" 3247 + }, 3248 + "node_modules/react-is": { 3249 + "version": "18.3.1", 3250 + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", 3251 + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", 3252 + "dev": true, 3253 + "license": "MIT" 3254 + }, 3255 + "node_modules/require-directory": { 3256 + "version": "2.1.1", 3257 + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", 3258 + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", 3259 + "dev": true, 3260 + "license": "MIT", 3261 + "engines": { 3262 + "node": ">=0.10.0" 3263 + } 3264 + }, 3265 + "node_modules/resolve": { 3266 + "version": "1.22.11", 3267 + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", 3268 + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", 3269 + "dev": true, 3270 + "license": "MIT", 3271 + "dependencies": { 3272 + "is-core-module": "^2.16.1", 3273 + "path-parse": "^1.0.7", 3274 + "supports-preserve-symlinks-flag": "^1.0.0" 3275 + }, 3276 + "bin": { 3277 + "resolve": "bin/resolve" 3278 + }, 3279 + "engines": { 3280 + "node": ">= 0.4" 3281 + }, 3282 + "funding": { 3283 + "url": "https://github.com/sponsors/ljharb" 3284 + } 3285 + }, 3286 + "node_modules/resolve-cwd": { 3287 + "version": "3.0.0", 3288 + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", 3289 + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", 3290 + "dev": true, 3291 + "license": "MIT", 3292 + "dependencies": { 3293 + "resolve-from": "^5.0.0" 3294 + }, 3295 + "engines": { 3296 + "node": ">=8" 3297 + } 3298 + }, 3299 + "node_modules/resolve-from": { 3300 + "version": "5.0.0", 3301 + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", 3302 + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", 3303 + "dev": true, 3304 + "license": "MIT", 3305 + "engines": { 3306 + "node": ">=8" 3307 + } 3308 + }, 3309 + "node_modules/resolve.exports": { 3310 + "version": "2.0.3", 3311 + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", 3312 + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", 3313 + "dev": true, 3314 + "license": "MIT", 3315 + "engines": { 3316 + "node": ">=10" 3317 + } 3318 + }, 3319 + "node_modules/semver": { 3320 + "version": "6.3.1", 3321 + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", 3322 + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", 3323 + "dev": true, 3324 + "license": "ISC", 3325 + "bin": { 3326 + "semver": "bin/semver.js" 3327 + } 3328 + }, 3329 + "node_modules/shebang-command": { 3330 + "version": "2.0.0", 3331 + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", 3332 + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", 3333 + "dev": true, 3334 + "license": "MIT", 3335 + "dependencies": { 3336 + "shebang-regex": "^3.0.0" 3337 + }, 3338 + "engines": { 3339 + "node": ">=8" 3340 + } 3341 + }, 3342 + "node_modules/shebang-regex": { 3343 + "version": "3.0.0", 3344 + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", 3345 + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", 3346 + "dev": true, 3347 + "license": "MIT", 3348 + "engines": { 3349 + "node": ">=8" 3350 + } 3351 + }, 3352 + "node_modules/signal-exit": { 3353 + "version": "3.0.7", 3354 + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", 3355 + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", 3356 + "dev": true, 3357 + "license": "ISC" 3358 + }, 3359 + "node_modules/sisteransi": { 3360 + "version": "1.0.5", 3361 + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", 3362 + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", 3363 + "dev": true, 3364 + "license": "MIT" 3365 + }, 3366 + "node_modules/slash": { 3367 + "version": "3.0.0", 3368 + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", 3369 + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", 3370 + "dev": true, 3371 + "license": "MIT", 3372 + "engines": { 3373 + "node": ">=8" 3374 + } 3375 + }, 3376 + "node_modules/source-map": { 3377 + "version": "0.6.1", 3378 + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", 3379 + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", 3380 + "dev": true, 3381 + "license": "BSD-3-Clause", 3382 + "engines": { 3383 + "node": ">=0.10.0" 3384 + } 3385 + }, 3386 + "node_modules/source-map-support": { 3387 + "version": "0.5.13", 3388 + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", 3389 + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", 3390 + "dev": true, 3391 + "license": "MIT", 3392 + "dependencies": { 3393 + "buffer-from": "^1.0.0", 3394 + "source-map": "^0.6.0" 3395 + } 3396 + }, 3397 + "node_modules/sprintf-js": { 3398 + "version": "1.0.3", 3399 + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", 3400 + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", 3401 + "dev": true, 3402 + "license": "BSD-3-Clause" 3403 + }, 3404 + "node_modules/stack-utils": { 3405 + "version": "2.0.6", 3406 + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", 3407 + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", 3408 + "dev": true, 3409 + "license": "MIT", 3410 + "dependencies": { 3411 + "escape-string-regexp": "^2.0.0" 3412 + }, 3413 + "engines": { 3414 + "node": ">=10" 3415 + } 3416 + }, 3417 + "node_modules/string-length": { 3418 + "version": "4.0.2", 3419 + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", 3420 + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", 3421 + "dev": true, 3422 + "license": "MIT", 3423 + "dependencies": { 3424 + "char-regex": "^1.0.2", 3425 + "strip-ansi": "^6.0.0" 3426 + }, 3427 + "engines": { 3428 + "node": ">=10" 3429 + } 3430 + }, 3431 + "node_modules/string-width": { 3432 + "version": "4.2.3", 3433 + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", 3434 + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", 3435 + "dev": true, 3436 + "license": "MIT", 3437 + "dependencies": { 3438 + "emoji-regex": "^8.0.0", 3439 + "is-fullwidth-code-point": "^3.0.0", 3440 + "strip-ansi": "^6.0.1" 3441 + }, 3442 + "engines": { 3443 + "node": ">=8" 3444 + } 3445 + }, 3446 + "node_modules/strip-ansi": { 3447 + "version": "6.0.1", 3448 + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", 3449 + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", 3450 + "dev": true, 3451 + "license": "MIT", 3452 + "dependencies": { 3453 + "ansi-regex": "^5.0.1" 3454 + }, 3455 + "engines": { 3456 + "node": ">=8" 3457 + } 3458 + }, 3459 + "node_modules/strip-bom": { 3460 + "version": "4.0.0", 3461 + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", 3462 + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", 3463 + "dev": true, 3464 + "license": "MIT", 3465 + "engines": { 3466 + "node": ">=8" 3467 + } 3468 + }, 3469 + "node_modules/strip-final-newline": { 3470 + "version": "2.0.0", 3471 + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", 3472 + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", 3473 + "dev": true, 3474 + "license": "MIT", 3475 + "engines": { 3476 + "node": ">=6" 3477 + } 3478 + }, 3479 + "node_modules/strip-json-comments": { 3480 + "version": "3.1.1", 3481 + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", 3482 + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", 3483 + "dev": true, 3484 + "license": "MIT", 3485 + "engines": { 3486 + "node": ">=8" 3487 + }, 3488 + "funding": { 3489 + "url": "https://github.com/sponsors/sindresorhus" 3490 + } 3491 + }, 3492 + "node_modules/supports-color": { 3493 + "version": "7.2.0", 3494 + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", 3495 + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", 3496 + "dev": true, 3497 + "license": "MIT", 3498 + "dependencies": { 3499 + "has-flag": "^4.0.0" 3500 + }, 3501 + "engines": { 3502 + "node": ">=8" 3503 + } 3504 + }, 3505 + "node_modules/supports-preserve-symlinks-flag": { 3506 + "version": "1.0.0", 3507 + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", 3508 + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", 3509 + "dev": true, 3510 + "license": "MIT", 3511 + "engines": { 3512 + "node": ">= 0.4" 3513 + }, 3514 + "funding": { 3515 + "url": "https://github.com/sponsors/ljharb" 3516 + } 3517 + }, 3518 + "node_modules/test-exclude": { 3519 + "version": "6.0.0", 3520 + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", 3521 + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", 3522 + "dev": true, 3523 + "license": "ISC", 3524 + "dependencies": { 3525 + "@istanbuljs/schema": "^0.1.2", 3526 + "glob": "^7.1.4", 3527 + "minimatch": "^3.0.4" 3528 + }, 3529 + "engines": { 3530 + "node": ">=8" 3531 + } 3532 + }, 3533 + "node_modules/tmpl": { 3534 + "version": "1.0.5", 3535 + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", 3536 + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", 3537 + "dev": true, 3538 + "license": "BSD-3-Clause" 3539 + }, 3540 + "node_modules/to-regex-range": { 3541 + "version": "5.0.1", 3542 + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", 3543 + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", 3544 + "dev": true, 3545 + "license": "MIT", 3546 + "dependencies": { 3547 + "is-number": "^7.0.0" 3548 + }, 3549 + "engines": { 3550 + "node": ">=8.0" 3551 + } 3552 + }, 3553 + "node_modules/ts-jest": { 3554 + "version": "29.4.6", 3555 + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.6.tgz", 3556 + "integrity": "sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==", 3557 + "dev": true, 3558 + "license": "MIT", 3559 + "dependencies": { 3560 + "bs-logger": "^0.2.6", 3561 + "fast-json-stable-stringify": "^2.1.0", 3562 + "handlebars": "^4.7.8", 3563 + "json5": "^2.2.3", 3564 + "lodash.memoize": "^4.1.2", 3565 + "make-error": "^1.3.6", 3566 + "semver": "^7.7.3", 3567 + "type-fest": "^4.41.0", 3568 + "yargs-parser": "^21.1.1" 3569 + }, 3570 + "bin": { 3571 + "ts-jest": "cli.js" 3572 + }, 3573 + "engines": { 3574 + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" 3575 + }, 3576 + "peerDependencies": { 3577 + "@babel/core": ">=7.0.0-beta.0 <8", 3578 + "@jest/transform": "^29.0.0 || ^30.0.0", 3579 + "@jest/types": "^29.0.0 || ^30.0.0", 3580 + "babel-jest": "^29.0.0 || ^30.0.0", 3581 + "jest": "^29.0.0 || ^30.0.0", 3582 + "jest-util": "^29.0.0 || ^30.0.0", 3583 + "typescript": ">=4.3 <6" 3584 + }, 3585 + "peerDependenciesMeta": { 3586 + "@babel/core": { 3587 + "optional": true 3588 + }, 3589 + "@jest/transform": { 3590 + "optional": true 3591 + }, 3592 + "@jest/types": { 3593 + "optional": true 3594 + }, 3595 + "babel-jest": { 3596 + "optional": true 3597 + }, 3598 + "esbuild": { 3599 + "optional": true 3600 + }, 3601 + "jest-util": { 3602 + "optional": true 3603 + } 3604 + } 3605 + }, 3606 + "node_modules/ts-jest/node_modules/semver": { 3607 + "version": "7.7.4", 3608 + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", 3609 + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", 3610 + "dev": true, 3611 + "license": "ISC", 3612 + "bin": { 3613 + "semver": "bin/semver.js" 3614 + }, 3615 + "engines": { 3616 + "node": ">=10" 3617 + } 3618 + }, 3619 + "node_modules/ts-jest/node_modules/type-fest": { 3620 + "version": "4.41.0", 3621 + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", 3622 + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", 3623 + "dev": true, 3624 + "license": "(MIT OR CC0-1.0)", 3625 + "engines": { 3626 + "node": ">=16" 3627 + }, 3628 + "funding": { 3629 + "url": "https://github.com/sponsors/sindresorhus" 3630 + } 3631 + }, 3632 + "node_modules/type-detect": { 3633 + "version": "4.0.8", 3634 + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", 3635 + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", 3636 + "dev": true, 3637 + "license": "MIT", 3638 + "engines": { 3639 + "node": ">=4" 3640 + } 3641 + }, 3642 + "node_modules/type-fest": { 3643 + "version": "0.21.3", 3644 + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", 3645 + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", 3646 + "dev": true, 3647 + "license": "(MIT OR CC0-1.0)", 3648 + "engines": { 3649 + "node": ">=10" 3650 + }, 3651 + "funding": { 3652 + "url": "https://github.com/sponsors/sindresorhus" 3653 + } 3654 + }, 3655 + "node_modules/typescript": { 3656 + "version": "5.9.3", 3657 + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", 3658 + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", 3659 + "dev": true, 3660 + "license": "Apache-2.0", 3661 + "peer": true, 3662 + "bin": { 3663 + "tsc": "bin/tsc", 3664 + "tsserver": "bin/tsserver" 3665 + }, 3666 + "engines": { 3667 + "node": ">=14.17" 3668 + } 3669 + }, 3670 + "node_modules/uglify-js": { 3671 + "version": "3.19.3", 3672 + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", 3673 + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", 3674 + "dev": true, 3675 + "license": "BSD-2-Clause", 3676 + "optional": true, 3677 + "bin": { 3678 + "uglifyjs": "bin/uglifyjs" 3679 + }, 3680 + "engines": { 3681 + "node": ">=0.8.0" 3682 + } 3683 + }, 3684 + "node_modules/undici-types": { 3685 + "version": "7.18.2", 3686 + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", 3687 + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", 3688 + "dev": true, 3689 + "license": "MIT" 3690 + }, 3691 + "node_modules/update-browserslist-db": { 3692 + "version": "1.2.3", 3693 + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", 3694 + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", 3695 + "dev": true, 3696 + "funding": [ 3697 + { 3698 + "type": "opencollective", 3699 + "url": "https://opencollective.com/browserslist" 3700 + }, 3701 + { 3702 + "type": "tidelift", 3703 + "url": "https://tidelift.com/funding/github/npm/browserslist" 3704 + }, 3705 + { 3706 + "type": "github", 3707 + "url": "https://github.com/sponsors/ai" 3708 + } 3709 + ], 3710 + "license": "MIT", 3711 + "dependencies": { 3712 + "escalade": "^3.2.0", 3713 + "picocolors": "^1.1.1" 3714 + }, 3715 + "bin": { 3716 + "update-browserslist-db": "cli.js" 3717 + }, 3718 + "peerDependencies": { 3719 + "browserslist": ">= 4.21.0" 3720 + } 3721 + }, 3722 + "node_modules/v8-to-istanbul": { 3723 + "version": "9.3.0", 3724 + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", 3725 + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", 3726 + "dev": true, 3727 + "license": "ISC", 3728 + "dependencies": { 3729 + "@jridgewell/trace-mapping": "^0.3.12", 3730 + "@types/istanbul-lib-coverage": "^2.0.1", 3731 + "convert-source-map": "^2.0.0" 3732 + }, 3733 + "engines": { 3734 + "node": ">=10.12.0" 3735 + } 3736 + }, 3737 + "node_modules/walker": { 3738 + "version": "1.0.8", 3739 + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", 3740 + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", 3741 + "dev": true, 3742 + "license": "Apache-2.0", 3743 + "dependencies": { 3744 + "makeerror": "1.0.12" 3745 + } 3746 + }, 3747 + "node_modules/which": { 3748 + "version": "2.0.2", 3749 + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", 3750 + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", 3751 + "dev": true, 3752 + "license": "ISC", 3753 + "dependencies": { 3754 + "isexe": "^2.0.0" 3755 + }, 3756 + "bin": { 3757 + "node-which": "bin/node-which" 3758 + }, 3759 + "engines": { 3760 + "node": ">= 8" 3761 + } 3762 + }, 3763 + "node_modules/wordwrap": { 3764 + "version": "1.0.0", 3765 + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", 3766 + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", 3767 + "dev": true, 3768 + "license": "MIT" 3769 + }, 3770 + "node_modules/wrap-ansi": { 3771 + "version": "7.0.0", 3772 + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", 3773 + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", 3774 + "dev": true, 3775 + "license": "MIT", 3776 + "dependencies": { 3777 + "ansi-styles": "^4.0.0", 3778 + "string-width": "^4.1.0", 3779 + "strip-ansi": "^6.0.0" 3780 + }, 3781 + "engines": { 3782 + "node": ">=10" 3783 + }, 3784 + "funding": { 3785 + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" 3786 + } 3787 + }, 3788 + "node_modules/wrappy": { 3789 + "version": "1.0.2", 3790 + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", 3791 + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", 3792 + "dev": true, 3793 + "license": "ISC" 3794 + }, 3795 + "node_modules/write-file-atomic": { 3796 + "version": "4.0.2", 3797 + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", 3798 + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", 3799 + "dev": true, 3800 + "license": "ISC", 3801 + "dependencies": { 3802 + "imurmurhash": "^0.1.4", 3803 + "signal-exit": "^3.0.7" 3804 + }, 3805 + "engines": { 3806 + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" 3807 + } 3808 + }, 3809 + "node_modules/y18n": { 3810 + "version": "5.0.8", 3811 + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", 3812 + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", 3813 + "dev": true, 3814 + "license": "ISC", 3815 + "engines": { 3816 + "node": ">=10" 3817 + } 3818 + }, 3819 + "node_modules/yallist": { 3820 + "version": "3.1.1", 3821 + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", 3822 + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", 3823 + "dev": true, 3824 + "license": "ISC" 3825 + }, 3826 + "node_modules/yargs": { 3827 + "version": "17.7.2", 3828 + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", 3829 + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", 3830 + "dev": true, 3831 + "license": "MIT", 3832 + "dependencies": { 3833 + "cliui": "^8.0.1", 3834 + "escalade": "^3.1.1", 3835 + "get-caller-file": "^2.0.5", 3836 + "require-directory": "^2.1.1", 3837 + "string-width": "^4.2.3", 3838 + "y18n": "^5.0.5", 3839 + "yargs-parser": "^21.1.1" 3840 + }, 3841 + "engines": { 3842 + "node": ">=12" 3843 + } 3844 + }, 3845 + "node_modules/yargs-parser": { 3846 + "version": "21.1.1", 3847 + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", 3848 + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", 3849 + "dev": true, 3850 + "license": "ISC", 3851 + "engines": { 3852 + "node": ">=12" 3853 + } 3854 + }, 3855 + "node_modules/yocto-queue": { 3856 + "version": "0.1.0", 3857 + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", 3858 + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", 3859 + "dev": true, 3860 + "license": "MIT", 3861 + "engines": { 3862 + "node": ">=10" 3863 + }, 3864 + "funding": { 3865 + "url": "https://github.com/sponsors/sindresorhus" 3866 + } 3867 + } 3868 + } 3869 + }
+22
packages/recurrence/package.json
··· 1 + { 2 + "name": "@newpublic/recurrence", 3 + "version": "0.1.0", 4 + "description": "Recurrence rule engine for ALF scheduled posts", 5 + "license": "Apache-2.0", 6 + "main": "dist/index.js", 7 + "types": "dist/index.d.ts", 8 + "scripts": { 9 + "build": "tsc", 10 + "test": "jest --config jest.config.js" 11 + }, 12 + "dependencies": { 13 + "luxon": "^3.5.0" 14 + }, 15 + "devDependencies": { 16 + "@types/jest": "^29.5.14", 17 + "@types/luxon": "^3.4.2", 18 + "jest": "^29.7.0", 19 + "ts-jest": "^29.4.6", 20 + "typescript": "^5.6.0" 21 + } 22 + }
+515
packages/recurrence/src/__tests__/engine.test.ts
··· 1 + // ABOUTME: Tests for the recurrence engine computeNextOccurrence 2 + 3 + import { computeNextOccurrence, getOccurrenceRecord } from '../engine'; 4 + import type { RecurrenceRule } from '../types'; 5 + 6 + // Helper to parse an ISO string and get a Date 7 + const d = (iso: string) => new Date(iso); 8 + 9 + // Helper to format a Date as ISO string 10 + const fmt = (date: Date | null) => date?.toISOString() ?? null; 11 + 12 + describe('computeNextOccurrence — daily rules', () => { 13 + const dailyRule: RecurrenceRule = { 14 + rule: { 15 + type: 'daily', 16 + interval: 1, 17 + time: { 18 + type: 'wall_time', 19 + hour: 9, 20 + minute: 0, 21 + timezone: 'America/New_York', 22 + }, 23 + }, 24 + }; 25 + 26 + it('returns the next day occurrence when called just before the fire time', () => { 27 + // 9:00 AM ET = 14:00 UTC (EST, UTC-5) 28 + // Ask for next after 2024-01-15T13:59:00Z (just before 9 AM ET) 29 + const next = computeNextOccurrence(dailyRule, d('2024-01-15T13:59:00Z')); 30 + // Should fire at 2024-01-15T14:00:00Z (9 AM ET) 31 + expect(next).not.toBeNull(); 32 + expect(next!.toISOString()).toBe('2024-01-15T14:00:00.000Z'); 33 + }); 34 + 35 + it('returns the next day when called after the fire time', () => { 36 + // After 9 AM ET on Jan 15, next is Jan 16 37 + const next = computeNextOccurrence(dailyRule, d('2024-01-15T14:01:00Z')); 38 + expect(next).not.toBeNull(); 39 + expect(next!.toISOString()).toBe('2024-01-16T14:00:00.000Z'); 40 + }); 41 + 42 + it('respects interval=2 (every other day)', () => { 43 + const rule: RecurrenceRule = { 44 + rule: { 45 + type: 'daily', 46 + interval: 2, 47 + time: { type: 'wall_time', hour: 12, minute: 0, timezone: 'UTC' }, 48 + }, 49 + }; 50 + const next = computeNextOccurrence(rule, d('2024-01-01T12:00:00Z')); 51 + // After Jan 1 noon, next is Jan 3 noon 52 + expect(next?.toISOString()).toBe('2024-01-03T12:00:00.000Z'); 53 + }); 54 + 55 + it('respects startDate constraint', () => { 56 + const rule: RecurrenceRule = { 57 + rule: { 58 + type: 'daily', 59 + time: { type: 'wall_time', hour: 9, minute: 0, timezone: 'UTC' }, 60 + }, 61 + startDate: '2024-03-01', 62 + }; 63 + // Even though we ask for next after a much earlier date, startDate gates it 64 + const next = computeNextOccurrence(rule, d('2024-01-01T00:00:00Z')); 65 + expect(next?.toISOString()).toBe('2024-03-01T09:00:00.000Z'); 66 + }); 67 + 68 + it('returns null after endDate', () => { 69 + const rule: RecurrenceRule = { 70 + rule: { 71 + type: 'daily', 72 + time: { type: 'wall_time', hour: 9, minute: 0, timezone: 'UTC' }, 73 + }, 74 + endDate: '2024-01-15', 75 + }; 76 + const next = computeNextOccurrence(rule, d('2024-01-15T09:01:00Z')); 77 + expect(next).toBeNull(); 78 + }); 79 + 80 + it('returns null after count is reached', () => { 81 + const rule: RecurrenceRule = { 82 + rule: { 83 + type: 'daily', 84 + time: { type: 'wall_time', hour: 9, minute: 0, timezone: 'UTC' }, 85 + }, 86 + count: 3, 87 + startDate: '2024-01-01', 88 + }; 89 + // Count=3 means occurrences on Jan 1, 2, 3. After Jan 3, no more. 90 + const next = computeNextOccurrence(rule, d('2024-01-03T09:01:00Z')); 91 + expect(next).toBeNull(); 92 + }); 93 + }); 94 + 95 + describe('computeNextOccurrence — weekly rules', () => { 96 + it('fires on specified days of the week', () => { 97 + const rule: RecurrenceRule = { 98 + rule: { 99 + type: 'weekly', 100 + daysOfWeek: [1, 3], // Monday, Wednesday 101 + time: { type: 'wall_time', hour: 10, minute: 0, timezone: 'UTC' }, 102 + }, 103 + }; 104 + // 2024-01-15 is a Monday 105 + const next = computeNextOccurrence(rule, d('2024-01-15T10:01:00Z')); 106 + // Next is Wednesday Jan 17 107 + expect(next?.toISOString()).toBe('2024-01-17T10:00:00.000Z'); 108 + }); 109 + 110 + it('respects weekly interval', () => { 111 + const rule: RecurrenceRule = { 112 + rule: { 113 + type: 'weekly', 114 + interval: 2, 115 + daysOfWeek: [1], // Every other Monday 116 + time: { type: 'wall_time', hour: 9, minute: 0, timezone: 'UTC' }, 117 + }, 118 + startDate: '2024-01-15', // Monday Jan 15 119 + }; 120 + const next = computeNextOccurrence(rule, d('2024-01-15T09:01:00Z')); 121 + // Next is 2 weeks later: Jan 29 (also a Monday) 122 + expect(next?.toISOString()).toBe('2024-01-29T09:00:00.000Z'); 123 + }); 124 + 125 + it('wraps to next week when no more days this week', () => { 126 + const rule: RecurrenceRule = { 127 + rule: { 128 + type: 'weekly', 129 + daysOfWeek: [1], // Monday only 130 + time: { type: 'wall_time', hour: 9, minute: 0, timezone: 'UTC' }, 131 + }, 132 + }; 133 + // After Monday Jan 15, next Monday is Jan 22 134 + const next = computeNextOccurrence(rule, d('2024-01-15T09:01:00Z')); 135 + expect(next?.toISOString()).toBe('2024-01-22T09:00:00.000Z'); 136 + }); 137 + }); 138 + 139 + describe('computeNextOccurrence — monthly_on_day rules', () => { 140 + it('fires on the specified day of month', () => { 141 + const rule: RecurrenceRule = { 142 + rule: { 143 + type: 'monthly_on_day', 144 + dayOfMonth: 15, 145 + time: { type: 'wall_time', hour: 12, minute: 0, timezone: 'UTC' }, 146 + }, 147 + }; 148 + // After Jan 15 noon, next is Feb 15 noon 149 + const next = computeNextOccurrence(rule, d('2024-01-15T12:01:00Z')); 150 + expect(next?.toISOString()).toBe('2024-02-15T12:00:00.000Z'); 151 + }); 152 + 153 + it('clamps day to end of month', () => { 154 + const rule: RecurrenceRule = { 155 + rule: { 156 + type: 'monthly_on_day', 157 + dayOfMonth: 31, 158 + time: { type: 'wall_time', hour: 0, minute: 0, timezone: 'UTC' }, 159 + }, 160 + }; 161 + // Jan 31 is valid; after Jan 31, next is Feb 29 (2024 is leap year) 162 + const next = computeNextOccurrence(rule, d('2024-01-31T00:01:00Z')); 163 + expect(next?.toISOString()).toBe('2024-02-29T00:00:00.000Z'); 164 + }); 165 + }); 166 + 167 + describe('computeNextOccurrence — monthly_nth_weekday rules', () => { 168 + it('fires on the 1st Monday of each month', () => { 169 + const rule: RecurrenceRule = { 170 + rule: { 171 + type: 'monthly_nth_weekday', 172 + nth: 1, 173 + weekday: 1, // Monday 174 + time: { type: 'wall_time', hour: 9, minute: 0, timezone: 'UTC' }, 175 + }, 176 + }; 177 + // First Monday of Jan 2024 = Jan 1 178 + // After Jan 1, first Monday of Feb 2024 = Feb 5 179 + const next = computeNextOccurrence(rule, d('2024-01-01T09:01:00Z')); 180 + expect(next?.toISOString()).toBe('2024-02-05T09:00:00.000Z'); 181 + }); 182 + 183 + it('fires on the last Friday (nth=-1) of each month', () => { 184 + const rule: RecurrenceRule = { 185 + rule: { 186 + type: 'monthly_nth_weekday', 187 + nth: -1, 188 + weekday: 5, // Friday 189 + time: { type: 'wall_time', hour: 17, minute: 0, timezone: 'UTC' }, 190 + }, 191 + }; 192 + // Last Friday of Jan 2024 = Jan 26 193 + const next = computeNextOccurrence(rule, d('2024-01-01T00:00:00Z')); 194 + expect(next?.toISOString()).toBe('2024-01-26T17:00:00.000Z'); 195 + }); 196 + }); 197 + 198 + describe('computeNextOccurrence — fixed_instant rules', () => { 199 + it('handles fixed_instant time type', () => { 200 + const rule: RecurrenceRule = { 201 + rule: { 202 + type: 'daily', 203 + time: { 204 + type: 'fixed_instant', 205 + utcOffsetMinutes: -300, // UTC-5 206 + hour: 9, 207 + minute: 0, 208 + }, 209 + }, 210 + }; 211 + // 9 AM at UTC-5 = 14:00 UTC 212 + const next = computeNextOccurrence(rule, d('2024-01-15T13:59:00Z')); 213 + expect(next?.toISOString()).toBe('2024-01-15T14:00:00.000Z'); 214 + }); 215 + }); 216 + 217 + describe('computeNextOccurrence — exceptions', () => { 218 + const baseRule: RecurrenceRule = { 219 + rule: { 220 + type: 'daily', 221 + time: { type: 'wall_time', hour: 9, minute: 0, timezone: 'UTC' }, 222 + }, 223 + }; 224 + 225 + it('skips cancelled exception dates', () => { 226 + const rule: RecurrenceRule = { 227 + ...baseRule, 228 + exceptions: [{ type: 'cancel', date: '2024-01-16' }], 229 + }; 230 + // After Jan 15, Jan 16 is cancelled, so next is Jan 17 231 + const next = computeNextOccurrence(rule, d('2024-01-15T09:01:00Z')); 232 + expect(next?.toISOString()).toBe('2024-01-17T09:00:00.000Z'); 233 + }); 234 + 235 + it('uses moved datetime for move exceptions', () => { 236 + const rule: RecurrenceRule = { 237 + ...baseRule, 238 + exceptions: [{ type: 'move', date: '2024-01-16', newDatetime: '2024-01-16T15:00:00Z' }], 239 + }; 240 + const next = computeNextOccurrence(rule, d('2024-01-15T09:01:00Z')); 241 + // Jan 16 is moved to 15:00 UTC 242 + expect(next?.toISOString()).toBe('2024-01-16T15:00:00.000Z'); 243 + }); 244 + 245 + it('uses override time for override_time exceptions', () => { 246 + const rule: RecurrenceRule = { 247 + ...baseRule, 248 + exceptions: [{ 249 + type: 'override_time', 250 + date: '2024-01-16', 251 + time: { type: 'wall_time', hour: 17, minute: 30, timezone: 'UTC' }, 252 + }], 253 + }; 254 + const next = computeNextOccurrence(rule, d('2024-01-15T09:01:00Z')); 255 + // Jan 16 at 17:30 UTC 256 + expect(next?.toISOString()).toBe('2024-01-16T17:30:00.000Z'); 257 + }); 258 + }); 259 + 260 + describe('computeNextOccurrence — revisions', () => { 261 + it('applies revision time spec after effectiveFromDate', () => { 262 + const rule: RecurrenceRule = { 263 + rule: { 264 + type: 'daily', 265 + time: { type: 'wall_time', hour: 9, minute: 0, timezone: 'UTC' }, 266 + }, 267 + revisions: [ 268 + { 269 + id: 'rev1', 270 + effectiveFromDate: '2024-01-20', 271 + rule: { 272 + type: 'daily', 273 + time: { type: 'wall_time', hour: 14, minute: 0, timezone: 'UTC' }, 274 + }, 275 + }, 276 + ], 277 + }; 278 + // Before revision: 9 AM 279 + const before = computeNextOccurrence(rule, d('2024-01-18T09:01:00Z')); 280 + expect(before?.toISOString()).toBe('2024-01-19T09:00:00.000Z'); 281 + 282 + // After revision kicks in: 2 PM 283 + const after = computeNextOccurrence(rule, d('2024-01-19T09:01:00Z')); 284 + expect(after?.toISOString()).toBe('2024-01-20T14:00:00.000Z'); 285 + }); 286 + }); 287 + 288 + describe('computeNextOccurrence — DST handling', () => { 289 + it('handles spring-forward gap (clocks spring forward in America/New_York)', () => { 290 + // In 2024, US spring-forward: March 10 at 2 AM local → 3 AM 291 + // A rule firing at 2:30 AM ET on March 10 should resolve to 3:30 AM ET (7:30 AM UTC) 292 + const rule: RecurrenceRule = { 293 + rule: { 294 + type: 'daily', 295 + time: { type: 'wall_time', hour: 2, minute: 30, timezone: 'America/New_York' }, 296 + }, 297 + }; 298 + const next = computeNextOccurrence(rule, d('2024-03-09T09:00:00Z')); 299 + // Luxon will resolve the 2:30 AM gap to the post-transition time 300 + // March 10 in ET spring-forward: 2:30 AM is in the gap, resolves to 3:30 AM EDT = 7:30 AM UTC 301 + expect(next).not.toBeNull(); 302 + }); 303 + 304 + it('handles fall-back overlap (clocks fall back in America/New_York)', () => { 305 + // In 2024, US fall-back: November 3 at 2 AM → 1 AM (clocks fall back) 306 + // Rule fires at 1:30 AM ET — there are two 1:30 AM ET on this day 307 + const rule: RecurrenceRule = { 308 + rule: { 309 + type: 'daily', 310 + time: { type: 'wall_time', hour: 1, minute: 30, timezone: 'America/New_York' }, 311 + }, 312 + }; 313 + const next = computeNextOccurrence(rule, d('2024-11-02T09:00:00Z')); 314 + expect(next).not.toBeNull(); 315 + // Luxon resolves ambiguous fall-back times to the post-transition (EST = UTC-5 → 6:30 AM UTC). 316 + // This is consistent behavior: same wall time after the clock has fallen back. 317 + expect(next!.toISOString()).toBe('2024-11-03T06:30:00.000Z'); 318 + }); 319 + }); 320 + 321 + describe('computeNextOccurrence — once rule', () => { 322 + it('fires exactly once when datetime is in the future', () => { 323 + const rule: RecurrenceRule = { 324 + rule: { type: 'once', datetime: '2024-06-15T10:00:00Z' }, 325 + }; 326 + const next = computeNextOccurrence(rule, d('2024-06-01T00:00:00Z')); 327 + expect(next?.toISOString()).toBe('2024-06-15T10:00:00.000Z'); 328 + }); 329 + 330 + it('returns null when called after the datetime', () => { 331 + const rule: RecurrenceRule = { 332 + rule: { type: 'once', datetime: '2024-06-15T10:00:00Z' }, 333 + }; 334 + const next = computeNextOccurrence(rule, d('2024-06-15T10:00:00Z')); 335 + expect(next).toBeNull(); 336 + }); 337 + 338 + it('returns null when datetime is in the past relative to after', () => { 339 + const rule: RecurrenceRule = { 340 + rule: { type: 'once', datetime: '2024-01-01T00:00:00Z' }, 341 + }; 342 + const next = computeNextOccurrence(rule, d('2024-06-01T00:00:00Z')); 343 + expect(next).toBeNull(); 344 + }); 345 + }); 346 + 347 + describe('computeNextOccurrence — move exception with endDate', () => { 348 + it('returns null when a moved occurrence falls past endDate', () => { 349 + const rule: RecurrenceRule = { 350 + rule: { type: 'daily', time: { type: 'wall_time', hour: 9, minute: 0, timezone: 'UTC' } }, 351 + endDate: '2024-01-20', 352 + exceptions: [{ type: 'move', date: '2024-01-15', newDatetime: '2024-01-25T09:00:00Z' }], 353 + }; 354 + // Jan 15 is moved to Jan 25, which is after endDate Jan 20 → should return null 355 + const next = computeNextOccurrence(rule, d('2024-01-14T09:01:00Z')); 356 + expect(next).toBeNull(); 357 + }); 358 + 359 + it('returns moved occurrence when it falls within endDate', () => { 360 + const rule: RecurrenceRule = { 361 + rule: { type: 'daily', time: { type: 'wall_time', hour: 9, minute: 0, timezone: 'UTC' } }, 362 + endDate: '2024-01-20', 363 + exceptions: [{ type: 'move', date: '2024-01-15', newDatetime: '2024-01-18T09:00:00Z' }], 364 + }; 365 + // Jan 15 is moved to Jan 18, which is within endDate → should return the moved date 366 + const next = computeNextOccurrence(rule, d('2024-01-14T09:01:00Z')); 367 + expect(next?.toISOString()).toBe('2024-01-18T09:00:00.000Z'); 368 + }); 369 + }); 370 + 371 + describe('getOccurrenceRecord — override_payload exception', () => { 372 + it('returns the override record for a matching override_payload exception', () => { 373 + const rule: RecurrenceRule = { 374 + rule: { type: 'daily', time: { type: 'wall_time', hour: 9, minute: 0, timezone: 'UTC' } }, 375 + exceptions: [{ 376 + type: 'override_payload', 377 + date: '2024-03-15', 378 + record: { '$type': 'app.bsky.feed.post', text: 'Special post!' }, 379 + }], 380 + }; 381 + // occurrenceDate = March 15 at 9 AM UTC 382 + const overrideRecord = getOccurrenceRecord(rule, d('2024-03-15T09:00:00Z')); 383 + expect(overrideRecord).toBeDefined(); 384 + expect(overrideRecord?.text).toBe('Special post!'); 385 + }); 386 + 387 + it('returns undefined when no override_payload exception matches', () => { 388 + const rule: RecurrenceRule = { 389 + rule: { type: 'daily', time: { type: 'wall_time', hour: 9, minute: 0, timezone: 'UTC' } }, 390 + exceptions: [{ 391 + type: 'override_payload', 392 + date: '2024-03-15', 393 + record: { '$type': 'app.bsky.feed.post', text: 'Special post!' }, 394 + }], 395 + }; 396 + const overrideRecord = getOccurrenceRecord(rule, d('2024-03-16T09:00:00Z')); 397 + expect(overrideRecord).toBeUndefined(); 398 + }); 399 + }); 400 + 401 + describe('computeNextOccurrence — DST exact UTC values', () => { 402 + it('spring-forward: 9 AM ET on 2024-03-10 → 2024-03-10T13:00:00Z (EDT=UTC-4)', () => { 403 + const rule: RecurrenceRule = { 404 + rule: { type: 'daily', time: { type: 'wall_time', hour: 9, minute: 0, timezone: 'America/New_York' } }, 405 + }; 406 + const next = computeNextOccurrence(rule, d('2024-03-09T14:00:00Z')); 407 + // After spring-forward, EDT = UTC-4, so 9 AM EDT = 13:00 UTC 408 + expect(next?.toISOString()).toBe('2024-03-10T13:00:00.000Z'); 409 + }); 410 + 411 + it('fall-back: 1:30 AM ET on 2024-11-03 → 2024-11-03T06:30:00Z (EST=UTC-5)', () => { 412 + // This test is the same as the existing fall-back test — verifying the exact UTC value 413 + const rule: RecurrenceRule = { 414 + rule: { type: 'daily', time: { type: 'wall_time', hour: 1, minute: 30, timezone: 'America/New_York' } }, 415 + }; 416 + const next = computeNextOccurrence(rule, d('2024-11-02T09:00:00Z')); 417 + expect(next?.toISOString()).toBe('2024-11-03T06:30:00.000Z'); 418 + }); 419 + }); 420 + 421 + describe('computeNextOccurrence — yearly_on_month_day', () => { 422 + it('fires on Feb 14 annually for 3 consecutive years', () => { 423 + const rule: RecurrenceRule = { 424 + rule: { 425 + type: 'yearly_on_month_day', 426 + month: 2, 427 + dayOfMonth: 14, 428 + time: { type: 'wall_time', hour: 12, minute: 0, timezone: 'UTC' }, 429 + }, 430 + }; 431 + 432 + const first = computeNextOccurrence(rule, d('2024-01-01T00:00:00Z')); 433 + expect(first?.toISOString()).toBe('2024-02-14T12:00:00.000Z'); 434 + 435 + const second = computeNextOccurrence(rule, first!); 436 + expect(second?.toISOString()).toBe('2025-02-14T12:00:00.000Z'); 437 + 438 + const third = computeNextOccurrence(rule, second!); 439 + expect(third?.toISOString()).toBe('2026-02-14T12:00:00.000Z'); 440 + }); 441 + }); 442 + 443 + describe('computeNextOccurrence — yearly_nth_weekday', () => { 444 + it('fires on the 3rd Monday of October each year', () => { 445 + const rule: RecurrenceRule = { 446 + rule: { 447 + type: 'yearly_nth_weekday', 448 + month: 10, 449 + nth: 3, 450 + weekday: 1, // Monday 451 + time: { type: 'wall_time', hour: 9, minute: 0, timezone: 'UTC' }, 452 + }, 453 + }; 454 + 455 + // 3rd Monday of Oct 2024 = Oct 21 456 + const next2024 = computeNextOccurrence(rule, d('2024-01-01T00:00:00Z')); 457 + expect(next2024?.toISOString()).toBe('2024-10-21T09:00:00.000Z'); 458 + 459 + // 3rd Monday of Oct 2025 = Oct 20 460 + const next2025 = computeNextOccurrence(rule, next2024!); 461 + expect(next2025?.toISOString()).toBe('2025-10-20T09:00:00.000Z'); 462 + }); 463 + }); 464 + 465 + describe('computeNextOccurrence — monthly_last_business_day', () => { 466 + it('fires on Dec 29 2023 (last=Sat, so last business day is Fri Dec 29)', () => { 467 + const rule: RecurrenceRule = { 468 + rule: { 469 + type: 'monthly_last_business_day', 470 + time: { type: 'wall_time', hour: 17, minute: 0, timezone: 'UTC' }, 471 + }, 472 + }; 473 + const next = computeNextOccurrence(rule, d('2023-12-01T00:00:00Z')); 474 + expect(next?.toISOString()).toBe('2023-12-29T17:00:00.000Z'); 475 + }); 476 + 477 + it('fires on Feb 29 2024 (leap year, Feb 29 is a Thursday)', () => { 478 + const rule: RecurrenceRule = { 479 + rule: { 480 + type: 'monthly_last_business_day', 481 + time: { type: 'wall_time', hour: 17, minute: 0, timezone: 'UTC' }, 482 + }, 483 + }; 484 + const next = computeNextOccurrence(rule, d('2024-02-01T00:00:00Z')); 485 + expect(next?.toISOString()).toBe('2024-02-29T17:00:00.000Z'); 486 + }); 487 + }); 488 + 489 + describe('computeNextOccurrence — quarterly_last_weekday', () => { 490 + it('fires on last Friday of each quarter-end month (Mar, Jun, Sep, Dec)', () => { 491 + const rule: RecurrenceRule = { 492 + rule: { 493 + type: 'quarterly_last_weekday', 494 + weekday: 5, // Friday 495 + time: { type: 'wall_time', hour: 16, minute: 0, timezone: 'UTC' }, 496 + }, 497 + }; 498 + 499 + // Last Friday of Mar 2024 = Mar 29 500 + const q1 = computeNextOccurrence(rule, d('2024-01-01T00:00:00Z')); 501 + expect(q1?.toISOString()).toBe('2024-03-29T16:00:00.000Z'); 502 + 503 + // Last Friday of Jun 2024 = Jun 28 504 + const q2 = computeNextOccurrence(rule, q1!); 505 + expect(q2?.toISOString()).toBe('2024-06-28T16:00:00.000Z'); 506 + 507 + // Last Friday of Sep 2024 = Sep 27 508 + const q3 = computeNextOccurrence(rule, q2!); 509 + expect(q3?.toISOString()).toBe('2024-09-27T16:00:00.000Z'); 510 + 511 + // Last Friday of Dec 2024 = Dec 27 512 + const q4 = computeNextOccurrence(rule, q3!); 513 + expect(q4?.toISOString()).toBe('2024-12-27T16:00:00.000Z'); 514 + }); 515 + });
+196
packages/recurrence/src/engine.ts
··· 1 + // ABOUTME: Main recurrence engine — computes the next occurrence after a given date 2 + 3 + import type { RecurrenceRule, RecurrenceRuleCore, OnceRule, TimeSpec } from './types.js'; 4 + import { realizeTime, getTimezone, todayInZone, compareDates } from './time.js'; 5 + import { getRuleForDate } from './revisions.js'; 6 + import { checkExceptions } from './exceptions.js'; 7 + import { dailyCandidates } from './rules/daily.js'; 8 + import { weeklyCandidates } from './rules/weekly.js'; 9 + import { monthlyOnDayCandidates, monthlyNthWeekdayCandidates, monthlyLastBusinessDayCandidates } from './rules/monthly.js'; 10 + import { yearlyOnMonthDayCandidates, yearlyNthWeekdayCandidates } from './rules/yearly.js'; 11 + import { quarterlyLastWeekdayCandidates } from './rules/quarterly.js'; 12 + 13 + const MAX_CANDIDATES = 10000; 14 + 15 + /** 16 + * Generate raw occurrence date strings (YYYY-MM-DD) from a rule core, starting from startDate. 17 + * Does NOT apply exceptions or revisions — those are applied in the engine. 18 + */ 19 + function* generateCandidateDates(rule: RecurrenceRuleCore, startDate: string): Generator<string> { 20 + switch (rule.type) { 21 + case 'daily': 22 + yield* dailyCandidates(rule, startDate); 23 + break; 24 + case 'weekly': 25 + yield* weeklyCandidates(rule, startDate); 26 + break; 27 + case 'monthly_on_day': 28 + yield* monthlyOnDayCandidates(rule, startDate); 29 + break; 30 + case 'monthly_nth_weekday': 31 + yield* monthlyNthWeekdayCandidates(rule, startDate); 32 + break; 33 + case 'monthly_last_business_day': 34 + yield* monthlyLastBusinessDayCandidates(rule, startDate); 35 + break; 36 + case 'yearly_on_month_day': 37 + yield* yearlyOnMonthDayCandidates(rule, startDate); 38 + break; 39 + case 'yearly_nth_weekday': 40 + yield* yearlyNthWeekdayCandidates(rule, startDate); 41 + break; 42 + case 'quarterly_last_weekday': 43 + yield* quarterlyLastWeekdayCandidates(rule, startDate); 44 + break; 45 + default: 46 + throw new Error(`Unsupported rule type: ${(rule as RecurrenceRuleCore).type}`); 47 + } 48 + } 49 + 50 + /** 51 + * Get the time spec from a rule core (all repeating rules have a `time` field). 52 + */ 53 + function getTimeSpec(rule: RecurrenceRuleCore): TimeSpec { 54 + return (rule as { time: TimeSpec }).time; 55 + } 56 + 57 + /** 58 + * Compute the next UTC occurrence after `after`. 59 + * 60 + * @param fullRule - The complete RecurrenceRule 61 + * @param after - Find occurrences strictly after this date 62 + * @returns The next UTC Date, or null if the series is exhausted 63 + */ 64 + export function computeNextOccurrence(fullRule: RecurrenceRule, after: Date): Date | null { 65 + // Special-case: once rules fire exactly once 66 + if (fullRule.rule.type === 'once') { 67 + const fireDate = new Date((fullRule.rule as OnceRule).datetime); 68 + return fireDate > after ? fireDate : null; 69 + } 70 + 71 + // Determine the base timezone from the base rule's time spec 72 + const baseTimezone = getTimezone(getTimeSpec(fullRule.rule)); 73 + 74 + // Convert `after` to a date string in the rule's timezone 75 + const afterDateStr = todayInZone(after, baseTimezone); 76 + 77 + // Start searching from the day of `after` (inclusive — we'll filter by time below) 78 + let searchStartDate = afterDateStr; 79 + 80 + // Respect startDate constraint 81 + if (fullRule.startDate && compareDates(fullRule.startDate, searchStartDate) > 0) { 82 + searchStartDate = fullRule.startDate; 83 + } 84 + 85 + const exceptions = fullRule.exceptions ?? []; 86 + let fireCount = 0; 87 + const maxCount = fullRule.count; 88 + 89 + // When count is specified and startDate is given, we must count from startDate 90 + // to properly enforce the total count limit (not just the count since `after`). 91 + const generatorStartDate = (maxCount !== undefined && fullRule.startDate) 92 + ? fullRule.startDate 93 + : searchStartDate; 94 + 95 + // We need to iterate candidates. Because of revisions, the rule can change per date, 96 + // so we use the base rule to generate candidate dates, then look up the governing rule 97 + // to compute the actual UTC time. 98 + let candidateCount = 0; 99 + 100 + // We generate from the base rule, but when revisions apply, the candidate dates may differ. 101 + // Strategy: generate from base rule, check exceptions, apply revision's time spec for the time. 102 + const baseRule = fullRule.rule; 103 + const gen = generateCandidateDates(baseRule, generatorStartDate); 104 + 105 + for (const date of gen) { 106 + if (candidateCount++ > MAX_CANDIDATES) break; 107 + 108 + // Apply endDate constraint 109 + if (fullRule.endDate && compareDates(date, fullRule.endDate) > 0) { 110 + return null; 111 + } 112 + 113 + // Apply count constraint (we need to count all occurrences from the beginning, 114 + // but since we're searching for "next after", we approximate by limiting candidates) 115 + if (maxCount !== undefined && fireCount >= maxCount) { 116 + return null; 117 + } 118 + 119 + // Check exceptions 120 + const exResult = checkExceptions(exceptions, date); 121 + 122 + if (exResult.cancelled) { 123 + // This date is cancelled — skip it but still count toward count limit 124 + fireCount++; 125 + continue; 126 + } 127 + 128 + if (exResult.movedTo) { 129 + // This occurrence is moved to a different datetime 130 + const movedDate = exResult.movedTo; 131 + fireCount++; 132 + 133 + // Check endDate against the moved date 134 + if (fullRule.endDate) { 135 + const movedDateStr = todayInZone(movedDate, baseTimezone); 136 + if (compareDates(movedDateStr, fullRule.endDate) > 0) { 137 + return null; 138 + } 139 + } 140 + 141 + if (movedDate > after) { 142 + return movedDate; 143 + } 144 + continue; 145 + } 146 + 147 + // Determine governing rule for this date (may be a revision) 148 + const governingRule = getRuleForDate(fullRule, date); 149 + const timeSpec = exResult.overrideTime ?? getTimeSpec(governingRule); 150 + 151 + // Realize the UTC datetime for this date 152 + let occurrence: Date; 153 + try { 154 + occurrence = realizeTime(date, timeSpec); 155 + } catch { 156 + // Invalid date/timezone combination — skip 157 + fireCount++; 158 + continue; 159 + } 160 + 161 + fireCount++; 162 + 163 + // Must be strictly after `after` 164 + if (occurrence > after) { 165 + return occurrence; 166 + } 167 + } 168 + 169 + return null; 170 + } 171 + 172 + /** 173 + * Get the override record for a specific occurrence, if an override_payload exception applies. 174 + * 175 + * @param rule - The complete RecurrenceRule 176 + * @param occurrenceDate - The scheduled UTC datetime for this occurrence 177 + * @returns The override record, or undefined if no override_payload exception matches 178 + */ 179 + export function getOccurrenceRecord( 180 + rule: RecurrenceRule, 181 + occurrenceDate: Date, 182 + ): Record<string, unknown> | undefined { 183 + if (!rule.exceptions?.length) return undefined; 184 + 185 + // Convert occurrence date to a date string in the rule's timezone 186 + let dateStr: string; 187 + if (rule.rule.type === 'once') { 188 + dateStr = todayInZone(occurrenceDate, 'UTC'); 189 + } else { 190 + const timezone = getTimezone(getTimeSpec(rule.rule)); 191 + dateStr = todayInZone(occurrenceDate, timezone); 192 + } 193 + 194 + const exResult = checkExceptions(rule.exceptions, dateStr); 195 + return exResult.overrideRecord; 196 + }
+41
packages/recurrence/src/exceptions.ts
··· 1 + // ABOUTME: Exception handling for recurrence rules (cancel, move, override_time, override_payload) 2 + 3 + import type { RecurrenceException, CancelException, MoveException, OverrideTimeException, OverridePayloadException, TimeSpec } from './types.js'; 4 + 5 + export interface ExceptionResult { 6 + cancelled: boolean; 7 + movedTo?: Date; // If moved, the new UTC datetime 8 + overrideTime?: TimeSpec; // If time overridden 9 + overrideRecord?: Record<string, unknown>; // If payload overridden 10 + } 11 + 12 + /** 13 + * Check exceptions for a given occurrence date (YYYY-MM-DD). 14 + * Returns the exception result for that date. 15 + */ 16 + export function checkExceptions(exceptions: RecurrenceException[], date: string): ExceptionResult { 17 + for (const ex of exceptions) { 18 + if (ex.date !== date) continue; 19 + 20 + if (ex.type === 'cancel') { 21 + return { cancelled: true }; 22 + } 23 + if (ex.type === 'move') { 24 + return { cancelled: false, movedTo: new Date((ex as MoveException).newDatetime) }; 25 + } 26 + if (ex.type === 'override_time') { 27 + return { cancelled: false, overrideTime: (ex as OverrideTimeException).time }; 28 + } 29 + if (ex.type === 'override_payload') { 30 + return { cancelled: false, overrideRecord: (ex as OverridePayloadException).record }; 31 + } 32 + } 33 + return { cancelled: false }; 34 + } 35 + 36 + /** 37 + * Check if a given date has a cancelled exception. 38 + */ 39 + export function isCancelled(exceptions: RecurrenceException[], date: string): boolean { 40 + return exceptions.some(ex => ex.type === 'cancel' && (ex as CancelException).date === date); 41 + }
+25
packages/recurrence/src/index.ts
··· 1 + // ABOUTME: Public API for @newpublic/recurrence 2 + 3 + export { computeNextOccurrence, getOccurrenceRecord } from './engine.js'; 4 + export type { 5 + RecurrenceRule, 6 + RecurrenceRuleCore, 7 + DailyRule, 8 + WeeklyRule, 9 + MonthlyOnDayRule, 10 + MonthlyNthWeekdayRule, 11 + OnceRule, 12 + YearlyOnMonthDayRule, 13 + YearlyNthWeekdayRule, 14 + MonthlyLastBusinessDayRule, 15 + QuarterlyLastWeekdayRule, 16 + TimeSpec, 17 + WallTime, 18 + FixedInstant, 19 + RecurrenceException, 20 + CancelException, 21 + MoveException, 22 + OverrideTimeException, 23 + OverridePayloadException, 24 + RecurrenceRevision, 25 + } from './types.js';
+36
packages/recurrence/src/revisions.ts
··· 1 + // ABOUTME: Revision selection logic — determines which revision governs a given date 2 + 3 + import type { RecurrenceRule, RecurrenceRuleCore, RecurrenceRevision } from './types.js'; 4 + import { compareDates } from './time.js'; 5 + 6 + /** 7 + * Find the governing rule for a given occurrence date. 8 + * Revisions are sorted by effectiveFromDate; the latest revision whose 9 + * effectiveFromDate <= occurrenceDate governs that occurrence. 10 + * If no revision applies, the base rule is used. 11 + */ 12 + export function getRuleForDate(fullRule: RecurrenceRule, occurrenceDate: string): RecurrenceRuleCore { 13 + const revisions = fullRule.revisions ?? []; 14 + if (revisions.length === 0) return fullRule.rule; 15 + 16 + // Sort revisions by effectiveFromDate descending, then find the first one <= occurrenceDate 17 + const sorted = [...revisions].sort((a, b) => compareDates(b.effectiveFromDate, a.effectiveFromDate)); 18 + for (const rev of sorted) { 19 + if (compareDates(rev.effectiveFromDate, occurrenceDate) <= 0) { 20 + return rev.rule; 21 + } 22 + } 23 + return fullRule.rule; 24 + } 25 + 26 + /** 27 + * Get the earliest possible revision that could affect occurrences after a given date. 28 + * Used to determine what start date to use for the search. 29 + */ 30 + export function getLatestRevisionBefore(revisions: RecurrenceRevision[], beforeDate: string): RecurrenceRevision | null { 31 + const applicable = revisions.filter(r => compareDates(r.effectiveFromDate, beforeDate) <= 0); 32 + if (applicable.length === 0) return null; 33 + return applicable.reduce((latest, r) => 34 + compareDates(r.effectiveFromDate, latest.effectiveFromDate) > 0 ? r : latest, 35 + ); 36 + }
+19
packages/recurrence/src/rules/daily.ts
··· 1 + // ABOUTME: Daily recurrence rule — fires every N days at a given time 2 + 3 + import type { DailyRule } from '../types.js'; 4 + import { getTimezone, addDays } from '../time.js'; 5 + 6 + /** 7 + * Given a start date and an interval, generate candidate dates for a daily rule. 8 + * Yields dates in ascending order, starting from startDate. 9 + */ 10 + export function* dailyCandidates(rule: DailyRule, startDate: string): Generator<string> { 11 + const interval = rule.interval ?? 1; 12 + const timezone = getTimezone(rule.time); 13 + let current = startDate; 14 + // Safety: don't yield more than 3650 candidates (10 years of daily) 15 + for (let i = 0; i < 3650; i++) { 16 + yield current; 17 + current = addDays(current, interval, timezone); 18 + } 19 + }
+77
packages/recurrence/src/rules/helpers.ts
··· 1 + // ABOUTME: Shared date helpers for monthly and yearly recurrence rule generators 2 + 3 + import { DateTime } from 'luxon'; 4 + 5 + /** 6 + * Get the ISO date string for a specific day-of-month in a given month. 7 + * Clamps to the last day of the month if dayOfMonth > month length. 8 + */ 9 + export function nthDayOfMonth(year: number, month: number, dayOfMonth: number, timezone: string): string { 10 + const lastDay = DateTime.fromObject({ year, month }, { zone: timezone }).daysInMonth!; 11 + const day = Math.min(dayOfMonth, lastDay); 12 + return DateTime.fromObject({ year, month, day }, { zone: timezone }).toISODate()!; 13 + } 14 + 15 + /** 16 + * Get the ISO date for the Nth weekday of a month. 17 + * nth=1 means first, nth=2 means second, ..., nth=-1 means last. 18 + * weekday: 0=Sunday, 1=Monday, ..., 6=Saturday 19 + */ 20 + export function nthWeekdayOfMonth(year: number, month: number, nth: number, weekday: number, timezone: string): string | null { 21 + const dt = DateTime.fromObject({ year, month, day: 1 }, { zone: timezone }); 22 + const daysInMonth = dt.daysInMonth!; 23 + 24 + if (nth === -1) { 25 + for (let day = daysInMonth; day >= 1; day--) { 26 + const candidate = DateTime.fromObject({ year, month, day }, { zone: timezone }); 27 + if (candidate.weekday % 7 === weekday) { 28 + return candidate.toISODate()!; 29 + } 30 + } 31 + return null; 32 + } 33 + 34 + let count = 0; 35 + for (let day = 1; day <= daysInMonth; day++) { 36 + const candidate = DateTime.fromObject({ year, month, day }, { zone: timezone }); 37 + if (candidate.weekday % 7 === weekday) { 38 + count++; 39 + if (count === nth) { 40 + return candidate.toISODate()!; 41 + } 42 + } 43 + } 44 + return null; 45 + } 46 + 47 + /** 48 + * Get the ISO date for the last business day (Mon–Fri) of a month. 49 + * Walks backward from the last day of the month. 50 + */ 51 + export function lastBusinessDayOfMonth(year: number, month: number, timezone: string): string { 52 + const lastDay = DateTime.fromObject({ year, month }, { zone: timezone }).daysInMonth!; 53 + for (let day = lastDay; day >= 1; day--) { 54 + const candidate = DateTime.fromObject({ year, month, day }, { zone: timezone }); 55 + // Luxon weekday: 1=Monday, 7=Sunday; 1-5 = Mon-Fri 56 + if (candidate.weekday >= 1 && candidate.weekday <= 5) { 57 + return candidate.toISODate()!; 58 + } 59 + } 60 + /* istanbul ignore next */ 61 + return DateTime.fromObject({ year, month, day: 1 }, { zone: timezone }).toISODate()!; 62 + } 63 + 64 + /** 65 + * Get the ISO date for the last occurrence of a specific weekday in a month. 66 + * weekday: 0=Sunday, 1=Monday, ..., 6=Saturday 67 + */ 68 + export function lastWeekdayOfMonth(year: number, month: number, weekday: number, timezone: string): string | null { 69 + const lastDay = DateTime.fromObject({ year, month }, { zone: timezone }).daysInMonth!; 70 + for (let day = lastDay; day >= 1; day--) { 71 + const candidate = DateTime.fromObject({ year, month, day }, { zone: timezone }); 72 + if (candidate.weekday % 7 === weekday) { 73 + return candidate.toISODate()!; 74 + } 75 + } 76 + return null; 77 + }
+75
packages/recurrence/src/rules/monthly.ts
··· 1 + // ABOUTME: Monthly recurrence rules — monthly_on_day, monthly_nth_weekday, monthly_last_business_day 2 + 3 + import { DateTime } from 'luxon'; 4 + import type { MonthlyOnDayRule, MonthlyNthWeekdayRule, MonthlyLastBusinessDayRule } from '../types.js'; 5 + import { getTimezone } from '../time.js'; 6 + import { nthDayOfMonth, nthWeekdayOfMonth, lastBusinessDayOfMonth } from './helpers.js'; 7 + 8 + /** 9 + * Generate candidate dates for monthly_on_day rule. 10 + */ 11 + export function* monthlyOnDayCandidates(rule: MonthlyOnDayRule, startDate: string): Generator<string> { 12 + const interval = rule.interval ?? 1; 13 + const timezone = getTimezone(rule.time); 14 + 15 + const startDt = DateTime.fromISO(startDate, { zone: timezone }); 16 + let year = startDt.year; 17 + let month = startDt.month; 18 + 19 + for (let i = 0; i < 1200; i++) { // Up to 100 years of monthly 20 + const candidate = nthDayOfMonth(year, month, rule.dayOfMonth, timezone); 21 + if (candidate >= startDate) { 22 + yield candidate; 23 + } 24 + 25 + const nextDt = DateTime.fromObject({ year, month }, { zone: timezone }).plus({ months: interval }); 26 + year = nextDt.year; 27 + month = nextDt.month; 28 + } 29 + } 30 + 31 + /** 32 + * Generate candidate dates for monthly_nth_weekday rule. 33 + */ 34 + export function* monthlyNthWeekdayCandidates(rule: MonthlyNthWeekdayRule, startDate: string): Generator<string> { 35 + const interval = rule.interval ?? 1; 36 + const timezone = getTimezone(rule.time); 37 + 38 + const startDt = DateTime.fromISO(startDate, { zone: timezone }); 39 + let year = startDt.year; 40 + let month = startDt.month; 41 + 42 + for (let i = 0; i < 1200; i++) { 43 + const candidate = nthWeekdayOfMonth(year, month, rule.nth, rule.weekday, timezone); 44 + if (candidate !== null && candidate >= startDate) { 45 + yield candidate; 46 + } 47 + 48 + const nextDt = DateTime.fromObject({ year, month }, { zone: timezone }).plus({ months: interval }); 49 + year = nextDt.year; 50 + month = nextDt.month; 51 + } 52 + } 53 + 54 + /** 55 + * Generate candidate dates for monthly_last_business_day rule. 56 + */ 57 + export function* monthlyLastBusinessDayCandidates(rule: MonthlyLastBusinessDayRule, startDate: string): Generator<string> { 58 + const interval = rule.interval ?? 1; 59 + const timezone = getTimezone(rule.time); 60 + 61 + const startDt = DateTime.fromISO(startDate, { zone: timezone }); 62 + let year = startDt.year; 63 + let month = startDt.month; 64 + 65 + for (let i = 0; i < 1200; i++) { 66 + const candidate = lastBusinessDayOfMonth(year, month, timezone); 67 + if (candidate >= startDate) { 68 + yield candidate; 69 + } 70 + 71 + const nextDt = DateTime.fromObject({ year, month }, { zone: timezone }).plus({ months: interval }); 72 + year = nextDt.year; 73 + month = nextDt.month; 74 + } 75 + }
+52
packages/recurrence/src/rules/quarterly.ts
··· 1 + // ABOUTME: Quarterly recurrence rules — quarterly_last_weekday 2 + 3 + import { DateTime } from 'luxon'; 4 + import type { QuarterlyLastWeekdayRule } from '../types.js'; 5 + import { getTimezone } from '../time.js'; 6 + import { lastWeekdayOfMonth } from './helpers.js'; 7 + 8 + // Quarter-end months 9 + const QUARTER_END_MONTHS = [3, 6, 9, 12]; 10 + 11 + /** 12 + * Generate candidate dates for quarterly_last_weekday rule. 13 + * Fires on the last occurrence of a specific weekday in each quarter-end month (Mar, Jun, Sep, Dec). 14 + */ 15 + export function* quarterlyLastWeekdayCandidates(rule: QuarterlyLastWeekdayRule, startDate: string): Generator<string> { 16 + const interval = rule.interval ?? 1; 17 + const timezone = getTimezone(rule.time); 18 + 19 + const startDt = DateTime.fromISO(startDate, { zone: timezone }); 20 + let year = startDt.year; 21 + 22 + // Find which quarter-end month to start from 23 + let quarterIdx = QUARTER_END_MONTHS.findIndex(m => m >= startDt.month); 24 + if (quarterIdx === -1) { 25 + quarterIdx = 0; 26 + year++; 27 + } 28 + 29 + for (let i = 0; i < 400; i++) { // 100 years at quarterly 30 + const month = QUARTER_END_MONTHS[quarterIdx]; 31 + const candidate = lastWeekdayOfMonth(year, month, rule.weekday, timezone); 32 + if (candidate !== null && candidate >= startDate) { 33 + yield candidate; 34 + } 35 + 36 + // Advance by interval quarters 37 + let nextMonth = month + interval * 3; 38 + while (nextMonth > 12) { 39 + nextMonth -= 12; 40 + year++; 41 + } 42 + quarterIdx = QUARTER_END_MONTHS.indexOf(nextMonth); 43 + if (quarterIdx === -1) { 44 + // interval took us off a quarter-end month — find next valid quarter 45 + quarterIdx = QUARTER_END_MONTHS.findIndex(m => m >= nextMonth); 46 + if (quarterIdx === -1) { 47 + quarterIdx = 0; 48 + year++; 49 + } 50 + } 51 + } 52 + }
+43
packages/recurrence/src/rules/weekly.ts
··· 1 + // ABOUTME: Weekly recurrence rule — fires on specific days of the week, every N weeks 2 + 3 + import type { WeeklyRule } from '../types.js'; 4 + import { getTimezone, addDays, dayOfWeek, addWeeks } from '../time.js'; 5 + import { DateTime } from 'luxon'; 6 + 7 + /** 8 + * Given a start date, generate candidate dates matching the weekly rule's daysOfWeek. 9 + * The interval applies to the week boundary (every N weeks of the same day pattern). 10 + */ 11 + export function* weeklyCandidates(rule: WeeklyRule, startDate: string): Generator<string> { 12 + const interval = rule.interval ?? 1; 13 + const timezone = getTimezone(rule.time); 14 + const daysOfWeek = [...rule.daysOfWeek].sort(); 15 + 16 + if (daysOfWeek.length === 0) return; 17 + 18 + // Find the start of the week containing startDate (Sunday = 0) 19 + const startDt = DateTime.fromISO(startDate, { zone: timezone }); 20 + const startDow = startDt.weekday % 7; // 0=Sunday, 1=Monday, ... 6=Saturday 21 + 22 + // Find first candidate on or after startDate 23 + // First, try the current week 24 + let weekStart = startDate; 25 + // Go back to Sunday of current week 26 + const daysFromSunday = startDow; 27 + const sundayOfWeek = addDays(startDate, -daysFromSunday, timezone); 28 + weekStart = sundayOfWeek; 29 + 30 + let safety = 0; 31 + while (safety++ < 3650) { 32 + // Generate candidates for this week 33 + const weekCandidates = daysOfWeek 34 + .map(dow => addDays(weekStart, dow, timezone)) 35 + .filter(d => d >= startDate); 36 + 37 + for (const candidate of weekCandidates) { 38 + yield candidate; 39 + } 40 + 41 + weekStart = addWeeks(weekStart, interval, timezone); 42 + } 43 + }
+46
packages/recurrence/src/rules/yearly.ts
··· 1 + // ABOUTME: Yearly recurrence rules — yearly_on_month_day and yearly_nth_weekday 2 + 3 + import { DateTime } from 'luxon'; 4 + import type { YearlyOnMonthDayRule, YearlyNthWeekdayRule } from '../types.js'; 5 + import { getTimezone } from '../time.js'; 6 + import { nthDayOfMonth, nthWeekdayOfMonth } from './helpers.js'; 7 + 8 + /** 9 + * Generate candidate dates for yearly_on_month_day rule. 10 + */ 11 + export function* yearlyOnMonthDayCandidates(rule: YearlyOnMonthDayRule, startDate: string): Generator<string> { 12 + const interval = rule.interval ?? 1; 13 + const timezone = getTimezone(rule.time); 14 + 15 + const startDt = DateTime.fromISO(startDate, { zone: timezone }); 16 + let year = startDt.year; 17 + 18 + for (let i = 0; i < 200; i++) { // Up to 200 years 19 + const candidate = nthDayOfMonth(year, rule.month, rule.dayOfMonth, timezone); 20 + if (candidate >= startDate) { 21 + yield candidate; 22 + } 23 + 24 + year += interval; 25 + } 26 + } 27 + 28 + /** 29 + * Generate candidate dates for yearly_nth_weekday rule. 30 + */ 31 + export function* yearlyNthWeekdayCandidates(rule: YearlyNthWeekdayRule, startDate: string): Generator<string> { 32 + const interval = rule.interval ?? 1; 33 + const timezone = getTimezone(rule.time); 34 + 35 + const startDt = DateTime.fromISO(startDate, { zone: timezone }); 36 + let year = startDt.year; 37 + 38 + for (let i = 0; i < 200; i++) { 39 + const candidate = nthWeekdayOfMonth(year, rule.month, rule.nth, rule.weekday, timezone); 40 + if (candidate !== null && candidate >= startDate) { 41 + yield candidate; 42 + } 43 + 44 + year += interval; 45 + } 46 + }
+131
packages/recurrence/src/time.ts
··· 1 + // ABOUTME: Time realization helpers for wall_time and fixed_instant specs using luxon 2 + 3 + import { DateTime } from 'luxon'; 4 + import type { TimeSpec, WallTime, FixedInstant } from './types.js'; 5 + 6 + /** 7 + * Given a date string (YYYY-MM-DD) and a WallTime spec, compute the UTC Date for that occurrence. 8 + * DST policy: 9 + * - Gap (spring-forward): use the post-transition time (Luxon's default) 10 + * - Overlap (fall-back): use the earlier (pre-transition / pre-DST-end) occurrence 11 + */ 12 + export function wallTimeToUtc(date: string, spec: WallTime): Date { 13 + const second = spec.second ?? 0; 14 + const dt = DateTime.fromObject( 15 + { 16 + year: parseInt(date.substring(0, 4), 10), 17 + month: parseInt(date.substring(5, 7), 10), 18 + day: parseInt(date.substring(8, 10), 10), 19 + hour: spec.hour, 20 + minute: spec.minute, 21 + second, 22 + }, 23 + { zone: spec.timezone }, 24 + ); 25 + 26 + if (!dt.isValid) { 27 + throw new Error(`Invalid date/timezone combination: ${date} in ${spec.timezone}`); 28 + } 29 + 30 + return dt.toJSDate(); 31 + } 32 + 33 + /** 34 + * Given a date string (YYYY-MM-DD) and a FixedInstant spec, compute the UTC Date. 35 + */ 36 + export function fixedInstantToUtc(date: string, spec: FixedInstant): Date { 37 + const second = spec.second ?? 0; 38 + const year = parseInt(date.substring(0, 4), 10); 39 + const month = parseInt(date.substring(5, 7), 10); 40 + const day = parseInt(date.substring(8, 10), 10); 41 + // Build a UTC date at the given offset 42 + const offsetHours = Math.trunc(spec.utcOffsetMinutes / 60); 43 + const offsetMins = Math.abs(spec.utcOffsetMinutes % 60); 44 + const sign = spec.utcOffsetMinutes >= 0 ? '+' : '-'; 45 + const padded = (n: number) => String(Math.abs(n)).padStart(2, '0'); 46 + const isoStr = `${year}-${padded(month)}-${padded(day)}T${padded(spec.hour)}:${padded(spec.minute)}:${padded(second)}${sign}${padded(offsetHours)}:${padded(offsetMins)}`; 47 + return new Date(isoStr); 48 + } 49 + 50 + /** 51 + * Realize a TimeSpec for a given date (YYYY-MM-DD) into a UTC Date. 52 + */ 53 + export function realizeTime(date: string, spec: TimeSpec): Date { 54 + if (spec.type === 'wall_time') { 55 + return wallTimeToUtc(date, spec); 56 + } 57 + return fixedInstantToUtc(date, spec as FixedInstant); 58 + } 59 + 60 + /** 61 + * Get the IANA timezone from a TimeSpec (for date arithmetic). 62 + */ 63 + export function getTimezone(spec: TimeSpec): string { 64 + if (spec.type === 'wall_time') { 65 + return (spec as WallTime).timezone; 66 + } 67 + return 'UTC'; 68 + } 69 + 70 + /** 71 + * Format a UTC Date as an ISO date string (YYYY-MM-DD) in the given timezone. 72 + */ 73 + export function dateInZone(utcDate: Date, timezone: string): string { 74 + const dt = DateTime.fromJSDate(utcDate, { zone: timezone }); 75 + return dt.toISODate()!; 76 + } 77 + 78 + /** 79 + * Return a luxon DateTime for the given date string in the given timezone, 80 + * positioned at the start of the day. 81 + */ 82 + export function startOfDay(date: string, timezone: string): DateTime { 83 + return DateTime.fromISO(date, { zone: timezone }).startOf('day'); 84 + } 85 + 86 + /** 87 + * Add days to a date string, returning the new date string. 88 + */ 89 + export function addDays(date: string, days: number, timezone: string): string { 90 + const dt = DateTime.fromISO(date, { zone: timezone }).plus({ days }); 91 + return dt.toISODate()!; 92 + } 93 + 94 + /** 95 + * Add weeks to a date string. 96 + */ 97 + export function addWeeks(date: string, weeks: number, timezone: string): string { 98 + const dt = DateTime.fromISO(date, { zone: timezone }).plus({ weeks }); 99 + return dt.toISODate()!; 100 + } 101 + 102 + /** 103 + * Add months to a date string. 104 + */ 105 + export function addMonths(date: string, months: number, timezone: string): string { 106 + const dt = DateTime.fromISO(date, { zone: timezone }).plus({ months }); 107 + return dt.toISODate()!; 108 + } 109 + 110 + /** 111 + * Get the ISO date string for today in the given timezone. 112 + */ 113 + export function todayInZone(utcNow: Date, timezone: string): string { 114 + return DateTime.fromJSDate(utcNow, { zone: timezone }).toISODate()!; 115 + } 116 + 117 + /** 118 + * Compare two ISO date strings. Returns negative if a < b, 0 if equal, positive if a > b. 119 + */ 120 + export function compareDates(a: string, b: string): number { 121 + return a < b ? -1 : a > b ? 1 : 0; 122 + } 123 + 124 + /** 125 + * Get day of week (0=Sunday) for a date string in a timezone. 126 + */ 127 + export function dayOfWeek(date: string, timezone: string): number { 128 + const dt = DateTime.fromISO(date, { zone: timezone }); 129 + // luxon weekday: 1=Monday, 7=Sunday 130 + return dt.weekday % 7; // 0=Sunday, 1=Monday, ..., 6=Saturday 131 + }
+168
packages/recurrence/src/types.ts
··· 1 + // ABOUTME: TypeScript types for the recurrence rule schema 2 + 3 + /** 4 + * A wall-clock time in a specific IANA timezone. 5 + * Uses the following DST policy: 6 + * - Gap (spring-forward): use the post-transition time (skip stays skipped) 7 + * - Overlap (fall-back): use the earlier (pre-transition) occurrence 8 + */ 9 + export interface WallTime { 10 + type: 'wall_time'; 11 + hour: number; // 0-23 12 + minute: number; // 0-59 13 + second?: number; // 0-59, default 0 14 + timezone: string; // IANA timezone string, e.g. "America/New_York" 15 + } 16 + 17 + /** 18 + * A fixed UTC instant for the time-of-day component. 19 + * The date component comes from the recurrence rule; the time is always this UTC offset. 20 + */ 21 + export interface FixedInstant { 22 + type: 'fixed_instant'; 23 + utcOffsetMinutes: number; // e.g. -300 for UTC-5 24 + hour: number; 25 + minute: number; 26 + second?: number; 27 + } 28 + 29 + export type TimeSpec = WallTime | FixedInstant; 30 + 31 + // ---- Exception types ---- 32 + 33 + export interface CancelException { 34 + type: 'cancel'; 35 + date: string; // ISO date YYYY-MM-DD (in rule's timezone for wall_time rules) 36 + } 37 + 38 + export interface MoveException { 39 + type: 'move'; 40 + date: string; // Original date (ISO date) 41 + newDatetime: string; // New ISO 8601 datetime (UTC) 42 + } 43 + 44 + export interface OverrideTimeException { 45 + type: 'override_time'; 46 + date: string; // Original date (ISO date) 47 + time: TimeSpec; // Replacement time spec 48 + } 49 + 50 + export interface OverridePayloadException { 51 + type: 'override_payload'; 52 + date: string; // Original date (ISO date) 53 + record: Record<string, unknown>; // Override record content 54 + } 55 + 56 + export type RecurrenceException = 57 + | CancelException 58 + | MoveException 59 + | OverrideTimeException 60 + | OverridePayloadException; 61 + 62 + // ---- Revision types ---- 63 + 64 + export interface RecurrenceRevision { 65 + id: string; // UUID 66 + effectiveFromDate: string; // ISO date YYYY-MM-DD — this revision governs occurrences on/after this date 67 + rule: RecurrenceRuleCore; // The rule for this revision 68 + } 69 + 70 + // ---- Core rule types ---- 71 + 72 + export interface DailyRule { 73 + type: 'daily'; 74 + interval?: number; // Every N days, default 1 75 + time: TimeSpec; 76 + } 77 + 78 + export interface WeeklyRule { 79 + type: 'weekly'; 80 + interval?: number; // Every N weeks, default 1 81 + daysOfWeek: number[]; // 0=Sunday, 1=Monday, ... 6=Saturday 82 + time: TimeSpec; 83 + } 84 + 85 + export interface MonthlyOnDayRule { 86 + type: 'monthly_on_day'; 87 + interval?: number; // Every N months, default 1 88 + dayOfMonth: number; // 1-31 (clamped to month end if > month length) 89 + time: TimeSpec; 90 + } 91 + 92 + export interface MonthlyNthWeekdayRule { 93 + type: 'monthly_nth_weekday'; 94 + interval?: number; // Every N months, default 1 95 + nth: number; // 1-4 (positive) or -1 (last) 96 + weekday: number; // 0=Sunday, 1=Monday, ... 6=Saturday 97 + time: TimeSpec; 98 + } 99 + 100 + export interface OnceRule { 101 + type: 'once'; 102 + datetime: string; // ISO 8601 UTC — "2024-03-15T14:30:00Z" 103 + } 104 + 105 + export interface YearlyOnMonthDayRule { 106 + type: 'yearly_on_month_day'; 107 + interval?: number; // default 1 108 + month: number; // 1-12 109 + dayOfMonth: number; // 1-31, clamped to month end 110 + time: TimeSpec; 111 + } 112 + 113 + export interface YearlyNthWeekdayRule { 114 + type: 'yearly_nth_weekday'; 115 + interval?: number; // default 1 116 + month: number; // 1-12 117 + nth: number; // 1-4, or -1 for last 118 + weekday: number; // 0=Sun ... 6=Sat 119 + time: TimeSpec; 120 + } 121 + 122 + export interface MonthlyLastBusinessDayRule { 123 + type: 'monthly_last_business_day'; 124 + interval?: number; // default 1 125 + time: TimeSpec; 126 + } 127 + 128 + export interface QuarterlyLastWeekdayRule { 129 + type: 'quarterly_last_weekday'; 130 + interval?: number; // default 1 (every quarter) 131 + weekday: number; // 0=Sun ... 6=Sat 132 + time: TimeSpec; 133 + } 134 + 135 + export type RecurrenceRuleCore = 136 + | DailyRule 137 + | WeeklyRule 138 + | MonthlyOnDayRule 139 + | MonthlyNthWeekdayRule 140 + | OnceRule 141 + | YearlyOnMonthDayRule 142 + | YearlyNthWeekdayRule 143 + | MonthlyLastBusinessDayRule 144 + | QuarterlyLastWeekdayRule; 145 + 146 + /** 147 + * Full recurrence rule with optional revisions and exceptions. 148 + * Stored as JSON in schedules.recurrence_rule. 149 + */ 150 + export interface RecurrenceRule { 151 + // The current/primary rule (may be superseded by a revision) 152 + rule: RecurrenceRuleCore; 153 + 154 + // Optional start date: first occurrence must be on or after this date (ISO date) 155 + startDate?: string; 156 + 157 + // Optional end date: no occurrences after this date (ISO date) 158 + endDate?: string; 159 + 160 + // Optional max occurrences 161 + count?: number; 162 + 163 + // Revisions supersede the base rule from a given effective date 164 + revisions?: RecurrenceRevision[]; 165 + 166 + // Exceptions modify or cancel specific occurrences 167 + exceptions?: RecurrenceException[]; 168 + }
+17
packages/recurrence/tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "target": "ES2022", 4 + "module": "commonjs", 5 + "lib": ["ES2022"], 6 + "outDir": "./dist", 7 + "rootDir": "./src", 8 + "strict": true, 9 + "esModuleInterop": true, 10 + "skipLibCheck": true, 11 + "declaration": true, 12 + "declarationMap": true, 13 + "sourceMap": true 14 + }, 15 + "include": ["src/**/*"], 16 + "exclude": ["src/__tests__/**/*", "dist"] 17 + }
+65 -1
src/__tests__/oauth.test.ts
··· 283 283 expect(result.tokenSet.token_type).toBe('DPoP'); 284 284 }); 285 285 286 + it('get parses ISO string expires_at and converts to Unix ms timestamp', async () => { 287 + const { getUserAuthorization } = jest.requireMock('../storage') as { 288 + getOAuthState: jest.Mock; 289 + saveOAuthState: jest.Mock; 290 + deleteOAuthState: jest.Mock; 291 + getUserAuthorization: jest.Mock; 292 + upsertUserAuthorization: jest.Mock; 293 + }; 294 + const isoExpiry = '2026-06-01T12:00:00.000Z'; 295 + const expectedMs = new Date(isoExpiry).getTime(); 296 + const dpopBlob = JSON.stringify({ 297 + jwk: { kty: 'EC', crv: 'P-256' }, 298 + aud: 'https://pds.host.example.com', 299 + access_token: 'valid-token', 300 + expires_at: isoExpiry, 301 + }); 302 + getUserAuthorization.mockResolvedValueOnce({ 303 + pds_url: 'https://pds.example.com', 304 + refresh_token: `enc:mytoken`, 305 + dpop_private_key: `enc:${dpopBlob}`, 306 + token_scope: 'atproto', 307 + auth_type: 'oauth', 308 + user_did: 'did:plc:x', 309 + created_at: 0, 310 + updated_at: 0, 311 + }); 312 + const sessionStore = getSessionStore(); 313 + const result = await sessionStore.get('did:plc:x') as { 314 + tokenSet: { expires_at: number; access_token: string }; 315 + }; 316 + expect(result).toBeDefined(); 317 + expect(result.tokenSet.expires_at).toBe(expectedMs); 318 + expect(result.tokenSet.access_token).toBe('valid-token'); 319 + }); 320 + 321 + it('get parses numeric expires_at directly as Unix ms', async () => { 322 + const { getUserAuthorization } = jest.requireMock('../storage') as { 323 + getUserAuthorization: jest.Mock; 324 + }; 325 + const numericExpiry = 1748779200000; // some future Unix ms 326 + const dpopBlob = JSON.stringify({ 327 + jwk: { kty: 'EC' }, 328 + aud: 'https://pds.host.example.com', 329 + access_token: 'valid-token', 330 + expires_at: numericExpiry, 331 + }); 332 + getUserAuthorization.mockResolvedValueOnce({ 333 + pds_url: 'https://pds.example.com', 334 + refresh_token: `enc:mytoken`, 335 + dpop_private_key: `enc:${dpopBlob}`, 336 + token_scope: 'atproto', 337 + auth_type: 'oauth', 338 + user_did: 'did:plc:x', 339 + created_at: 0, 340 + updated_at: 0, 341 + }); 342 + const sessionStore = getSessionStore(); 343 + const result = await sessionStore.get('did:plc:x') as { 344 + tokenSet: { expires_at: number }; 345 + }; 346 + expect(result).toBeDefined(); 347 + expect(result.tokenSet.expires_at).toBe(numericExpiry); 348 + }); 349 + 286 350 it('get handles legacy format (bare JWK, no jwk or aud keys)', async () => { 287 351 const { getUserAuthorization } = jest.requireMock('../storage') as { 288 352 getOAuthState: jest.Mock; ··· 375 439 // encrypt mock prefixes with 'enc:' 376 440 expect(callArgs.refreshToken).toBe('enc:mytoken'); 377 441 expect(callArgs.dpopPrivateKey).toBe( 378 - `enc:${JSON.stringify({ jwk: { kty: 'EC', crv: 'P-256' }, aud: 'https://pds.host.example.com' })}`, 442 + `enc:${JSON.stringify({ jwk: { kty: 'EC', crv: 'P-256' }, aud: 'https://pds.host.example.com', access_token: '', expires_at: null })}`, 379 443 ); 380 444 expect(callArgs.tokenScope).toBe('atproto'); 381 445 });
+445
src/__tests__/scheduler.test.ts
··· 12 12 getReadyDrafts, 13 13 upsertUserAuthorization, 14 14 storeDraftBlob, 15 + createSchedule, 16 + getRawSchedule, 17 + updateScheduleNextDraft, 15 18 } = storage; 16 19 import { setOAuthClient } from '../oauth'; 17 20 import { publishDraft, startScheduler, stopScheduler, notifyScheduler } from '../scheduler'; ··· 635 638 }); 636 639 }); 637 640 641 + // ---- Schedule chaining tests ---- 642 + 643 + describe('schedule chaining (handleScheduleChaining)', () => { 644 + const DAILY_RULE = { 645 + rule: { type: 'daily', time: { type: 'wall_time', hour: 9, minute: 0, timezone: 'UTC' } }, 646 + }; 647 + 648 + beforeEach(async () => { 649 + await upsertUserAuthorization({ 650 + userDid: 'did:plc:alice', 651 + pdsUrl: 'https://pds.example.com', 652 + refreshToken: 'encrypted-token', 653 + dpopPrivateKey: 'encrypted-key', 654 + tokenScope: 'atproto', 655 + }); 656 + }); 657 + 658 + function mockOAuthAndAgent() { 659 + const mockCreateRecord = jest.fn().mockResolvedValue({ data: {} }); 660 + getOAuthClient.mockReturnValue({ 661 + restore: jest.fn().mockResolvedValue({ 662 + sub: 'did:plc:alice', 663 + serverMetadata: { issuer: 'https://pds.example.com' }, 664 + }), 665 + }); 666 + Agent.mockReturnValue({ 667 + com: { atproto: { repo: { createRecord: mockCreateRecord } } }, 668 + uploadBlob: jest.fn(), 669 + }); 670 + return mockCreateRecord; 671 + } 672 + 673 + it('creates next scheduled draft after publishing a schedule-linked draft', async () => { 674 + mockOAuthAndAgent(); 675 + const scheduleId = 'sched-chain-1'; 676 + await createSchedule({ 677 + id: scheduleId, 678 + userDid: 'did:plc:alice', 679 + collection: 'app.bsky.feed.post', 680 + record: { $type: 'app.bsky.feed.post', text: 'recurring' }, 681 + contentUrl: null, 682 + recurrenceRule: DAILY_RULE, 683 + timezone: 'UTC', 684 + }); 685 + 686 + const draftUri = 'at://did:plc:alice/app.bsky.feed.post/chain-draft-1'; 687 + await createDraft({ 688 + uri: draftUri, 689 + userDid: 'did:plc:alice', 690 + collection: 'app.bsky.feed.post', 691 + rkey: 'chain-draft-1', 692 + record: { $type: 'app.bsky.feed.post', text: 'recurring' }, 693 + recordCid: 'bafychain1', 694 + action: 'create', 695 + scheduledAt: Date.now() - 1000, 696 + scheduleId, 697 + }); 698 + await updateScheduleNextDraft(scheduleId, draftUri); 699 + 700 + await publishDraft(draftUri, config); 701 + 702 + // Verify fire count incremented 703 + const schedule = await getRawSchedule(scheduleId); 704 + expect(schedule?.fire_count).toBe(1); 705 + expect(schedule?.last_fired_at).toBeDefined(); 706 + 707 + // Verify a new next draft was created 708 + expect(schedule?.next_draft_uri).not.toBe(draftUri); 709 + expect(schedule?.next_draft_uri).toBeDefined(); 710 + 711 + // Verify the new draft exists and is scheduled 712 + const nextDraft = await getDraft(schedule!.next_draft_uri!); 713 + expect(nextDraft?.status).toBe('scheduled'); 714 + expect(nextDraft?.scheduleId).toBe(scheduleId); 715 + }); 716 + 717 + it('does not chain when schedule status is not active (paused)', async () => { 718 + mockOAuthAndAgent(); 719 + const scheduleId = 'sched-chain-paused'; 720 + await createSchedule({ 721 + id: scheduleId, 722 + userDid: 'did:plc:alice', 723 + collection: 'app.bsky.feed.post', 724 + record: { $type: 'app.bsky.feed.post', text: 'paused' }, 725 + contentUrl: null, 726 + recurrenceRule: DAILY_RULE, 727 + timezone: 'UTC', 728 + }); 729 + 730 + // Manually pause the schedule 731 + await db 732 + .updateTable('schedules') 733 + .set({ status: 'paused' }) 734 + .where('id', '=', scheduleId) 735 + .execute(); 736 + 737 + const draftUri = 'at://did:plc:alice/app.bsky.feed.post/chain-paused-draft'; 738 + await createDraft({ 739 + uri: draftUri, 740 + userDid: 'did:plc:alice', 741 + collection: 'app.bsky.feed.post', 742 + rkey: 'chain-paused-draft', 743 + record: { $type: 'app.bsky.feed.post', text: 'paused' }, 744 + recordCid: 'bafychainpaused', 745 + action: 'create', 746 + scheduledAt: Date.now() - 1000, 747 + scheduleId, 748 + }); 749 + 750 + await publishDraft(draftUri, config); 751 + 752 + const draft = await getDraft(draftUri); 753 + expect(draft?.status).toBe('published'); 754 + 755 + const schedule = await getRawSchedule(scheduleId); 756 + // Fire count should NOT be incremented (schedule was paused) 757 + expect(schedule?.fire_count).toBe(0); 758 + }); 759 + 760 + it('marks schedule cancelled when series is exhausted (no next occurrence)', async () => { 761 + mockOAuthAndAgent(); 762 + const scheduleId = 'sched-chain-exhausted'; 763 + // Use endDate in the past so computeNextOccurrence returns null 764 + const exhaustedRule = { 765 + rule: { type: 'daily', time: { type: 'wall_time', hour: 9, minute: 0, timezone: 'UTC' } }, 766 + endDate: '2020-01-01', 767 + }; 768 + await createSchedule({ 769 + id: scheduleId, 770 + userDid: 'did:plc:alice', 771 + collection: 'app.bsky.feed.post', 772 + record: { $type: 'app.bsky.feed.post', text: 'exhausted' }, 773 + contentUrl: null, 774 + recurrenceRule: exhaustedRule, 775 + timezone: 'UTC', 776 + }); 777 + 778 + const draftUri = 'at://did:plc:alice/app.bsky.feed.post/chain-exhausted-draft'; 779 + await createDraft({ 780 + uri: draftUri, 781 + userDid: 'did:plc:alice', 782 + collection: 'app.bsky.feed.post', 783 + rkey: 'chain-exhausted-draft', 784 + record: { $type: 'app.bsky.feed.post', text: 'exhausted' }, 785 + recordCid: 'bafychainexhausted', 786 + action: 'create', 787 + scheduledAt: Date.now() - 1000, 788 + scheduleId, 789 + }); 790 + 791 + await publishDraft(draftUri, config); 792 + 793 + const schedule = await getRawSchedule(scheduleId); 794 + expect(schedule?.status).toBe('cancelled'); 795 + expect(schedule?.next_draft_uri).toBeNull(); 796 + }); 797 + 798 + it('marks schedule as error when recurrence rule JSON is invalid', async () => { 799 + mockOAuthAndAgent(); 800 + const scheduleId = 'sched-chain-badrule'; 801 + await createSchedule({ 802 + id: scheduleId, 803 + userDid: 'did:plc:alice', 804 + collection: 'app.bsky.feed.post', 805 + record: { $type: 'app.bsky.feed.post', text: 'bad rule' }, 806 + contentUrl: null, 807 + recurrenceRule: DAILY_RULE, 808 + timezone: 'UTC', 809 + }); 810 + 811 + // Corrupt the recurrence rule JSON directly in the DB 812 + await db 813 + .updateTable('schedules') 814 + .set({ recurrence_rule: 'not-valid-json{{{' }) 815 + .where('id', '=', scheduleId) 816 + .execute(); 817 + 818 + const draftUri = 'at://did:plc:alice/app.bsky.feed.post/chain-badrule-draft'; 819 + await createDraft({ 820 + uri: draftUri, 821 + userDid: 'did:plc:alice', 822 + collection: 'app.bsky.feed.post', 823 + rkey: 'chain-badrule-draft', 824 + record: { $type: 'app.bsky.feed.post', text: 'bad rule' }, 825 + recordCid: 'bafychainbadrule', 826 + action: 'create', 827 + scheduledAt: Date.now() - 1000, 828 + scheduleId, 829 + }); 830 + 831 + await publishDraft(draftUri, config); 832 + 833 + const schedule = await getRawSchedule(scheduleId); 834 + expect(schedule?.status).toBe('error'); 835 + }); 836 + 837 + it('fetches content_url at publish time for dynamic schedules', async () => { 838 + const scheduleId = 'sched-dynamic-1'; 839 + await createSchedule({ 840 + id: scheduleId, 841 + userDid: 'did:plc:alice', 842 + collection: 'app.bsky.feed.post', 843 + record: null, 844 + contentUrl: 'https://example.com/dynamic-content', 845 + recurrenceRule: DAILY_RULE, 846 + timezone: 'UTC', 847 + }); 848 + 849 + const dynamicRecord = { $type: 'app.bsky.feed.post', text: 'dynamic content from url' }; 850 + const mockCreateRecord = jest.fn().mockResolvedValue({ data: {} }); 851 + getOAuthClient.mockReturnValue({ 852 + restore: jest.fn().mockResolvedValue({ 853 + sub: 'did:plc:alice', 854 + serverMetadata: { issuer: 'https://pds.example.com' }, 855 + }), 856 + }); 857 + Agent.mockReturnValue({ 858 + com: { atproto: { repo: { createRecord: mockCreateRecord } } }, 859 + uploadBlob: jest.fn(), 860 + }); 861 + 862 + // Mock fetch to return dynamic record content 863 + global.fetch = jest.fn().mockResolvedValue({ 864 + ok: true, 865 + json: jest.fn().mockResolvedValue(dynamicRecord), 866 + }); 867 + 868 + const draftUri = 'at://did:plc:alice/app.bsky.feed.post/dynamic-draft-1'; 869 + await createDraft({ 870 + uri: draftUri, 871 + userDid: 'did:plc:alice', 872 + collection: 'app.bsky.feed.post', 873 + rkey: 'dynamic-draft-1', 874 + record: null, // null record → dynamic schedule 875 + recordCid: null, 876 + action: 'create', 877 + scheduledAt: Date.now() - 1000, 878 + scheduleId, 879 + }); 880 + 881 + await publishDraft(draftUri, config); 882 + 883 + expect(global.fetch).toHaveBeenCalled(); 884 + const fetchedUrl = (global.fetch as jest.Mock).mock.calls[0][0] as string; 885 + expect(fetchedUrl).toContain('https://example.com/dynamic-content'); 886 + expect(fetchedUrl).toContain('fireCount=1'); 887 + expect(fetchedUrl).toContain('scheduledAt='); 888 + 889 + // Draft should be published with the fetched content 890 + const draft = await getDraft(draftUri); 891 + expect(draft?.status).toBe('published'); 892 + 893 + // The createRecord should have been called with the fetched record 894 + expect(mockCreateRecord).toHaveBeenCalledWith( 895 + expect.objectContaining({ 896 + record: expect.objectContaining({ text: 'dynamic content from url' }), 897 + }), 898 + ); 899 + }); 900 + 901 + it('marks draft and schedule as error when content_url fetch fails', async () => { 902 + const scheduleId = 'sched-dynamic-fail'; 903 + await createSchedule({ 904 + id: scheduleId, 905 + userDid: 'did:plc:alice', 906 + collection: 'app.bsky.feed.post', 907 + record: null, 908 + contentUrl: 'https://example.com/failing-content', 909 + recurrenceRule: DAILY_RULE, 910 + timezone: 'UTC', 911 + }); 912 + 913 + getOAuthClient.mockReturnValue({ 914 + restore: jest.fn().mockResolvedValue({ 915 + sub: 'did:plc:alice', 916 + serverMetadata: { issuer: 'https://pds.example.com' }, 917 + }), 918 + }); 919 + Agent.mockReturnValue({ 920 + com: { atproto: { repo: { createRecord: jest.fn() } } }, 921 + uploadBlob: jest.fn(), 922 + }); 923 + 924 + // Mock fetch to simulate HTTP error 925 + global.fetch = jest.fn().mockResolvedValue({ 926 + ok: false, 927 + status: 503, 928 + }); 929 + 930 + const draftUri = 'at://did:plc:alice/app.bsky.feed.post/dynamic-fail-draft'; 931 + await createDraft({ 932 + uri: draftUri, 933 + userDid: 'did:plc:alice', 934 + collection: 'app.bsky.feed.post', 935 + rkey: 'dynamic-fail-draft', 936 + record: null, 937 + recordCid: null, 938 + action: 'create', 939 + scheduledAt: Date.now() - 1000, 940 + scheduleId, 941 + }); 942 + 943 + await publishDraft(draftUri, config); 944 + 945 + const draft = await getDraft(draftUri); 946 + expect(draft?.status).toBe('failed'); 947 + expect(draft?.failureReason).toContain('content_url_fetch_failed'); 948 + 949 + const schedule = await getRawSchedule(scheduleId); 950 + expect(schedule?.status).toBe('error'); 951 + }); 952 + 953 + it('marks draft failed when dynamic schedule has no content_url', async () => { 954 + const scheduleId = 'sched-dynamic-nocontent'; 955 + await createSchedule({ 956 + id: scheduleId, 957 + userDid: 'did:plc:alice', 958 + collection: 'app.bsky.feed.post', 959 + record: null, 960 + contentUrl: null, // no content URL set 961 + recurrenceRule: DAILY_RULE, 962 + timezone: 'UTC', 963 + }); 964 + 965 + getOAuthClient.mockReturnValue({ 966 + restore: jest.fn().mockResolvedValue({ 967 + sub: 'did:plc:alice', 968 + serverMetadata: { issuer: 'https://pds.example.com' }, 969 + }), 970 + }); 971 + Agent.mockReturnValue({ 972 + com: { atproto: { repo: { createRecord: jest.fn() } } }, 973 + }); 974 + 975 + const draftUri = 'at://did:plc:alice/app.bsky.feed.post/dynamic-nocontent-draft'; 976 + await createDraft({ 977 + uri: draftUri, 978 + userDid: 'did:plc:alice', 979 + collection: 'app.bsky.feed.post', 980 + rkey: 'dynamic-nocontent-draft', 981 + record: null, // null record triggers dynamic schedule path 982 + recordCid: null, 983 + action: 'create', 984 + scheduledAt: Date.now() - 1000, 985 + scheduleId, 986 + }); 987 + 988 + await publishDraft(draftUri, config); 989 + 990 + const draft = await getDraft(draftUri); 991 + expect(draft?.status).toBe('failed'); 992 + expect(draft?.failureReason).toBe('dynamic_schedule_missing_content_url'); 993 + }); 994 + 995 + it('marks schedule as error when createDraft throws during next draft creation', async () => { 996 + mockOAuthAndAgent(); 997 + const scheduleId = 'sched-chain-createfail'; 998 + await createSchedule({ 999 + id: scheduleId, 1000 + userDid: 'did:plc:alice', 1001 + collection: 'app.bsky.feed.post', 1002 + record: { $type: 'app.bsky.feed.post', text: 'recurring' }, 1003 + contentUrl: null, 1004 + recurrenceRule: DAILY_RULE, 1005 + timezone: 'UTC', 1006 + }); 1007 + 1008 + const draftUri = 'at://did:plc:alice/app.bsky.feed.post/chain-createfail-1'; 1009 + await createDraft({ 1010 + uri: draftUri, 1011 + userDid: 'did:plc:alice', 1012 + collection: 'app.bsky.feed.post', 1013 + rkey: 'chain-createfail-1', 1014 + record: { $type: 'app.bsky.feed.post', text: 'recurring' }, 1015 + recordCid: 'bafychain-cf', 1016 + action: 'create', 1017 + scheduledAt: Date.now() - 1000, 1018 + scheduleId, 1019 + }); 1020 + await updateScheduleNextDraft(scheduleId, draftUri); 1021 + 1022 + // Make createDraft fail on the next call (for creating the chained next draft) 1023 + const spy = jest.spyOn(storage, 'createDraft').mockRejectedValueOnce(new Error('DB constraint')); 1024 + 1025 + await publishDraft(draftUri, config); 1026 + 1027 + spy.mockRestore(); 1028 + 1029 + const schedule = await getRawSchedule(scheduleId); 1030 + expect(schedule?.status).toBe('error'); 1031 + }); 1032 + 1033 + it('catches errors thrown by handleScheduleChaining itself (non-fatal chain error path)', async () => { 1034 + mockOAuthAndAgent(); 1035 + const scheduleId = 'sched-chain-throw'; 1036 + await createSchedule({ 1037 + id: scheduleId, 1038 + userDid: 'did:plc:alice', 1039 + collection: 'app.bsky.feed.post', 1040 + record: { $type: 'app.bsky.feed.post', text: 'recurring' }, 1041 + contentUrl: null, 1042 + recurrenceRule: DAILY_RULE, 1043 + timezone: 'UTC', 1044 + }); 1045 + 1046 + const draftUri = 'at://did:plc:alice/app.bsky.feed.post/chain-throw-1'; 1047 + await createDraft({ 1048 + uri: draftUri, 1049 + userDid: 'did:plc:alice', 1050 + collection: 'app.bsky.feed.post', 1051 + rkey: 'chain-throw-1', 1052 + record: { $type: 'app.bsky.feed.post', text: 'recurring' }, 1053 + recordCid: 'bafychain-throw', 1054 + action: 'create', 1055 + scheduledAt: Date.now() - 1000, 1056 + scheduleId, 1057 + }); 1058 + await updateScheduleNextDraft(scheduleId, draftUri); 1059 + 1060 + // Make incrementScheduleFireCount throw — handleScheduleChaining has no try/catch around it, 1061 + // so this propagates to the outer catch in publishDraft (line 302) 1062 + const spy = jest.spyOn(storage, 'incrementScheduleFireCount').mockRejectedValueOnce(new Error('Unexpected DB error')); 1063 + 1064 + // publishDraft should NOT throw — the error is caught and logged non-fatally 1065 + await expect(publishDraft(draftUri, config)).resolves.toBeUndefined(); 1066 + 1067 + spy.mockRestore(); 1068 + 1069 + // The draft itself should still be published (chaining error is non-fatal) 1070 + const draft = await getDraft(draftUri); 1071 + expect(draft?.status).toBe('published'); 1072 + }); 1073 + }); 1074 + 638 1075 describe('poll error handling', () => { 639 1076 it('should not throw when getReadyDrafts fails during a poll', async () => { 640 1077 // Seed auth so scheduleNextWakeup has a past-due draft to schedule against ··· 767 1204 768 1205 setTimeoutSpy.mockRestore(); 769 1206 clearTimeoutSpy.mockRestore(); 1207 + }); 1208 + 1209 + it('handles superseded wakeup generation gracefully with no scheduled drafts (notifyScheduler noop)', async () => { 1210 + // When stopScheduler is called, scheduleNextWakeup returns early 1211 + // This test just verifies notifyScheduler doesn't throw when scheduler is stopped 1212 + stopScheduler(); 1213 + notifyScheduler(); 1214 + // No assertions needed — just verifying no throws 770 1215 }); 771 1216 772 1217 it('handles superseded wakeup generation gracefully', async () => {
+84 -2
src/__tests__/schema.test.ts
··· 1 1 // Tests for schema utility functions 2 2 3 - import { rowToDraftView, extractDidFromAtUri } from '../schema'; 4 - import type { DraftRow } from '../schema'; 3 + import { rowToDraftView, extractDidFromAtUri, rowToScheduleView } from '../schema'; 4 + import type { DraftRow, ScheduleRow } from '../schema'; 5 5 6 6 describe('rowToDraftView', () => { 7 7 const baseRow = (): DraftRow => ({ ··· 19 19 updated_at: 1700000000000, 20 20 published_at: null, 21 21 failure_reason: null, 22 + trigger_key_hash: null, 23 + trigger_key_encrypted: null, 24 + schedule_id: null, 22 25 }); 23 26 24 27 it('maps required fields', () => { ··· 59 62 it('includes failureReason when set', () => { 60 63 const row = { ...baseRow(), failure_reason: 'network error' }; 61 64 expect(rowToDraftView(row).failureReason).toBe('network error'); 65 + }); 66 + }); 67 + 68 + describe('rowToScheduleView', () => { 69 + const baseScheduleRow = (): ScheduleRow => ({ 70 + id: 'sched-uuid-1', 71 + user_did: 'did:plc:alice', 72 + collection: 'app.bsky.feed.post', 73 + record: JSON.stringify({ $type: 'app.bsky.feed.post', text: 'scheduled post' }), 74 + content_url: null, 75 + recurrence_rule: JSON.stringify({ rule: { type: 'daily', time: { type: 'wall_time', hour: 9, minute: 0, timezone: 'UTC' } } }), 76 + timezone: 'UTC', 77 + status: 'active', 78 + fire_count: 3, 79 + created_at: 1700000000000, 80 + updated_at: 1700001000000, 81 + last_fired_at: 1700000500000, 82 + next_draft_uri: 'at://did:plc:alice/app.bsky.feed.post/next1', 83 + }); 84 + 85 + it('maps required fields', () => { 86 + const view = rowToScheduleView(baseScheduleRow()); 87 + expect(view.id).toBe('sched-uuid-1'); 88 + expect(view.collection).toBe('app.bsky.feed.post'); 89 + expect(view.status).toBe('active'); 90 + expect(view.timezone).toBe('UTC'); 91 + expect(view.fireCount).toBe(3); 92 + }); 93 + 94 + it('parses recurrenceRule JSON', () => { 95 + const view = rowToScheduleView(baseScheduleRow()); 96 + expect(view.recurrenceRule).toEqual({ 97 + rule: { type: 'daily', time: { type: 'wall_time', hour: 9, minute: 0, timezone: 'UTC' } }, 98 + }); 99 + }); 100 + 101 + it('includes ISO timestamps', () => { 102 + const view = rowToScheduleView(baseScheduleRow()); 103 + expect(view.createdAt).toBe(new Date(1700000000000).toISOString()); 104 + expect(view.updatedAt).toBe(new Date(1700001000000).toISOString()); 105 + }); 106 + 107 + it('includes lastFiredAt when set', () => { 108 + const view = rowToScheduleView(baseScheduleRow()); 109 + expect(view.lastFiredAt).toBe(new Date(1700000500000).toISOString()); 110 + }); 111 + 112 + it('omits lastFiredAt when null', () => { 113 + const row = { ...baseScheduleRow(), last_fired_at: null }; 114 + expect(rowToScheduleView(row).lastFiredAt).toBeUndefined(); 115 + }); 116 + 117 + it('includes nextDraftUri when set', () => { 118 + const view = rowToScheduleView(baseScheduleRow()); 119 + expect(view.nextDraftUri).toBe('at://did:plc:alice/app.bsky.feed.post/next1'); 120 + }); 121 + 122 + it('omits nextDraftUri when null', () => { 123 + const row = { ...baseScheduleRow(), next_draft_uri: null }; 124 + expect(rowToScheduleView(row).nextDraftUri).toBeUndefined(); 125 + }); 126 + 127 + it('includes contentUrl when set', () => { 128 + const row = { ...baseScheduleRow(), content_url: 'https://example.com/content' }; 129 + expect(rowToScheduleView(row).contentUrl).toBe('https://example.com/content'); 130 + }); 131 + 132 + it('omits contentUrl when null', () => { 133 + expect(rowToScheduleView(baseScheduleRow()).contentUrl).toBeUndefined(); 134 + }); 135 + 136 + it('parses record JSON when set', () => { 137 + const view = rowToScheduleView(baseScheduleRow()); 138 + expect(view.record).toEqual({ $type: 'app.bsky.feed.post', text: 'scheduled post' }); 139 + }); 140 + 141 + it('omits record when null', () => { 142 + const row = { ...baseScheduleRow(), record: null }; 143 + expect(rowToScheduleView(row).record).toBeUndefined(); 62 144 }); 63 145 }); 64 146
+702 -1
src/__tests__/server.test.ts
··· 18 18 jest.mock('../storage', () => ({ 19 19 createDraft: jest.fn(), 20 20 getDraft: jest.fn(), 21 + getDraftRawRow: jest.fn(), 22 + getDraftByTriggerKeyHash: jest.fn(), 21 23 listDrafts: jest.fn(), 22 24 scheduleDraft: jest.fn(), 23 25 updateDraft: jest.fn(), ··· 26 28 getUserAuthorization: jest.fn(), 27 29 deleteUserData: jest.fn(), 28 30 countActiveDraftsForUser: jest.fn(), 31 + createSchedule: jest.fn(), 32 + getSchedule: jest.fn(), 33 + getRawSchedule: jest.fn(), 34 + listSchedules: jest.fn(), 35 + updateSchedule: jest.fn(), 36 + updateScheduleStatus: jest.fn(), 37 + updateScheduleNextDraft: jest.fn(), 38 + deleteSchedule: jest.fn(), 29 39 })); 30 40 31 41 jest.mock('../scheduler', () => ({ ··· 50 60 // --------------------------------------------------------------------------- 51 61 52 62 import { verifyRequestAuth, extractBearerToken, extractPdsUrlFromToken, verifyDpopBoundToken } from '../auth'; 53 - import { createDraft, getDraft, listDrafts, scheduleDraft, updateDraft, cancelDraft, storeDraftBlob, getUserAuthorization, countActiveDraftsForUser, deleteUserData } from '../storage'; 63 + import { createDraft, getDraft, getDraftRawRow, getDraftByTriggerKeyHash, listDrafts, scheduleDraft, updateDraft, cancelDraft, storeDraftBlob, getUserAuthorization, countActiveDraftsForUser, deleteUserData, createSchedule, getSchedule, getRawSchedule, listSchedules, updateSchedule, updateScheduleNextDraft, deleteSchedule } from '../storage'; 54 64 import { publishDraft, notifyScheduler } from '../scheduler'; 55 65 56 66 const mockVerifyRequestAuth = verifyRequestAuth as jest.Mock; ··· 59 69 const mockVerifyDpopBoundToken = verifyDpopBoundToken as jest.Mock; 60 70 const mockCreateDraft = createDraft as jest.Mock; 61 71 const mockGetDraft = getDraft as jest.Mock; 72 + const mockGetDraftRawRow = getDraftRawRow as jest.Mock; 62 73 const mockListDrafts = listDrafts as jest.Mock; 63 74 const mockScheduleDraft = scheduleDraft as jest.Mock; 64 75 const mockUpdateDraft = updateDraft as jest.Mock; ··· 92 103 mockVerifyRequestAuth.mockResolvedValue({ did: USER_DID }); 93 104 mockVerifyDpopBoundToken.mockResolvedValue({ did: USER_DID }); 94 105 mockCountActiveDraftsForUser.mockResolvedValue(0); 106 + // Default: no trigger key, no schedule association 107 + mockGetDraftRawRow.mockResolvedValue(null); 95 108 } 96 109 97 110 const DRAFT_VIEW = { ··· 825 838 it('returns 404 for unknown routes', async () => { 826 839 const res = await request(app).get('/totally/unknown/route'); 827 840 expect(res.status).toBe(404); 841 + }); 842 + }); 843 + 844 + // ---- Webhook trigger ---- 845 + 846 + describe('POST /triggers/:key', () => { 847 + beforeEach(() => { 848 + mockAuth(); 849 + }); 850 + 851 + it('returns 404 when trigger key not found', async () => { 852 + (getDraftByTriggerKeyHash as jest.Mock).mockResolvedValue(null); 853 + const res = await request(app).post('/triggers/unknownkey'); 854 + expect(res.status).toBe(404); 855 + expect(res.body.error).toBe('NotFound'); 856 + }); 857 + 858 + it('returns 409 when draft is already in terminal state', async () => { 859 + (getDraftByTriggerKeyHash as jest.Mock).mockResolvedValue({ 860 + uri: DRAFT_VIEW.uri, 861 + status: 'published', 862 + schedule_id: null, 863 + }); 864 + const res = await request(app).post('/triggers/somekey'); 865 + expect(res.status).toBe(409); 866 + expect(res.body.error).toBe('TriggerAlreadyFired'); 867 + }); 868 + 869 + it('publishes the draft and returns 200 on valid key', async () => { 870 + (getDraftByTriggerKeyHash as jest.Mock).mockResolvedValue({ 871 + uri: DRAFT_VIEW.uri, 872 + status: 'draft', 873 + schedule_id: null, 874 + }); 875 + (mockPublishDraft as jest.Mock).mockResolvedValue(undefined); 876 + mockGetDraft.mockResolvedValue({ ...DRAFT_VIEW, status: 'published' }); 877 + 878 + const res = await request(app).post('/triggers/test-trigger-key'); 879 + expect(res.status).toBe(200); 880 + expect(res.body.published).toBe(true); 881 + }); 882 + 883 + it('returns 500 when publishDraft succeeds but draft status is not published', async () => { 884 + (getDraftByTriggerKeyHash as jest.Mock).mockResolvedValue({ 885 + uri: DRAFT_VIEW.uri, 886 + status: 'draft', 887 + schedule_id: null, 888 + }); 889 + (mockPublishDraft as jest.Mock).mockResolvedValue(undefined); 890 + mockGetDraft.mockResolvedValue({ ...DRAFT_VIEW, status: 'failed', failureReason: 'Malformed token' }); 891 + 892 + const res = await request(app).post('/triggers/test-trigger-key'); 893 + expect(res.status).toBe(500); 894 + expect(res.body.error).toBe('PublishFailed'); 895 + expect(res.body.message).toBe('Malformed token'); 896 + expect(res.body.status).toBe('failed'); 897 + }); 898 + }); 899 + 900 + // ---- createRecord with x-trigger: webhook header ---- 901 + 902 + describe('com.atproto.repo.createRecord with x-trigger: webhook', () => { 903 + beforeEach(() => { 904 + mockAuth(); 905 + mockCreateDraft.mockResolvedValue(DRAFT_VIEW); 906 + }); 907 + 908 + it('returns triggerUrl in response when x-trigger: webhook header is set', async () => { 909 + const res = await request(app) 910 + .post('/xrpc/com.atproto.repo.createRecord') 911 + .set('Authorization', AUTH_HEADER) 912 + .set('x-trigger', 'webhook') 913 + .send({ repo: USER_DID, collection: 'app.bsky.feed.post', record: { $type: 'app.bsky.feed.post', text: 'hello' } }); 914 + expect(res.status).toBe(200); 915 + expect(res.body.triggerUrl).toBeDefined(); 916 + expect(res.body.triggerUrl).toMatch(/^http:\/\/localhost:1986\/triggers\//); 917 + }); 918 + 919 + it('does not include triggerUrl when x-trigger header is absent', async () => { 920 + const res = await request(app) 921 + .post('/xrpc/com.atproto.repo.createRecord') 922 + .set('Authorization', AUTH_HEADER) 923 + .send({ repo: USER_DID, collection: 'app.bsky.feed.post', record: { $type: 'app.bsky.feed.post', text: 'hello' } }); 924 + expect(res.status).toBe(200); 925 + expect(res.body.triggerUrl).toBeUndefined(); 926 + }); 927 + }); 928 + 929 + // ---- getPost returns triggerUrl when draft has webhook key ---- 930 + 931 + describe('town.roundabout.scheduledPosts.getPost with trigger key', () => { 932 + beforeEach(() => { 933 + mockAuth(); 934 + }); 935 + 936 + it('includes triggerUrl in response when draft has trigger_key_encrypted', async () => { 937 + const uri = DRAFT_VIEW.uri; 938 + mockGetDraft.mockResolvedValue(DRAFT_VIEW); 939 + // Provide a raw row with an encrypted trigger key 940 + // We encrypt using the same key as config.encryptionKey = 'a'.repeat(64) 941 + const { encrypt } = jest.requireActual('../encrypt') as typeof import('../encrypt'); 942 + const plainKey = 'test-uuid-key-1234'; 943 + const encryptedKey = encrypt(plainKey, 'a'.repeat(64)); 944 + mockGetDraftRawRow.mockResolvedValue({ 945 + uri, 946 + trigger_key_encrypted: encryptedKey, 947 + trigger_key_hash: 'somehash', 948 + schedule_id: null, 949 + }); 950 + 951 + const res = await request(app) 952 + .get(`/xrpc/town.roundabout.scheduledPosts.getPost?uri=${encodeURIComponent(uri)}`) 953 + .set('Authorization', AUTH_HEADER); 954 + expect(res.status).toBe(200); 955 + expect(res.body.triggerUrl).toBe(`http://localhost:1986/triggers/${plainKey}`); 956 + }); 957 + 958 + it('omits triggerUrl when raw row has no trigger_key_encrypted', async () => { 959 + const uri = DRAFT_VIEW.uri; 960 + mockGetDraft.mockResolvedValue(DRAFT_VIEW); 961 + mockGetDraftRawRow.mockResolvedValue({ uri, trigger_key_encrypted: null, schedule_id: null }); 962 + 963 + const res = await request(app) 964 + .get(`/xrpc/town.roundabout.scheduledPosts.getPost?uri=${encodeURIComponent(uri)}`) 965 + .set('Authorization', AUTH_HEADER); 966 + expect(res.status).toBe(200); 967 + expect(res.body.triggerUrl).toBeUndefined(); 968 + }); 969 + }); 970 + 971 + // ---- Schedule XRPC endpoints ---- 972 + 973 + describe('town.roundabout.scheduledPosts.createSchedule', () => { 974 + const DAILY_RULE = { 975 + rule: { 976 + type: 'daily', 977 + time: { type: 'wall_time', hour: 9, minute: 0, timezone: 'UTC' }, 978 + }, 979 + }; 980 + 981 + const SCHEDULE_VIEW = { 982 + id: 'sched-uuid', 983 + collection: 'app.bsky.feed.post', 984 + status: 'active', 985 + recurrenceRule: DAILY_RULE, 986 + timezone: 'UTC', 987 + fireCount: 0, 988 + createdAt: new Date().toISOString(), 989 + updatedAt: new Date().toISOString(), 990 + nextDraftUri: DRAFT_VIEW.uri, 991 + }; 992 + 993 + beforeEach(() => { 994 + mockAuth(); 995 + (createSchedule as jest.Mock).mockResolvedValue({ id: 'sched-uuid' }); 996 + mockCreateDraft.mockResolvedValue(DRAFT_VIEW); 997 + (updateScheduleNextDraft as jest.Mock).mockResolvedValue(undefined); 998 + (getSchedule as jest.Mock).mockResolvedValue(SCHEDULE_VIEW); 999 + }); 1000 + 1001 + it('creates a schedule and returns scheduleView', async () => { 1002 + const res = await request(app) 1003 + .post('/xrpc/town.roundabout.scheduledPosts.createSchedule') 1004 + .set('Authorization', AUTH_HEADER) 1005 + .send({ 1006 + collection: 'app.bsky.feed.post', 1007 + recurrenceRule: DAILY_RULE, 1008 + timezone: 'UTC', 1009 + record: { $type: 'app.bsky.feed.post', text: 'scheduled' }, 1010 + }); 1011 + expect(res.status).toBe(200); 1012 + expect(res.body.schedule).toBeDefined(); 1013 + expect(res.body.schedule.id).toBe('sched-uuid'); 1014 + }); 1015 + 1016 + it('creates a schedule with contentUrl (dynamic schedule, no record)', async () => { 1017 + const res = await request(app) 1018 + .post('/xrpc/town.roundabout.scheduledPosts.createSchedule') 1019 + .set('Authorization', AUTH_HEADER) 1020 + .send({ 1021 + collection: 'app.bsky.feed.post', 1022 + recurrenceRule: DAILY_RULE, 1023 + timezone: 'UTC', 1024 + contentUrl: 'https://example.com/dynamic-content', 1025 + }); 1026 + expect(res.status).toBe(200); 1027 + }); 1028 + 1029 + it('rejects when both record and contentUrl provided', async () => { 1030 + const res = await request(app) 1031 + .post('/xrpc/town.roundabout.scheduledPosts.createSchedule') 1032 + .set('Authorization', AUTH_HEADER) 1033 + .send({ 1034 + collection: 'app.bsky.feed.post', 1035 + recurrenceRule: DAILY_RULE, 1036 + timezone: 'UTC', 1037 + record: { $type: 'app.bsky.feed.post', text: 'hi' }, 1038 + contentUrl: 'https://example.com/content', 1039 + }); 1040 + expect(res.status).toBe(400); 1041 + }); 1042 + 1043 + it('requires auth', async () => { 1044 + mockVerifyRequestAuth.mockRejectedValue(new Error('Unauthorized')); 1045 + const res = await request(app) 1046 + .post('/xrpc/town.roundabout.scheduledPosts.createSchedule') 1047 + .send({ collection: 'app.bsky.feed.post', recurrenceRule: DAILY_RULE, timezone: 'UTC' }); 1048 + expect(res.status).toBe(401); 1049 + }); 1050 + }); 1051 + 1052 + describe('town.roundabout.scheduledPosts.listSchedules', () => { 1053 + beforeEach(() => { 1054 + mockAuth(); 1055 + (listSchedules as jest.Mock).mockResolvedValue({ schedules: [], cursor: undefined }); 1056 + }); 1057 + 1058 + it('lists schedules for the authenticated user', async () => { 1059 + const res = await request(app) 1060 + .get(`/xrpc/town.roundabout.scheduledPosts.listSchedules?repo=${USER_DID}`) 1061 + .set('Authorization', AUTH_HEADER); 1062 + expect(res.status).toBe(200); 1063 + expect(res.body.schedules).toEqual([]); 1064 + }); 1065 + 1066 + it('rejects listing another user schedules', async () => { 1067 + const res = await request(app) 1068 + .get('/xrpc/town.roundabout.scheduledPosts.listSchedules?repo=did:plc:other') 1069 + .set('Authorization', AUTH_HEADER); 1070 + expect(res.status).toBe(401); 1071 + }); 1072 + }); 1073 + 1074 + describe('town.roundabout.scheduledPosts.getSchedule', () => { 1075 + const SCHEDULE_VIEW = { 1076 + id: 'sched-uuid', 1077 + collection: 'app.bsky.feed.post', 1078 + status: 'active', 1079 + recurrenceRule: {}, 1080 + timezone: 'UTC', 1081 + fireCount: 0, 1082 + createdAt: new Date().toISOString(), 1083 + updatedAt: new Date().toISOString(), 1084 + }; 1085 + 1086 + beforeEach(() => { 1087 + mockAuth(); 1088 + (getSchedule as jest.Mock).mockResolvedValue(SCHEDULE_VIEW); 1089 + (getRawSchedule as jest.Mock).mockResolvedValue({ user_did: USER_DID }); 1090 + }); 1091 + 1092 + it('returns the schedule', async () => { 1093 + const res = await request(app) 1094 + .get('/xrpc/town.roundabout.scheduledPosts.getSchedule?id=sched-uuid') 1095 + .set('Authorization', AUTH_HEADER); 1096 + expect(res.status).toBe(200); 1097 + expect(res.body.schedule.id).toBe('sched-uuid'); 1098 + }); 1099 + 1100 + it('returns 404 for non-existent schedule', async () => { 1101 + (getSchedule as jest.Mock).mockResolvedValue(null); 1102 + const res = await request(app) 1103 + .get('/xrpc/town.roundabout.scheduledPosts.getSchedule?id=missing') 1104 + .set('Authorization', AUTH_HEADER); 1105 + expect(res.status).toBe(400); 1106 + }); 1107 + 1108 + it('rejects access to another user schedule', async () => { 1109 + (getRawSchedule as jest.Mock).mockResolvedValue({ user_did: 'did:plc:other' }); 1110 + const res = await request(app) 1111 + .get('/xrpc/town.roundabout.scheduledPosts.getSchedule?id=sched-uuid') 1112 + .set('Authorization', AUTH_HEADER); 1113 + expect(res.status).toBe(401); 1114 + }); 1115 + }); 1116 + 1117 + describe('town.roundabout.scheduledPosts.deleteSchedule', () => { 1118 + beforeEach(() => { 1119 + mockAuth(); 1120 + (getRawSchedule as jest.Mock).mockResolvedValue({ user_did: USER_DID }); 1121 + (deleteSchedule as jest.Mock).mockResolvedValue(undefined); 1122 + }); 1123 + 1124 + it('deletes the schedule', async () => { 1125 + const res = await request(app) 1126 + .post('/xrpc/town.roundabout.scheduledPosts.deleteSchedule') 1127 + .set('Authorization', AUTH_HEADER) 1128 + .send({ id: 'sched-uuid' }); 1129 + expect(res.status).toBe(200); 1130 + expect(deleteSchedule as jest.Mock).toHaveBeenCalledWith('sched-uuid'); 1131 + }); 1132 + 1133 + it('returns 404 for non-existent schedule', async () => { 1134 + (getRawSchedule as jest.Mock).mockResolvedValue(null); 1135 + const res = await request(app) 1136 + .post('/xrpc/town.roundabout.scheduledPosts.deleteSchedule') 1137 + .set('Authorization', AUTH_HEADER) 1138 + .send({ id: 'missing' }); 1139 + expect(res.status).toBe(400); 1140 + }); 1141 + 1142 + it('rejects deletion of another user schedule', async () => { 1143 + (getRawSchedule as jest.Mock).mockResolvedValue({ user_did: 'did:plc:other' }); 1144 + const res = await request(app) 1145 + .post('/xrpc/town.roundabout.scheduledPosts.deleteSchedule') 1146 + .set('Authorization', AUTH_HEADER) 1147 + .send({ id: 'sched-uuid' }); 1148 + expect(res.status).toBe(401); 1149 + }); 1150 + }); 1151 + 1152 + // ---- town.roundabout.scheduledPosts.updateSchedule ---- 1153 + 1154 + describe('town.roundabout.scheduledPosts.updateSchedule', () => { 1155 + const DAILY_RULE = { 1156 + rule: { 1157 + type: 'daily', 1158 + time: { type: 'wall_time', hour: 9, minute: 0, timezone: 'UTC' }, 1159 + }, 1160 + }; 1161 + const SCHEDULE_VIEW = { 1162 + id: 'sched-uuid', 1163 + collection: 'app.bsky.feed.post', 1164 + status: 'active', 1165 + recurrenceRule: DAILY_RULE, 1166 + timezone: 'UTC', 1167 + fireCount: 0, 1168 + createdAt: new Date().toISOString(), 1169 + updatedAt: new Date().toISOString(), 1170 + }; 1171 + 1172 + beforeEach(() => { 1173 + mockAuth(); 1174 + (getRawSchedule as jest.Mock).mockResolvedValue({ 1175 + user_did: USER_DID, 1176 + status: 'active', 1177 + next_draft_uri: null, 1178 + collection: 'app.bsky.feed.post', 1179 + record: null, 1180 + content_url: null, 1181 + recurrence_rule: JSON.stringify(DAILY_RULE), 1182 + }); 1183 + (updateSchedule as jest.Mock).mockResolvedValue(SCHEDULE_VIEW); 1184 + (cancelDraft as jest.Mock).mockResolvedValue(undefined); 1185 + (updateScheduleNextDraft as jest.Mock).mockResolvedValue(undefined); 1186 + mockCreateDraft.mockResolvedValue(DRAFT_VIEW); 1187 + }); 1188 + 1189 + it('returns 400 for non-existent schedule', async () => { 1190 + (getRawSchedule as jest.Mock).mockResolvedValue(null); 1191 + const res = await request(app) 1192 + .post('/xrpc/town.roundabout.scheduledPosts.updateSchedule') 1193 + .set('Authorization', AUTH_HEADER) 1194 + .send({ id: 'missing' }); 1195 + expect(res.status).toBe(400); 1196 + }); 1197 + 1198 + it('rejects update of another user schedule', async () => { 1199 + (getRawSchedule as jest.Mock).mockResolvedValue({ user_did: 'did:plc:other', status: 'active' }); 1200 + const res = await request(app) 1201 + .post('/xrpc/town.roundabout.scheduledPosts.updateSchedule') 1202 + .set('Authorization', AUTH_HEADER) 1203 + .send({ id: 'sched-uuid' }); 1204 + expect(res.status).toBe(401); 1205 + }); 1206 + 1207 + it('updates recurrenceRule and timezone', async () => { 1208 + const res = await request(app) 1209 + .post('/xrpc/town.roundabout.scheduledPosts.updateSchedule') 1210 + .set('Authorization', AUTH_HEADER) 1211 + .send({ id: 'sched-uuid', recurrenceRule: DAILY_RULE, timezone: 'America/New_York' }); 1212 + expect(res.status).toBe(200); 1213 + expect(res.body.schedule).toBeDefined(); 1214 + }); 1215 + 1216 + it('pauses an active schedule and cancels pending draft', async () => { 1217 + (getRawSchedule as jest.Mock).mockResolvedValue({ 1218 + user_did: USER_DID, 1219 + status: 'active', 1220 + next_draft_uri: DRAFT_VIEW.uri, 1221 + collection: 'app.bsky.feed.post', 1222 + record: null, 1223 + content_url: null, 1224 + recurrence_rule: JSON.stringify(DAILY_RULE), 1225 + }); 1226 + (updateSchedule as jest.Mock).mockResolvedValue({ ...SCHEDULE_VIEW, status: 'paused' }); 1227 + 1228 + const res = await request(app) 1229 + .post('/xrpc/town.roundabout.scheduledPosts.updateSchedule') 1230 + .set('Authorization', AUTH_HEADER) 1231 + .send({ id: 'sched-uuid', status: 'paused' }); 1232 + expect(res.status).toBe(200); 1233 + expect(cancelDraft as jest.Mock).toHaveBeenCalledWith(DRAFT_VIEW.uri); 1234 + expect(updateScheduleNextDraft as jest.Mock).toHaveBeenCalledWith('sched-uuid', null); 1235 + }); 1236 + 1237 + it('pauses an active schedule with no pending draft', async () => { 1238 + // next_draft_uri is null — no cancelDraft call needed 1239 + (updateSchedule as jest.Mock).mockResolvedValue({ ...SCHEDULE_VIEW, status: 'paused' }); 1240 + const res = await request(app) 1241 + .post('/xrpc/town.roundabout.scheduledPosts.updateSchedule') 1242 + .set('Authorization', AUTH_HEADER) 1243 + .send({ id: 'sched-uuid', status: 'paused' }); 1244 + expect(res.status).toBe(200); 1245 + expect(cancelDraft as jest.Mock).not.toHaveBeenCalled(); 1246 + }); 1247 + 1248 + it('resumes a paused schedule and creates a new draft', async () => { 1249 + (getRawSchedule as jest.Mock).mockResolvedValue({ 1250 + user_did: USER_DID, 1251 + status: 'paused', 1252 + next_draft_uri: null, 1253 + collection: 'app.bsky.feed.post', 1254 + record: JSON.stringify({ $type: 'app.bsky.feed.post', text: 'resumed' }), 1255 + content_url: null, 1256 + recurrence_rule: JSON.stringify(DAILY_RULE), 1257 + }); 1258 + (updateSchedule as jest.Mock).mockResolvedValue({ ...SCHEDULE_VIEW, status: 'active' }); 1259 + 1260 + const res = await request(app) 1261 + .post('/xrpc/town.roundabout.scheduledPosts.updateSchedule') 1262 + .set('Authorization', AUTH_HEADER) 1263 + .send({ id: 'sched-uuid', status: 'active' }); 1264 + expect(res.status).toBe(200); 1265 + expect(mockCreateDraft).toHaveBeenCalled(); 1266 + expect(updateScheduleNextDraft as jest.Mock).toHaveBeenCalled(); 1267 + expect(mockNotifyScheduler).toHaveBeenCalled(); 1268 + }); 1269 + 1270 + it('resumes a paused schedule with contentUrl (null record)', async () => { 1271 + (getRawSchedule as jest.Mock).mockResolvedValue({ 1272 + user_did: USER_DID, 1273 + status: 'paused', 1274 + next_draft_uri: null, 1275 + collection: 'app.bsky.feed.post', 1276 + record: null, 1277 + content_url: 'https://example.com/content', 1278 + recurrence_rule: JSON.stringify(DAILY_RULE), 1279 + }); 1280 + (updateSchedule as jest.Mock).mockResolvedValue({ ...SCHEDULE_VIEW, status: 'active' }); 1281 + 1282 + const res = await request(app) 1283 + .post('/xrpc/town.roundabout.scheduledPosts.updateSchedule') 1284 + .set('Authorization', AUTH_HEADER) 1285 + .send({ id: 'sched-uuid', status: 'active' }); 1286 + expect(res.status).toBe(200); 1287 + expect(mockCreateDraft).toHaveBeenCalled(); 1288 + }); 1289 + 1290 + it('resumes a paused schedule with an updated recurrenceRule', async () => { 1291 + (getRawSchedule as jest.Mock).mockResolvedValue({ 1292 + user_did: USER_DID, 1293 + status: 'paused', 1294 + next_draft_uri: null, 1295 + collection: 'app.bsky.feed.post', 1296 + record: null, 1297 + content_url: null, 1298 + recurrence_rule: JSON.stringify(DAILY_RULE), 1299 + }); 1300 + (updateSchedule as jest.Mock).mockResolvedValue({ ...SCHEDULE_VIEW, status: 'active' }); 1301 + 1302 + const newRule = { 1303 + rule: { type: 'weekly', daysOfWeek: [1], time: { type: 'wall_time', hour: 10, minute: 0, timezone: 'UTC' } }, 1304 + }; 1305 + const res = await request(app) 1306 + .post('/xrpc/town.roundabout.scheduledPosts.updateSchedule') 1307 + .set('Authorization', AUTH_HEADER) 1308 + .send({ id: 'sched-uuid', status: 'active', recurrenceRule: newRule }); 1309 + expect(res.status).toBe(200); 1310 + expect(mockCreateDraft).toHaveBeenCalled(); 1311 + }); 1312 + 1313 + it('does not create draft when resuming schedule with no future occurrences', async () => { 1314 + const exhaustedRule = { 1315 + rule: { type: 'daily', time: { type: 'wall_time', hour: 9, minute: 0, timezone: 'UTC' } }, 1316 + endDate: '2020-01-01', 1317 + }; 1318 + (getRawSchedule as jest.Mock).mockResolvedValue({ 1319 + user_did: USER_DID, 1320 + status: 'paused', 1321 + next_draft_uri: null, 1322 + collection: 'app.bsky.feed.post', 1323 + record: null, 1324 + content_url: null, 1325 + recurrence_rule: JSON.stringify(DAILY_RULE), 1326 + }); 1327 + (updateSchedule as jest.Mock).mockResolvedValue({ ...SCHEDULE_VIEW, status: 'active' }); 1328 + 1329 + const res = await request(app) 1330 + .post('/xrpc/town.roundabout.scheduledPosts.updateSchedule') 1331 + .set('Authorization', AUTH_HEADER) 1332 + .send({ id: 'sched-uuid', status: 'active', recurrenceRule: exhaustedRule }); 1333 + expect(res.status).toBe(200); 1334 + // No new draft when exhausted rule 1335 + expect(mockCreateDraft).not.toHaveBeenCalled(); 1336 + }); 1337 + 1338 + it('updates record content', async () => { 1339 + const res = await request(app) 1340 + .post('/xrpc/town.roundabout.scheduledPosts.updateSchedule') 1341 + .set('Authorization', AUTH_HEADER) 1342 + .send({ id: 'sched-uuid', record: { $type: 'app.bsky.feed.post', text: 'new' } }); 1343 + expect(res.status).toBe(200); 1344 + }); 1345 + 1346 + it('updates contentUrl to new value', async () => { 1347 + const res = await request(app) 1348 + .post('/xrpc/town.roundabout.scheduledPosts.updateSchedule') 1349 + .set('Authorization', AUTH_HEADER) 1350 + .send({ id: 'sched-uuid', contentUrl: 'https://example.com/new-content' }); 1351 + expect(res.status).toBe(200); 1352 + }); 1353 + 1354 + it('sets status directly when status is defined but neither pause nor resume case matches', async () => { 1355 + // When raw.status === 'paused' and body.status === 'paused', neither the 1356 + // pause (active→paused) nor resume (paused→active) branch fires. 1357 + // The else-if branch executes: updateParams.status = body.status 1358 + (getRawSchedule as jest.Mock).mockResolvedValue({ 1359 + user_did: USER_DID, 1360 + status: 'paused', 1361 + next_draft_uri: null, 1362 + collection: 'app.bsky.feed.post', 1363 + record: null, 1364 + content_url: null, 1365 + recurrence_rule: JSON.stringify({ 1366 + rule: { type: 'daily', time: { type: 'wall_time', hour: 9, minute: 0, timezone: 'UTC' } }, 1367 + }), 1368 + }); 1369 + const res = await request(app) 1370 + .post('/xrpc/town.roundabout.scheduledPosts.updateSchedule') 1371 + .set('Authorization', AUTH_HEADER) 1372 + .send({ id: 'sched-uuid', status: 'paused' }); 1373 + expect(res.status).toBe(200); 1374 + }); 1375 + 1376 + it('requires auth', async () => { 1377 + mockVerifyRequestAuth.mockRejectedValue(new Error('Unauthorized')); 1378 + const res = await request(app) 1379 + .post('/xrpc/town.roundabout.scheduledPosts.updateSchedule') 1380 + .send({ id: 'sched-uuid' }); 1381 + expect(res.status).toBe(401); 1382 + }); 1383 + }); 1384 + 1385 + // ---- POST /triggers/:key catch block ---- 1386 + 1387 + describe('POST /triggers/:key error handling', () => { 1388 + it('returns 500 when publishDraft throws', async () => { 1389 + (getDraftByTriggerKeyHash as jest.Mock).mockResolvedValue({ 1390 + uri: DRAFT_VIEW.uri, 1391 + status: 'draft', 1392 + schedule_id: null, 1393 + }); 1394 + (mockPublishDraft as jest.Mock).mockRejectedValue(new Error('PDS unreachable')); 1395 + 1396 + const res = await request(app).post('/triggers/some-key'); 1397 + expect(res.status).toBe(500); 1398 + expect(res.body.error).toBe('InternalError'); 1399 + }); 1400 + }); 1401 + 1402 + // ---- putRecord with x-trigger: webhook ---- 1403 + 1404 + describe('com.atproto.repo.putRecord with x-trigger: webhook', () => { 1405 + beforeEach(() => { 1406 + mockAuth(); 1407 + mockCreateDraft.mockResolvedValue({ ...DRAFT_VIEW, action: 'put' }); 1408 + }); 1409 + 1410 + it('returns triggerUrl in response when x-trigger: webhook is set', async () => { 1411 + const res = await request(app) 1412 + .post('/xrpc/com.atproto.repo.putRecord') 1413 + .set('Authorization', AUTH_HEADER) 1414 + .set('x-trigger', 'webhook') 1415 + .send({ repo: USER_DID, collection: 'app.bsky.feed.post', rkey: 'abc', record: { $type: 'app.bsky.feed.post', text: 'put' } }); 1416 + expect(res.status).toBe(200); 1417 + expect(res.body.triggerUrl).toBeDefined(); 1418 + expect(res.body.triggerUrl).toMatch(/^http:\/\/localhost:1986\/triggers\//); 1419 + }); 1420 + }); 1421 + 1422 + // ---- deleteRecord with x-trigger: webhook ---- 1423 + 1424 + describe('com.atproto.repo.deleteRecord with x-trigger: webhook', () => { 1425 + beforeEach(() => { 1426 + mockAuth(); 1427 + mockCreateDraft.mockResolvedValue({ ...DRAFT_VIEW, action: 'delete' }); 1428 + }); 1429 + 1430 + it('returns triggerUrl in response when x-trigger: webhook is set', async () => { 1431 + const res = await request(app) 1432 + .post('/xrpc/com.atproto.repo.deleteRecord') 1433 + .set('Authorization', AUTH_HEADER) 1434 + .set('x-trigger', 'webhook') 1435 + .send({ repo: USER_DID, collection: 'app.bsky.feed.post', rkey: 'abc' }); 1436 + expect(res.status).toBe(200); 1437 + expect(res.body.triggerUrl).toBeDefined(); 1438 + expect(res.body.triggerUrl).toMatch(/^http:\/\/localhost:1986\/triggers\//); 1439 + }); 1440 + }); 1441 + 1442 + // ---- listPosts with decryption failure ---- 1443 + 1444 + describe('town.roundabout.scheduledPosts.listPosts with trigger key decryption failure', () => { 1445 + it('omits triggerUrl for a draft when decryption fails', async () => { 1446 + mockAuth(); 1447 + // Return a draft with an invalid/corrupt encrypted key 1448 + mockListDrafts.mockResolvedValue({ 1449 + drafts: [{ ...DRAFT_VIEW, triggerKeyEncrypted: 'invalid:not:base64!!!' }], 1450 + cursor: undefined, 1451 + }); 1452 + 1453 + const res = await request(app) 1454 + .get(`/xrpc/town.roundabout.scheduledPosts.listPosts?repo=${USER_DID}`) 1455 + .set('Authorization', AUTH_HEADER); 1456 + expect(res.status).toBe(200); 1457 + // triggerUrl should be omitted when decryption fails 1458 + expect(res.body.posts[0].triggerUrl).toBeUndefined(); 1459 + }); 1460 + }); 1461 + 1462 + // ---- listPosts with trigger key decryption success ---- 1463 + 1464 + describe('town.roundabout.scheduledPosts.listPosts with valid trigger key', () => { 1465 + it('includes triggerUrl in post when decryption succeeds', async () => { 1466 + mockAuth(); 1467 + const plainKey = 'some-webhook-uuid-key'; 1468 + const { encrypt } = jest.requireActual('../encrypt') as typeof import('../encrypt'); 1469 + const encryptedKey = encrypt(plainKey, config.encryptionKey); 1470 + 1471 + mockListDrafts.mockResolvedValue({ 1472 + drafts: [{ ...DRAFT_VIEW, triggerKeyEncrypted: encryptedKey }], 1473 + cursor: undefined, 1474 + }); 1475 + 1476 + const res = await request(app) 1477 + .get(`/xrpc/town.roundabout.scheduledPosts.listPosts?repo=${USER_DID}`) 1478 + .set('Authorization', AUTH_HEADER); 1479 + expect(res.status).toBe(200); 1480 + expect(res.body.posts[0].triggerUrl).toBe(`http://localhost:1986/triggers/${plainKey}`); 1481 + }); 1482 + }); 1483 + 1484 + // ---- getPost with trigger key decryption failure ---- 1485 + 1486 + describe('town.roundabout.scheduledPosts.getPost with trigger key decryption failure', () => { 1487 + it('omits triggerUrl when decryption fails', async () => { 1488 + mockAuth(); 1489 + const uri = DRAFT_VIEW.uri; 1490 + mockGetDraft.mockResolvedValue(DRAFT_VIEW); 1491 + // Provide a corrupted encrypted key 1492 + mockGetDraftRawRow.mockResolvedValue({ 1493 + uri, 1494 + trigger_key_encrypted: 'corrupt:invalid:garbage!!!', 1495 + trigger_key_hash: 'somehash', 1496 + schedule_id: null, 1497 + }); 1498 + 1499 + const res = await request(app) 1500 + .get(`/xrpc/town.roundabout.scheduledPosts.getPost?uri=${encodeURIComponent(uri)}`) 1501 + .set('Authorization', AUTH_HEADER); 1502 + expect(res.status).toBe(200); 1503 + expect(res.body.triggerUrl).toBeUndefined(); 1504 + }); 1505 + }); 1506 + 1507 + // ---- createSchedule with invalid/exhausted recurrence rule ---- 1508 + 1509 + describe('town.roundabout.scheduledPosts.createSchedule with invalid rule', () => { 1510 + it('returns 400 when recurrence rule has no future occurrences', async () => { 1511 + mockAuth(); 1512 + const exhaustedRule = { 1513 + rule: { 1514 + type: 'daily', 1515 + time: { type: 'wall_time', hour: 9, minute: 0, timezone: 'UTC' }, 1516 + }, 1517 + endDate: '2020-01-01', 1518 + }; 1519 + const res = await request(app) 1520 + .post('/xrpc/town.roundabout.scheduledPosts.createSchedule') 1521 + .set('Authorization', AUTH_HEADER) 1522 + .send({ 1523 + collection: 'app.bsky.feed.post', 1524 + recurrenceRule: exhaustedRule, 1525 + timezone: 'UTC', 1526 + record: { $type: 'app.bsky.feed.post', text: 'hi' }, 1527 + }); 1528 + expect(res.status).toBe(400); 828 1529 }); 829 1530 }); 830 1531 });
+317
src/__tests__/storage.test.ts
··· 7 7 setDb, 8 8 createDraft, 9 9 getDraft, 10 + getDraftRawRow, 11 + getDraftByTriggerKeyHash, 10 12 listDrafts, 11 13 scheduleDraft, 12 14 updateDraft, ··· 27 29 getBlobsByCids, 28 30 deleteBlobs, 29 31 cleanExpiredOAuthStates, 32 + createSchedule, 33 + getSchedule, 34 + getRawSchedule, 35 + listSchedules, 36 + updateScheduleNextDraft, 37 + updateScheduleStatus, 38 + incrementScheduleFireCount, 39 + updateSchedule, 40 + deleteSchedule, 30 41 } from '../storage'; 31 42 import type { Database } from '../schema'; 32 43 ··· 109 120 110 121 expect(draft.action).toBe('delete'); 111 122 expect(draft.cid).toBeUndefined(); 123 + }); 124 + }); 125 + 126 + describe('getDraftRawRow', () => { 127 + it('returns null for non-existent uri', async () => { 128 + const result = await getDraftRawRow('at://did:plc:nobody/app/nonexistent'); 129 + expect(result).toBeNull(); 130 + }); 131 + 132 + it('returns the raw row for an existing draft', async () => { 133 + await createDraft({ 134 + uri: 'at://did:plc:alice/app.bsky.feed.post/rawrow1', 135 + userDid: 'did:plc:alice', 136 + collection: 'app.bsky.feed.post', 137 + rkey: 'rawrow1', 138 + record: { text: 'raw' }, 139 + recordCid: 'bafyraw1', 140 + action: 'create', 141 + }); 142 + const result = await getDraftRawRow('at://did:plc:alice/app.bsky.feed.post/rawrow1'); 143 + expect(result).not.toBeNull(); 144 + expect(result?.uri).toBe('at://did:plc:alice/app.bsky.feed.post/rawrow1'); 112 145 }); 113 146 }); 114 147 ··· 729 762 730 763 expect(await getOAuthState('expired-clean')).toBeNull(); 731 764 expect(await getOAuthState('valid-clean')).toEqual({ x: 2 }); 765 + }); 766 + }); 767 + 768 + // ---- getDraftByTriggerKeyHash ---- 769 + 770 + describe('getDraftByTriggerKeyHash', () => { 771 + it('returns the draft row when trigger key hash matches', async () => { 772 + await createDraft({ 773 + uri: 'at://did:plc:alice/app.bsky.feed.post/trig1', 774 + userDid: 'did:plc:alice', 775 + collection: 'app.bsky.feed.post', 776 + rkey: 'trig1', 777 + record: { text: 'trigger post' }, 778 + recordCid: 'bafytrig1', 779 + action: 'create', 780 + triggerKeyHash: 'testhash123', 781 + triggerKeyEncrypted: 'encryptedkey123', 782 + }); 783 + 784 + const row = await getDraftByTriggerKeyHash('testhash123'); 785 + expect(row).not.toBeNull(); 786 + expect(row?.trigger_key_hash).toBe('testhash123'); 787 + expect(row?.trigger_key_encrypted).toBe('encryptedkey123'); 788 + }); 789 + 790 + it('returns null when hash is not found', async () => { 791 + const row = await getDraftByTriggerKeyHash('nonexistent-hash'); 792 + expect(row).toBeNull(); 793 + }); 794 + }); 795 + 796 + // ---- Schedule Operations ---- 797 + 798 + const DAILY_RULE = { 799 + rule: { type: 'daily', time: { type: 'wall_time', hour: 9, minute: 0, timezone: 'UTC' } }, 800 + }; 801 + 802 + async function createTestSchedule(id: string, overrides?: Partial<{ 803 + record: Record<string, unknown> | null; 804 + contentUrl: string | null; 805 + }>) { 806 + return createSchedule({ 807 + id, 808 + userDid: 'did:plc:alice', 809 + collection: 'app.bsky.feed.post', 810 + record: overrides?.record !== undefined ? overrides.record : { $type: 'app.bsky.feed.post', text: 'test' }, 811 + contentUrl: overrides?.contentUrl ?? null, 812 + recurrenceRule: DAILY_RULE, 813 + timezone: 'UTC', 814 + }); 815 + } 816 + 817 + describe('createSchedule', () => { 818 + it('creates a schedule with active status and zero fire count', async () => { 819 + const row = await createTestSchedule('sched-create-1'); 820 + expect(row.id).toBe('sched-create-1'); 821 + expect(row.status).toBe('active'); 822 + expect(row.fire_count).toBe(0); 823 + expect(row.collection).toBe('app.bsky.feed.post'); 824 + expect(row.timezone).toBe('UTC'); 825 + expect(row.last_fired_at).toBeNull(); 826 + expect(row.next_draft_uri).toBeNull(); 827 + }); 828 + 829 + it('stores recurrence rule as JSON string', async () => { 830 + const row = await createTestSchedule('sched-create-2'); 831 + expect(JSON.parse(row.recurrence_rule)).toEqual(DAILY_RULE); 832 + }); 833 + 834 + it('stores null record when contentUrl is provided', async () => { 835 + const row = await createTestSchedule('sched-create-3', { 836 + record: null, 837 + contentUrl: 'https://example.com/content', 838 + }); 839 + expect(row.record).toBeNull(); 840 + expect(row.content_url).toBe('https://example.com/content'); 841 + }); 842 + }); 843 + 844 + describe('getSchedule', () => { 845 + it('returns scheduleView for an existing schedule', async () => { 846 + await createTestSchedule('sched-get-1'); 847 + const view = await getSchedule('sched-get-1'); 848 + expect(view).not.toBeNull(); 849 + expect(view?.id).toBe('sched-get-1'); 850 + expect(view?.status).toBe('active'); 851 + expect(view?.fireCount).toBe(0); 852 + expect(view?.recurrenceRule).toEqual(DAILY_RULE); 853 + }); 854 + 855 + it('returns null for non-existent schedule', async () => { 856 + expect(await getSchedule('nonexistent-sched')).toBeNull(); 857 + }); 858 + }); 859 + 860 + describe('getRawSchedule', () => { 861 + it('returns raw row for an existing schedule', async () => { 862 + await createTestSchedule('sched-raw-1'); 863 + const row = await getRawSchedule('sched-raw-1'); 864 + expect(row).not.toBeNull(); 865 + expect(row?.id).toBe('sched-raw-1'); 866 + expect(row?.user_did).toBe('did:plc:alice'); 867 + }); 868 + 869 + it('returns null for non-existent schedule', async () => { 870 + expect(await getRawSchedule('nonexistent-sched')).toBeNull(); 871 + }); 872 + }); 873 + 874 + describe('listSchedules', () => { 875 + beforeEach(async () => { 876 + await createTestSchedule('sched-list-1'); 877 + await createTestSchedule('sched-list-2'); 878 + await createTestSchedule('sched-list-3'); 879 + }); 880 + 881 + it('returns all schedules for the user', async () => { 882 + const result = await listSchedules({ userDid: 'did:plc:alice', limit: 50 }); 883 + expect(result.schedules.length).toBeGreaterThanOrEqual(3); 884 + }); 885 + 886 + it('filters by status', async () => { 887 + await updateScheduleStatus('sched-list-1', 'paused'); 888 + const active = await listSchedules({ userDid: 'did:plc:alice', status: 'active', limit: 50 }); 889 + const paused = await listSchedules({ userDid: 'did:plc:alice', status: 'paused', limit: 50 }); 890 + expect(active.schedules.every(s => s.status === 'active')).toBe(true); 891 + expect(paused.schedules.every(s => s.status === 'paused')).toBe(true); 892 + }); 893 + 894 + it('paginates with cursor', async () => { 895 + // Ensure distinct created_at values so cursor (created_at < N) works correctly 896 + const base = Date.now(); 897 + const ids = ['sched-list-1', 'sched-list-2', 'sched-list-3']; 898 + for (let i = 0; i < ids.length; i++) { 899 + await db.updateTable('schedules').set({ created_at: base - (2 - i) * 1000 }).where('id', '=', ids[i]).execute(); 900 + } 901 + 902 + const page1 = await listSchedules({ userDid: 'did:plc:alice', limit: 2 }); 903 + expect(page1.schedules).toHaveLength(2); 904 + expect(page1.cursor).toBeDefined(); 905 + 906 + const page2 = await listSchedules({ userDid: 'did:plc:alice', limit: 2, cursor: page1.cursor }); 907 + expect(page2.schedules.length).toBeGreaterThanOrEqual(1); 908 + }); 909 + 910 + it('returns empty list for unknown user', async () => { 911 + const result = await listSchedules({ userDid: 'did:plc:nobody', limit: 50 }); 912 + expect(result.schedules).toHaveLength(0); 913 + }); 914 + }); 915 + 916 + describe('updateScheduleNextDraft', () => { 917 + it('sets next_draft_uri', async () => { 918 + await createTestSchedule('sched-nd-1'); 919 + await updateScheduleNextDraft('sched-nd-1', 'at://did:plc:alice/app.bsky.feed.post/next1'); 920 + const view = await getSchedule('sched-nd-1'); 921 + expect(view?.nextDraftUri).toBe('at://did:plc:alice/app.bsky.feed.post/next1'); 922 + }); 923 + 924 + it('can clear next_draft_uri to null', async () => { 925 + await createTestSchedule('sched-nd-2'); 926 + await updateScheduleNextDraft('sched-nd-2', 'at://did:plc:alice/app.bsky.feed.post/x'); 927 + await updateScheduleNextDraft('sched-nd-2', null); 928 + const view = await getSchedule('sched-nd-2'); 929 + expect(view?.nextDraftUri).toBeUndefined(); 930 + }); 931 + }); 932 + 933 + describe('updateScheduleStatus', () => { 934 + it('updates schedule status', async () => { 935 + await createTestSchedule('sched-status-1'); 936 + await updateScheduleStatus('sched-status-1', 'paused'); 937 + const view = await getSchedule('sched-status-1'); 938 + expect(view?.status).toBe('paused'); 939 + }); 940 + 941 + it('can set to error status', async () => { 942 + await createTestSchedule('sched-status-2'); 943 + await updateScheduleStatus('sched-status-2', 'error'); 944 + const view = await getSchedule('sched-status-2'); 945 + expect(view?.status).toBe('error'); 946 + }); 947 + }); 948 + 949 + describe('incrementScheduleFireCount', () => { 950 + it('increments fire_count and sets last_fired_at', async () => { 951 + await createTestSchedule('sched-fc-1'); 952 + await incrementScheduleFireCount('sched-fc-1'); 953 + const view = await getSchedule('sched-fc-1'); 954 + expect(view?.fireCount).toBe(1); 955 + expect(view?.lastFiredAt).toBeDefined(); 956 + }); 957 + 958 + it('increments fire_count multiple times', async () => { 959 + await createTestSchedule('sched-fc-2'); 960 + await incrementScheduleFireCount('sched-fc-2'); 961 + await incrementScheduleFireCount('sched-fc-2'); 962 + const view = await getSchedule('sched-fc-2'); 963 + expect(view?.fireCount).toBe(2); 964 + }); 965 + }); 966 + 967 + describe('updateSchedule', () => { 968 + it('updates record content', async () => { 969 + await createTestSchedule('sched-upd-1'); 970 + const result = await updateSchedule('sched-upd-1', { 971 + record: { $type: 'app.bsky.feed.post', text: 'updated text' }, 972 + }); 973 + expect(result?.record).toEqual({ $type: 'app.bsky.feed.post', text: 'updated text' }); 974 + }); 975 + 976 + it('clears record when set to null', async () => { 977 + await createTestSchedule('sched-upd-2'); 978 + const result = await updateSchedule('sched-upd-2', { record: null }); 979 + expect(result?.record).toBeUndefined(); 980 + }); 981 + 982 + it('updates contentUrl', async () => { 983 + await createTestSchedule('sched-upd-3'); 984 + const result = await updateSchedule('sched-upd-3', { 985 + contentUrl: 'https://example.com/new-content', 986 + }); 987 + expect(result?.contentUrl).toBe('https://example.com/new-content'); 988 + }); 989 + 990 + it('clears contentUrl when set to null', async () => { 991 + await createTestSchedule('sched-upd-4', { record: null, contentUrl: 'https://example.com' }); 992 + const result = await updateSchedule('sched-upd-4', { contentUrl: null }); 993 + expect(result?.contentUrl).toBeUndefined(); 994 + }); 995 + 996 + it('updates recurrenceRule', async () => { 997 + await createTestSchedule('sched-upd-5'); 998 + const newRule = { rule: { type: 'weekly', daysOfWeek: [1], time: { type: 'wall_time', hour: 10, minute: 0, timezone: 'UTC' } } }; 999 + const result = await updateSchedule('sched-upd-5', { recurrenceRule: newRule }); 1000 + expect(result?.recurrenceRule).toEqual(newRule); 1001 + }); 1002 + 1003 + it('updates timezone', async () => { 1004 + await createTestSchedule('sched-upd-6'); 1005 + const result = await updateSchedule('sched-upd-6', { timezone: 'America/New_York' }); 1006 + expect(result?.timezone).toBe('America/New_York'); 1007 + }); 1008 + 1009 + it('updates status', async () => { 1010 + await createTestSchedule('sched-upd-7'); 1011 + const result = await updateSchedule('sched-upd-7', { status: 'paused' }); 1012 + expect(result?.status).toBe('paused'); 1013 + }); 1014 + }); 1015 + 1016 + describe('deleteSchedule', () => { 1017 + it('cancels pending draft and marks schedule cancelled', async () => { 1018 + await createTestSchedule('sched-del-1'); 1019 + 1020 + // Create a pending draft linked to this schedule 1021 + await createDraft({ 1022 + uri: 'at://did:plc:alice/app.bsky.feed.post/sched-del-draft', 1023 + userDid: 'did:plc:alice', 1024 + collection: 'app.bsky.feed.post', 1025 + rkey: 'sched-del-draft', 1026 + record: { text: 'scheduled post' }, 1027 + recordCid: 'bafyscheddel', 1028 + action: 'create', 1029 + scheduledAt: Date.now() + 60_000, 1030 + scheduleId: 'sched-del-1', 1031 + }); 1032 + 1033 + await deleteSchedule('sched-del-1'); 1034 + 1035 + // Pending draft should be cancelled 1036 + const draft = await getDraft('at://did:plc:alice/app.bsky.feed.post/sched-del-draft'); 1037 + expect(draft?.status).toBe('cancelled'); 1038 + 1039 + // Schedule status should be cancelled 1040 + const view = await getSchedule('sched-del-1'); 1041 + expect(view?.status).toBe('cancelled'); 1042 + }); 1043 + 1044 + it('works when there is no pending draft', async () => { 1045 + await createTestSchedule('sched-del-2'); 1046 + await deleteSchedule('sched-del-2'); 1047 + const view = await getSchedule('sched-del-2'); 1048 + expect(view?.status).toBe('cancelled'); 732 1049 }); 733 1050 }); 734 1051
+50
src/database.ts
··· 118 118 .addColumn('updated_at', bigintType as 'integer', (col) => col.notNull()) 119 119 .addColumn('published_at', bigintType as 'integer') 120 120 .addColumn('failure_reason', 'text') 121 + .addColumn('trigger_key_hash', 'text') 122 + .addColumn('trigger_key_encrypted', 'text') 123 + .addColumn('schedule_id', 'text') 121 124 .execute(); 122 125 126 + // Migrations: add trigger and schedule columns to existing drafts table 127 + for (const col of ['trigger_key_hash', 'trigger_key_encrypted', 'schedule_id']) { 128 + try { 129 + await db.schema 130 + .alterTable('drafts') 131 + .addColumn(col, 'text') 132 + .execute(); 133 + } catch { 134 + // Column already exists — no-op 135 + } 136 + } 137 + 123 138 await db.schema 124 139 .createIndex('idx_drafts_scheduled_at_status') 125 140 .ifNotExists() ··· 132 147 .ifNotExists() 133 148 .on('drafts') 134 149 .columns(['user_did', 'status']) 150 + .execute(); 151 + 152 + // Index for O(1) trigger key lookup 153 + await db.schema 154 + .createIndex('idx_drafts_trigger_key_hash') 155 + .ifNotExists() 156 + .unique() 157 + .on('drafts') 158 + .column('trigger_key_hash') 135 159 .execute(); 136 160 137 161 // draft_blobs table ··· 157 181 .unique() 158 182 .on('draft_blobs') 159 183 .columns(['user_did', 'cid']) 184 + .execute(); 185 + 186 + // schedules table 187 + await db.schema 188 + .createTable('schedules') 189 + .ifNotExists() 190 + .addColumn('id', 'text', (col) => col.primaryKey()) 191 + .addColumn('user_did', 'text', (col) => col.notNull()) 192 + .addColumn('collection', 'text', (col) => col.notNull()) 193 + .addColumn('record', 'text') 194 + .addColumn('content_url', 'text') 195 + .addColumn('recurrence_rule', 'text', (col) => col.notNull()) 196 + .addColumn('timezone', 'text', (col) => col.notNull()) 197 + .addColumn('status', 'text', (col) => col.notNull().defaultTo('active')) 198 + .addColumn('fire_count', intType as 'integer', (col) => col.notNull().defaultTo(0)) 199 + .addColumn('created_at', bigintType as 'integer', (col) => col.notNull()) 200 + .addColumn('updated_at', bigintType as 'integer', (col) => col.notNull()) 201 + .addColumn('last_fired_at', bigintType as 'integer') 202 + .addColumn('next_draft_uri', 'text') 203 + .execute(); 204 + 205 + await db.schema 206 + .createIndex('idx_schedules_user_did_status') 207 + .ifNotExists() 208 + .on('schedules') 209 + .columns(['user_did', 'status']) 160 210 .execute(); 161 211 162 212 logger.info(`${config.databaseType} database schema initialized`);
+11 -1
src/encrypt.ts
··· 1 1 // ABOUTME: AES-256-GCM encryption utilities for storing sensitive tokens at rest 2 2 3 - import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; 3 + import { createCipheriv, createDecipheriv, createHmac, randomBytes } from 'node:crypto'; 4 4 5 5 const ALGORITHM = 'aes-256-gcm'; 6 6 const IV_LENGTH = 12; ··· 52 52 export function validateEncryptionKey(keyHex: string): boolean { 53 53 return /^[0-9a-fA-F]{64}$/.test(keyHex); 54 54 } 55 + 56 + /** 57 + * Compute HMAC-SHA256(value, key) and return as a hex string. 58 + * Used to create a stable lookup index for webhook trigger keys without 59 + * exposing the plaintext key in the database. 60 + */ 61 + export function hmac(value: string, keyHex: string): string { 62 + const key = Buffer.from(keyHex, 'hex'); 63 + return createHmac('sha256', key).update(value).digest('hex'); 64 + }
+30 -3
src/oauth.ts
··· 52 52 try { 53 53 const refreshToken = decrypt(row.refresh_token, encryptionKey); 54 54 const dpopBlob = JSON.parse(decrypt(row.dpop_private_key, encryptionKey)) as 55 - | { jwk: Record<string, unknown>; aud: string } 55 + | { jwk: Record<string, unknown>; aud: string; access_token?: string; expires_at?: number | string } 56 56 | Record<string, unknown>; 57 57 58 - // New format: { jwk, aud }. Legacy format: bare JWK object. 58 + // New format: { jwk, aud, access_token?, expires_at? }. Legacy format: bare JWK object. 59 59 const dpopJwk = 'jwk' in dpopBlob ? dpopBlob.jwk : dpopBlob; 60 60 const aud = ('aud' in dpopBlob && typeof dpopBlob.aud === 'string' && dpopBlob.aud) 61 61 ? dpopBlob.aud 62 62 : row.pds_url; 63 + const accessToken = ('access_token' in dpopBlob && typeof dpopBlob.access_token === 'string' && dpopBlob.access_token) 64 + ? dpopBlob.access_token 65 + : undefined; 66 + // SDK stores expires_at as an ISO date string (toISOString()), but legacy code stored it as 67 + // a Unix ms number. Accept both formats so isStale() can detect stale sessions. 68 + const expiresAtRaw = 'expires_at' in dpopBlob ? dpopBlob.expires_at : undefined; 69 + const expiresAt = typeof expiresAtRaw === 'number' 70 + ? expiresAtRaw 71 + : typeof expiresAtRaw === 'string' && expiresAtRaw 72 + ? new Date(expiresAtRaw).getTime() 73 + : undefined; 74 + 75 + // If we have no stored access_token, force the SDK to refresh immediately 76 + // by returning expires_at=0 (epoch). Without this the SDK considers the 77 + // session "not stale" (no expires_at = never stale) and tries to use an 78 + // undefined access_token, producing "Authorization: DPoP undefined" which 79 + // the PDS rejects as "Malformed token". 80 + const effectiveExpiresAt = accessToken ? expiresAt : 0; 63 81 64 82 return { 65 83 dpopJwk: dpopJwk as Record<string, unknown>, ··· 70 88 aud, // resource server / PDS (e.g. https://morel.us-east.host.bsky.network) 71 89 scope: row.token_scope, 72 90 refresh_token: refreshToken, 91 + access_token: accessToken, 92 + expires_at: effectiveExpiresAt, 73 93 token_type: 'DPoP', 74 94 }, 75 95 } as NodeSavedSession; ··· 89 109 encryptionKey, 90 110 ); 91 111 // Store aud alongside the JWK so we can restore it in get(). 112 + // Also store the access_token and expires_at so the SDK can use it 113 + // directly on restore without always doing a fresh token exchange. 92 114 // iss (authorization server) and aud (resource server / PDS) differ for 93 115 // hosted PDSes like bsky.social where the entryway issues tokens for a 94 116 // separate PDS host. 95 117 const encryptedDpopKey = encrypt( 96 - JSON.stringify({ jwk: dpopJwk, aud: typeof tokenSet.aud === 'string' ? tokenSet.aud : '' }), 118 + JSON.stringify({ 119 + jwk: dpopJwk, 120 + aud: typeof tokenSet.aud === 'string' ? tokenSet.aud : '', 121 + access_token: tokenSet.access_token ?? '', 122 + expires_at: tokenSet.expires_at ?? null, 123 + }), 97 124 encryptionKey, 98 125 ); 99 126
+178 -5
src/scheduler.ts
··· 1 1 // ABOUTME: Background scheduler that polls for ready drafts and publishes them to the PDS 2 2 3 3 import { Agent } from '@atproto/api'; 4 + import { randomUUID } from 'node:crypto'; 4 5 import type { ServiceConfig } from './config.js'; 5 6 import { createLogger } from './logger.js'; 6 7 import { schedulerWakeupsTotal, schedulerPublishesTotal, publishDurationSeconds } from './metrics.js'; ··· 13 14 markDraftFailed, 14 15 incrementRetryCount, 15 16 getDraft, 17 + getDraftRawRow, 16 18 getUserAuthorization, 17 19 getBlobsByCids, 18 20 deleteBlobs, 21 + getRawSchedule, 22 + incrementScheduleFireCount, 23 + updateScheduleNextDraft, 24 + updateScheduleStatus, 25 + createDraft, 19 26 } from './storage.js'; 20 27 import { getOAuthClient } from './oauth.js'; 21 28 import type { DraftRow } from './schema.js'; 22 29 import { extractDidFromAtUri } from './schema.js'; 30 + import { computeNextOccurrence, getOccurrenceRecord } from '@newpublic/recurrence'; 31 + import type { RecurrenceRule } from '@newpublic/recurrence'; 23 32 24 33 const logger = createLogger('Scheduler'); 25 34 ··· 52 61 } 53 62 54 63 /** 64 + * After a successful publish, handle schedule chaining: 65 + * increment fire_count, compute next occurrence, create next draft. 66 + */ 67 + async function handleScheduleChaining(draftRow: DraftRow): Promise<void> { 68 + const scheduleId = draftRow.schedule_id; 69 + /* istanbul ignore next */ 70 + if (!scheduleId) return; 71 + 72 + const schedule = await getRawSchedule(scheduleId); 73 + if (!schedule || schedule.status !== 'active') return; 74 + 75 + const userDid = draftRow.user_did; 76 + const now = new Date(); 77 + 78 + // If dynamic content, fetch it (just for logging — it was already published) 79 + if (schedule.content_url) { 80 + try { 81 + const url = new URL(schedule.content_url); 82 + url.searchParams.set('fireCount', String(schedule.fire_count + 1)); 83 + url.searchParams.set('scheduledAt', new Date(Number(draftRow.scheduled_at ?? /* istanbul ignore next */ Date.now())).toISOString()); 84 + logger.info('Dynamic schedule published', { scheduleId, contentUrl: url.toString() }); 85 + } catch (err) { 86 + /* istanbul ignore next */ 87 + logger.warn('Failed to build content URL for logging', { scheduleId, error: String(err) }); 88 + } 89 + } 90 + 91 + // Increment fire count 92 + await incrementScheduleFireCount(scheduleId); 93 + 94 + // Compute next occurrence 95 + let rule: RecurrenceRule; 96 + try { 97 + rule = JSON.parse(schedule.recurrence_rule) as RecurrenceRule; 98 + } catch { 99 + logger.error('Invalid recurrence rule JSON in schedule', undefined, { scheduleId }); 100 + await updateScheduleStatus(scheduleId, 'error'); 101 + return; 102 + } 103 + 104 + const nextFireAt = computeNextOccurrence(rule, now); 105 + if (!nextFireAt) { 106 + // Series exhausted — 'once' schedules complete naturally; others are cancelled 107 + const isOnce = rule.rule.type === 'once'; 108 + if (isOnce) { 109 + logger.info('Once schedule completed, marking completed', { scheduleId }); 110 + await updateScheduleStatus(scheduleId, 'completed'); 111 + } else { 112 + logger.info('Schedule series exhausted, marking cancelled', { scheduleId }); 113 + await updateScheduleStatus(scheduleId, 'cancelled'); 114 + } 115 + await updateScheduleNextDraft(scheduleId, null); 116 + return; 117 + } 118 + 119 + // For dynamic schedules, create a draft with null record (fetched at publish time) 120 + // For static schedules, create a draft with the template record 121 + const nextRecord = schedule.content_url ? null : (schedule.record ? JSON.parse(schedule.record) as Record<string, unknown> : /* istanbul ignore next */ null); 122 + 123 + // Build AT-URI for next draft 124 + const rkey = `sched-${Date.now()}-${randomUUID().substring(0, 8)}`; 125 + const collection = schedule.collection; 126 + const uri = `at://${userDid}/${collection}/${rkey}`; 127 + 128 + try { 129 + await createDraft({ 130 + uri, 131 + userDid, 132 + collection, 133 + rkey, 134 + record: nextRecord, 135 + recordCid: null, // Will be computed at publish time for static records 136 + action: 'create', 137 + scheduledAt: nextFireAt.getTime(), 138 + scheduleId, 139 + }); 140 + 141 + await updateScheduleNextDraft(scheduleId, uri); 142 + notifyScheduler(); 143 + logger.info('Next scheduled draft created', { scheduleId, uri, nextFireAt: nextFireAt.toISOString() }); 144 + } catch (err) { 145 + logger.error('Failed to create next schedule draft', err instanceof Error ? err : /* istanbul ignore next */ undefined, { scheduleId }); 146 + await updateScheduleStatus(scheduleId, 'error'); 147 + } 148 + } 149 + 150 + /** 55 151 * Publish a single draft to the user's PDS. 56 152 * This is the shared logic used by both the scheduler and publishPost(). 57 153 */ ··· 99 195 agent = new Agent(session); 100 196 } catch (oauthErr) { 101 197 const errMsg = oauthErr instanceof Error ? oauthErr.message : /* istanbul ignore next */ String(oauthErr); 102 - if (errMsg.includes('revoked') || errMsg.includes('invalid_grant')) { 198 + const errCode = (oauthErr as { error?: string }).error ?? ''; 199 + // OAuthResponseError from a failed token refresh has .error on the cause 200 + const causeCode = ((oauthErr as { cause?: { error?: string } }).cause?.error) ?? ''; 201 + const isPermanentAuthFailure = 202 + errMsg.includes('revoked') || 203 + errMsg.includes('invalid_grant') || 204 + errCode === 'invalid_token' || 205 + errMsg.includes('invalid_token') || 206 + causeCode === 'invalid_grant' || 207 + // TokenRefreshError wraps the underlying OAuth error 208 + errMsg.toLowerCase().includes('token refresh') || 209 + errMsg.toLowerCase().includes('refresh failed'); 210 + if (isPermanentAuthFailure) { 103 211 await markDraftFailed(uri, 'oauth_revoked', false); 104 - logger.error('OAuth token revoked for draft', undefined, { uri }); 212 + logger.error('OAuth token revoked or refresh failed for draft', undefined, { uri, errMsg, errCode, causeCode }); 105 213 return; 106 214 } 107 215 throw oauthErr; ··· 115 223 rkey: draft.rkey, 116 224 }); 117 225 } else { 118 - const record = JSON.parse( 119 - /* istanbul ignore next */ (await getDraftRecord(uri)) ?? '{}', 120 - ) as Record<string, unknown>; 226 + // For dynamic schedule drafts, fetch content from content_url at publish time 227 + let record: Record<string, unknown>; 228 + const draftRaw = await getDraftRawRow(uri); 229 + const scheduleId = draftRaw?.schedule_id; 230 + 231 + if (scheduleId && !draftRaw?.record) { 232 + // Dynamic schedule: fetch content URL 233 + const schedule = await getRawSchedule(scheduleId); 234 + if (!schedule?.content_url) { 235 + await markDraftFailed(uri, 'dynamic_schedule_missing_content_url', false); 236 + return; 237 + } 238 + try { 239 + const url = new URL(schedule.content_url); 240 + url.searchParams.set('fireCount', String(schedule.fire_count + 1)); 241 + url.searchParams.set('scheduledAt', new Date(Number(draftRaw?.scheduled_at ?? /* istanbul ignore next */ Date.now())).toISOString()); 242 + const resp = await fetch(url.toString(), { 243 + headers: { 'Accept': 'application/json' }, 244 + }); 245 + if (!resp.ok) { 246 + throw new Error(`Content URL returned ${resp.status}`); 247 + } 248 + record = await resp.json() as Record<string, unknown>; 249 + } catch (fetchErr) { 250 + const errMsg = fetchErr instanceof Error ? fetchErr.message : /* istanbul ignore next */ String(fetchErr); 251 + logger.error('Failed to fetch dynamic schedule content', fetchErr instanceof Error ? fetchErr : /* istanbul ignore next */ undefined, { uri, scheduleId }); 252 + await markDraftFailed(uri, `content_url_fetch_failed: ${errMsg}`, false); 253 + await updateScheduleStatus(scheduleId, 'error'); 254 + return; 255 + } 256 + } else { 257 + record = JSON.parse( 258 + /* istanbul ignore next */ (await getDraftRecord(uri)) ?? '{}', 259 + ) as Record<string, unknown>; 260 + 261 + // Check for override_payload exception on this occurrence 262 + if (scheduleId && draftRaw?.scheduled_at) { 263 + const schedule = await getRawSchedule(scheduleId); 264 + if (schedule) { 265 + try { 266 + const parsedRule = JSON.parse(schedule.recurrence_rule) as RecurrenceRule; 267 + const overrideRecord = getOccurrenceRecord(parsedRule, new Date(Number(draftRaw.scheduled_at))); 268 + if (overrideRecord) record = overrideRecord; 269 + } catch { 270 + // Invalid rule JSON — use draft record as-is 271 + } 272 + } 273 + } 274 + } 121 275 122 276 // Use scheduled time as the published createdAt so the post appears 123 277 // in the feed at the intended time rather than at draft creation time. ··· 173 327 schedulerPublishesTotal.inc({ result: 'success' }); 174 328 logger.info('Draft published successfully', { uri }); 175 329 330 + // Handle schedule chaining (create next draft for recurring schedules) 331 + const rawRow = await getDraftRawRow(uri); 332 + if (rawRow?.schedule_id) { 333 + try { 334 + await handleScheduleChaining(rawRow); 335 + } catch (chainErr) { 336 + logger.error('Error in schedule chaining (non-fatal)', chainErr instanceof Error ? chainErr : /* istanbul ignore next */ undefined, { uri }); 337 + } 338 + } 339 + 176 340 // Call post-publish webhook if configured 177 341 const webhookUrl = process.env.POST_PUBLISH_WEBHOOK_URL ?? config.postPublishWebhookUrl; 178 342 if (webhookUrl) { ··· 188 352 } 189 353 } catch (err) { 190 354 const errMsg = err instanceof Error ? err.message : /* istanbul ignore next */ String(err); 355 + const errCode = (err as { error?: string }).error ?? ''; 191 356 logger.error('Failed to publish draft', err instanceof Error ? err : /* istanbul ignore next */ undefined, { uri }); 357 + 358 + // Permanent auth failures — re-authentication required, retrying won't help 359 + if (errCode === 'invalid_token' || errMsg.includes('invalid_grant') || errMsg.includes('revoked')) { 360 + await markDraftFailed(uri, `oauth_failure: ${errMsg}`, false); 361 + schedulerPublishesTotal.inc({ result: 'failed' }); 362 + logger.warn('Draft failed due to OAuth auth error, marked as permanently failed', { uri }); 363 + return; 364 + } 192 365 193 366 // Increment retry count 194 367 const retryCount = await incrementRetryCount(uri);
+66
src/schema.ts
··· 4 4 5 5 export type DraftStatus = 'draft' | 'scheduled' | 'publishing' | 'published' | 'failed' | 'cancelled'; 6 6 export type DraftAction = 'create' | 'put' | 'delete'; 7 + export type ScheduleStatus = 'active' | 'paused' | 'cancelled' | 'completed' | 'error'; 7 8 8 9 /** 9 10 * User authorizations table - stores OAuth delegation or session tokens per user ··· 47 48 updated_at: number; 48 49 published_at: number | null; 49 50 failure_reason: string | null; 51 + trigger_key_hash: string | null; // HMAC-SHA256 of plaintext key for O(1) lookup 52 + trigger_key_encrypted: string | null; // AES-256-GCM encrypted plaintext key for retrieval 53 + schedule_id: string | null; // FK → schedules(id), null if not part of a schedule 54 + } 55 + 56 + /** 57 + * Schedules table - stores recurrence rules for recurring posts 58 + */ 59 + export interface SchedulesTable { 60 + id: string; // UUID (PRIMARY KEY) 61 + user_did: string; 62 + collection: string; // NSID (e.g. app.bsky.feed.post) 63 + record: string | null; // Static JSON record, null if content_url used 64 + content_url: string | null; // Dynamic content URL, null if record used 65 + recurrence_rule: string; // Full JSON (RecurrenceRule schema) 66 + timezone: string; // IANA timezone (extracted from rule for indexing) 67 + status: ScheduleStatus; 68 + fire_count: number; 69 + created_at: number; 70 + updated_at: number; 71 + last_fired_at: number | null; 72 + next_draft_uri: string | null; // AT-URI of pending draft instance 50 73 } 51 74 52 75 /** ··· 70 93 oauth_states: OAuthStatesTable; 71 94 drafts: DraftsTable; 72 95 draft_blobs: DraftBlobsTable; 96 + schedules: SchedulesTable; 73 97 } 74 98 75 99 export type DraftRow = Selectable<DraftsTable>; 76 100 export type UserAuthorizationRow = Selectable<UserAuthorizationsTable>; 77 101 export type OAuthStateRow = Selectable<OAuthStatesTable>; 78 102 export type DraftBlobRow = Selectable<DraftBlobsTable>; 103 + export type ScheduleRow = Selectable<SchedulesTable>; 79 104 80 105 /** 81 106 * Public draft view (returned to clients) ··· 91 116 createdAt: string; 92 117 failureReason?: string; 93 118 record?: Record<string, unknown>; 119 + triggerUrl?: string; // Only present for webhook-triggered drafts (populated by server) 120 + scheduleId?: string; // Only present for schedule-linked drafts 121 + } 122 + 123 + /** 124 + * Public schedule view (returned to clients) 125 + */ 126 + export interface ScheduleView { 127 + id: string; 128 + collection: string; 129 + status: ScheduleStatus; 130 + recurrenceRule: Record<string, unknown>; 131 + timezone: string; 132 + fireCount: number; 133 + createdAt: string; 134 + updatedAt: string; 135 + lastFiredAt?: string; 136 + nextDraftUri?: string; 137 + contentUrl?: string; 138 + record?: Record<string, unknown>; 94 139 } 95 140 96 141 /** ··· 109 154 : undefined, 110 155 createdAt: new Date(Number(row.created_at)).toISOString(), 111 156 failureReason: row.failure_reason || undefined, 157 + record: row.record ? (JSON.parse(row.record) as Record<string, unknown>) : undefined, 158 + scheduleId: row.schedule_id || undefined, 159 + }; 160 + } 161 + 162 + /** 163 + * Convert a ScheduleRow to a ScheduleView 164 + */ 165 + export function rowToScheduleView(row: ScheduleRow): ScheduleView { 166 + return { 167 + id: row.id, 168 + collection: row.collection, 169 + status: row.status, 170 + recurrenceRule: JSON.parse(row.recurrence_rule) as Record<string, unknown>, 171 + timezone: row.timezone, 172 + fireCount: Number(row.fire_count), 173 + createdAt: new Date(Number(row.created_at)).toISOString(), 174 + updatedAt: new Date(Number(row.updated_at)).toISOString(), 175 + lastFiredAt: row.last_fired_at ? new Date(Number(row.last_fired_at)).toISOString() : undefined, 176 + nextDraftUri: row.next_draft_uri || undefined, 177 + contentUrl: row.content_url || undefined, 112 178 record: row.record ? (JSON.parse(row.record) as Record<string, unknown>) : undefined, 113 179 }; 114 180 }
+485 -24
src/server.ts
··· 5 5 import pinoHttp from 'pino-http'; 6 6 import { readFileSync } from 'fs'; 7 7 import path from 'path'; 8 + import { randomUUID } from 'node:crypto'; 8 9 import { CID } from 'multiformats/cid'; 9 10 import { sha256 } from 'multiformats/hashes/sha2'; 10 11 import { LexiconDoc } from '@atproto/lexicon'; ··· 21 22 import { 22 23 createDraft, 23 24 getDraft, 25 + getDraftRawRow, 26 + getDraftByTriggerKeyHash, 24 27 listDrafts, 25 28 scheduleDraft, 26 29 updateDraft, ··· 29 32 getUserAuthorization, 30 33 countActiveDraftsForUser, 31 34 deleteUserData, 35 + createSchedule, 36 + getSchedule, 37 + getRawSchedule, 38 + listSchedules, 39 + updateSchedule, 40 + updateScheduleNextDraft, 41 + deleteSchedule, 32 42 } from './storage.js'; 33 43 import { publishDraft, notifyScheduler } from './scheduler.js'; 44 + import { encrypt, decrypt, hmac } from './encrypt.js'; 45 + import { computeNextOccurrence } from '@newpublic/recurrence'; 46 + import type { RecurrenceRule } from '@newpublic/recurrence'; 34 47 35 48 const RAW_CODEC = 0x55; 36 49 ··· 44 57 return cid.toString(); 45 58 } 46 59 60 + /** 61 + * Create a 'once' schedule and its first (only) draft, routing the draft through schedule machinery. 62 + * Used by createRecord/putRecord when x-scheduled-at header is present. 63 + */ 64 + async function createOnceDraftThroughSchedule(params: { 65 + userDid: string; 66 + collection: string; 67 + rkey: string; 68 + uri: string; 69 + cid: string; 70 + record: Record<string, unknown>; 71 + scheduledAt: number; 72 + action: 'create' | 'put'; 73 + triggerKeyHash?: string; 74 + triggerKeyEncrypted?: string; 75 + }): Promise<void> { 76 + const scheduleId = randomUUID(); 77 + const onceRule: RecurrenceRule = { 78 + rule: { type: 'once', datetime: new Date(params.scheduledAt).toISOString() }, 79 + }; 80 + await createSchedule({ 81 + id: scheduleId, 82 + userDid: params.userDid, 83 + collection: params.collection, 84 + record: params.record, 85 + contentUrl: null, 86 + recurrenceRule: onceRule as unknown as Record<string, unknown>, 87 + timezone: 'UTC', 88 + }); 89 + await createDraft({ 90 + uri: params.uri, 91 + userDid: params.userDid, 92 + collection: params.collection, 93 + rkey: params.rkey, 94 + record: params.record, 95 + recordCid: params.cid, 96 + action: params.action, 97 + scheduledAt: params.scheduledAt, 98 + scheduleId, 99 + triggerKeyHash: params.triggerKeyHash, 100 + triggerKeyEncrypted: params.triggerKeyEncrypted, 101 + }); 102 + await updateScheduleNextDraft(scheduleId, params.uri); 103 + } 104 + 47 105 const logger = createLogger('Server'); 48 106 49 107 function loadLexicons(): LexiconDoc[] { 50 108 // Load town.roundabout.scheduledPosts lexicons from bundled JSON files 51 109 const lexiconDir = path.join(__dirname, '..', 'lexicons', 'town', 'roundabout', 'scheduledPosts'); 52 - const bundledNames = ['defs', 'listPosts', 'getPost', 'schedulePost', 'publishPost', 'updatePost', 'deletePost']; 110 + const bundledNames = [ 111 + 'defs', 112 + 'listPosts', 'getPost', 'schedulePost', 'publishPost', 'updatePost', 'deletePost', 113 + 'createSchedule', 'listSchedules', 'getSchedule', 'updateSchedule', 'deleteSchedule', 114 + ]; 53 115 const bundled = bundledNames.map(name => 54 116 JSON.parse(readFileSync(path.join(lexiconDir, `${name}.json`), 'utf8')) as LexiconDoc, 55 117 ); ··· 85 147 return extractPdsUrlFromToken(token, defaultPdsUrl); 86 148 } 87 149 150 + /** 151 + * Build the one-time trigger URL from a plaintext key. 152 + */ 153 + function buildTriggerUrl(serviceUrl: string, plainKey: string): string { 154 + return `${serviceUrl}/triggers/${plainKey}`; 155 + } 156 + 88 157 export function createServer(config: ServiceConfig): express.Application { 89 158 const app = express(); 90 159 ··· 190 259 } 191 260 }); 192 261 262 + // ------------------------------------------------------------------------- 263 + // Webhook trigger endpoint — no auth required (the URL is the secret) 264 + // POST /triggers/:key 265 + // Returns: { published: true, uri: "at://..." } or error 266 + // ------------------------------------------------------------------------- 267 + app.post('/triggers/:key', async (req, res) => { 268 + const plainKey = req.params.key; 269 + /* istanbul ignore next */ 270 + if (!plainKey) { 271 + res.status(400).json({ error: 'InvalidRequest', message: 'Trigger key is required' }); 272 + return; 273 + } 274 + 275 + try { 276 + // Compute HMAC of the incoming key to look up the draft 277 + const keyHash = hmac(plainKey, config.encryptionKey); 278 + const draftRow = await getDraftByTriggerKeyHash(keyHash); 279 + 280 + if (!draftRow) { 281 + res.status(404).json({ error: 'NotFound', message: 'Trigger key not found' }); 282 + return; 283 + } 284 + 285 + // Check if already in terminal state 286 + const terminalStatuses = ['published', 'failed', 'cancelled']; 287 + if (terminalStatuses.includes(draftRow.status)) { 288 + res.status(409).json({ error: 'TriggerAlreadyFired', message: 'This trigger has already been used or the draft is no longer active' }); 289 + return; 290 + } 291 + 292 + // Publish the draft (same path as publishPost) 293 + await publishDraft(draftRow.uri, config); 294 + notifyScheduler(); 295 + 296 + const published = await getDraft(draftRow.uri); 297 + const actualStatus = published?.status ?? /* istanbul ignore next */ 'unknown'; 298 + if (actualStatus === 'published') { 299 + res.json({ published: true, uri: draftRow.uri }); 300 + } else { 301 + res.status(500).json({ 302 + error: 'PublishFailed', 303 + message: published?.failureReason ?? 'Draft failed to publish', 304 + status: actualStatus, 305 + uri: draftRow.uri, 306 + }); 307 + } 308 + } catch (err) { 309 + const msg = err instanceof Error ? err.message : /* istanbul ignore next */ String(err); 310 + logger.error('Trigger endpoint error', err instanceof Error ? err : /* istanbul ignore next */ undefined); 311 + res.status(500).json({ error: 'InternalError', message: msg }); 312 + } 313 + }); 314 + 193 315 // Load lexicons 194 316 const lexicons = loadLexicons(); 195 317 logger.info(`Loaded ${lexicons.length} lexicons`); ··· 244 366 245 367 // ------------------------------------------------------------------------- 246 368 // Write Interface - all three endpoints always create drafts 369 + // Supports x-trigger: webhook header to create a one-time trigger URL 247 370 // ------------------------------------------------------------------------- 248 371 249 372 server.method('com.atproto.repo.createRecord', async (ctx: xrpc.HandlerContext) => { ··· 285 408 const scheduledAtHeader = ctx.req.headers['x-scheduled-at'] as string | undefined; 286 409 const scheduledAt = scheduledAtHeader ? new Date(scheduledAtHeader).getTime() : undefined; 287 410 411 + // Check for webhook trigger header 412 + const triggerHeader = ctx.req.headers['x-trigger'] as string | undefined; 413 + let triggerKeyHash: string | undefined; 414 + let triggerKeyEncrypted: string | undefined; 415 + let triggerUrl: string | undefined; 416 + 417 + if (triggerHeader === 'webhook') { 418 + const plainKey = randomUUID(); 419 + triggerKeyHash = hmac(plainKey, config.encryptionKey); 420 + triggerKeyEncrypted = encrypt(plainKey, config.encryptionKey); 421 + triggerUrl = buildTriggerUrl(config.serviceUrl, plainKey); 422 + } 423 + 288 424 try { 289 - await createDraft({ 290 - uri, 291 - userDid: user.did, 292 - collection: body.collection, 293 - rkey, 294 - record: body.record, 295 - recordCid: cid, 296 - action: 'create', 297 - scheduledAt, 298 - }); 425 + if (scheduledAt) { 426 + await createOnceDraftThroughSchedule({ 427 + userDid: user.did, 428 + collection: body.collection, 429 + rkey, 430 + uri, 431 + cid, 432 + record: body.record, 433 + scheduledAt, 434 + action: 'create', 435 + triggerKeyHash, 436 + triggerKeyEncrypted, 437 + }); 438 + } else { 439 + await createDraft({ 440 + uri, 441 + userDid: user.did, 442 + collection: body.collection, 443 + rkey, 444 + record: body.record, 445 + recordCid: cid, 446 + action: 'create', 447 + scheduledAt: undefined, 448 + triggerKeyHash, 449 + triggerKeyEncrypted, 450 + }); 451 + } 299 452 } catch (err) { 300 453 if ((err as { code?: string }).code === 'DuplicateDraft') { 301 454 throw new xrpc.InvalidRequestError((err as Error).message, 'DuplicateDraft'); ··· 312 465 uri, 313 466 cid, 314 467 validationStatus: 'unknown', 468 + ...(triggerUrl ? { triggerUrl } : {}), 315 469 }, 316 470 }; 317 471 }); ··· 351 505 const scheduledAtHeader = ctx.req.headers['x-scheduled-at'] as string | undefined; 352 506 const scheduledAt = scheduledAtHeader ? new Date(scheduledAtHeader).getTime() : undefined; 353 507 508 + const triggerHeader = ctx.req.headers['x-trigger'] as string | undefined; 509 + let triggerKeyHash: string | undefined; 510 + let triggerKeyEncrypted: string | undefined; 511 + let triggerUrl: string | undefined; 512 + 513 + if (triggerHeader === 'webhook') { 514 + const plainKey = randomUUID(); 515 + triggerKeyHash = hmac(plainKey, config.encryptionKey); 516 + triggerKeyEncrypted = encrypt(plainKey, config.encryptionKey); 517 + triggerUrl = buildTriggerUrl(config.serviceUrl, plainKey); 518 + } 519 + 354 520 try { 355 - await createDraft({ 356 - uri, 357 - userDid: user.did, 358 - collection: body.collection, 359 - rkey: body.rkey, 360 - record: body.record, 361 - recordCid: cid, 362 - action: 'put', 363 - scheduledAt, 364 - }); 521 + if (scheduledAt) { 522 + await createOnceDraftThroughSchedule({ 523 + userDid: user.did, 524 + collection: body.collection, 525 + rkey: body.rkey, 526 + uri, 527 + cid, 528 + record: body.record, 529 + scheduledAt, 530 + action: 'put', 531 + triggerKeyHash, 532 + triggerKeyEncrypted, 533 + }); 534 + } else { 535 + await createDraft({ 536 + uri, 537 + userDid: user.did, 538 + collection: body.collection, 539 + rkey: body.rkey, 540 + record: body.record, 541 + recordCid: cid, 542 + action: 'put', 543 + scheduledAt: undefined, 544 + triggerKeyHash, 545 + triggerKeyEncrypted, 546 + }); 547 + } 365 548 } catch (err) { 366 549 if ((err as { code?: string }).code === 'DuplicateDraft') { 367 550 throw new xrpc.InvalidRequestError((err as Error).message, 'DuplicateDraft'); ··· 378 561 uri, 379 562 cid, 380 563 validationStatus: 'unknown', 564 + ...(triggerUrl ? { triggerUrl } : {}), 381 565 }, 382 566 }; 383 567 }); ··· 415 599 const scheduledAtHeader = ctx.req.headers['x-scheduled-at'] as string | undefined; 416 600 const scheduledAt = scheduledAtHeader ? new Date(scheduledAtHeader).getTime() : undefined; 417 601 602 + const triggerHeader = ctx.req.headers['x-trigger'] as string | undefined; 603 + let triggerKeyHash: string | undefined; 604 + let triggerKeyEncrypted: string | undefined; 605 + let triggerUrl: string | undefined; 606 + 607 + if (triggerHeader === 'webhook') { 608 + const plainKey = randomUUID(); 609 + triggerKeyHash = hmac(plainKey, config.encryptionKey); 610 + triggerKeyEncrypted = encrypt(plainKey, config.encryptionKey); 611 + triggerUrl = buildTriggerUrl(config.serviceUrl, plainKey); 612 + } 613 + 418 614 try { 419 615 await createDraft({ 420 616 uri, ··· 425 621 recordCid: null, 426 622 action: 'delete', 427 623 scheduledAt, 624 + triggerKeyHash, 625 + triggerKeyEncrypted, 428 626 }); 429 627 } catch (err) { 430 628 if ((err as { code?: string }).code === 'DuplicateDraft') { ··· 438 636 439 637 return { 440 638 encoding: 'application/json', 441 - body: {}, 639 + body: { 640 + ...(triggerUrl ? { triggerUrl } : {}), 641 + }, 442 642 }; 443 643 }); 444 644 ··· 468 668 cursor: params.cursor, 469 669 }); 470 670 671 + // Decrypt trigger keys so clients can retrieve the webhook URL from the list 672 + const posts = result.drafts.map(({ triggerKeyEncrypted, ...view }) => { 673 + if (triggerKeyEncrypted) { 674 + try { 675 + const plainKey = decrypt(triggerKeyEncrypted, config.encryptionKey); 676 + return { ...view, triggerUrl: buildTriggerUrl(config.serviceUrl, plainKey) }; 677 + } catch { 678 + // Decryption failure — omit triggerUrl for this draft 679 + } 680 + } 681 + return view; 682 + }); 683 + 471 684 return { 472 685 encoding: 'application/json', 473 686 body: { 474 - posts: result.drafts, 687 + posts, 475 688 cursor: result.cursor, 476 689 }, 477 690 }; ··· 497 710 throw new xrpc.AuthRequiredError('You can only view your own drafts'); 498 711 } 499 712 713 + // Populate triggerUrl if the draft has a webhook trigger 714 + const rawRow = await getDraftRawRow(params.uri); 715 + let triggerUrl: string | undefined; 716 + if (rawRow?.trigger_key_encrypted) { 717 + try { 718 + const plainKey = decrypt(rawRow.trigger_key_encrypted, config.encryptionKey); 719 + triggerUrl = buildTriggerUrl(config.serviceUrl, plainKey); 720 + } catch { 721 + // Decryption failure — don't expose the URL, just omit it 722 + logger.warn('Failed to decrypt trigger key for getPost', { uri: params.uri }); 723 + } 724 + } 725 + 500 726 return { 501 727 encoding: 'application/json', 502 - body: draft, 728 + body: { 729 + ...draft, 730 + ...(triggerUrl ? { triggerUrl } : {}), 731 + }, 503 732 }; 504 733 }); 505 734 ··· 637 866 } 638 867 639 868 await cancelDraft(body.uri); 869 + notifyScheduler(); 870 + 871 + return { 872 + encoding: 'application/json', 873 + body: {}, 874 + }; 875 + }); 876 + 877 + // ------------------------------------------------------------------------- 878 + // Schedule Management Endpoints 879 + // ------------------------------------------------------------------------- 880 + 881 + server.method('town.roundabout.scheduledPosts.createSchedule', async (ctx: xrpc.HandlerContext) => { 882 + const user = await requireAuth(ctx.req.headers.authorization, ctx.req.headers.dpop as string | undefined); 883 + 884 + const body = ctx.input?.body as { 885 + collection: string; 886 + recurrenceRule: Record<string, unknown>; 887 + timezone: string; 888 + record?: Record<string, unknown>; 889 + contentUrl?: string; 890 + }; 891 + 892 + /* istanbul ignore next */ 893 + if (!body?.collection || !body?.recurrenceRule || !body?.timezone) { 894 + throw new xrpc.InvalidRequestError('collection, recurrenceRule, and timezone are required'); 895 + } 896 + 897 + if (body.record && body.contentUrl) { 898 + throw new xrpc.InvalidRequestError('record and contentUrl are mutually exclusive'); 899 + } 900 + 901 + // Validate the recurrence rule by computing the first occurrence 902 + const rule = body.recurrenceRule as unknown as RecurrenceRule; 903 + 904 + const nextFireAt = computeNextOccurrence(rule, new Date()); 905 + if (!nextFireAt) { 906 + throw new xrpc.InvalidRequestError('Recurrence rule produces no future occurrences'); 907 + } 908 + 909 + const scheduleId = randomUUID(); 910 + await createSchedule({ 911 + id: scheduleId, 912 + userDid: user.did, 913 + collection: body.collection, 914 + record: body.record ?? null, 915 + contentUrl: body.contentUrl ?? null, 916 + recurrenceRule: body.recurrenceRule, 917 + timezone: body.timezone, 918 + }); 919 + 920 + // Create the first draft 921 + const rkey = `sched-${Date.now()}-${randomUUID().substring(0, 8)}`; 922 + const uri = buildAtUri(user.did, body.collection, rkey); 923 + const draftRecord = body.contentUrl ? null : (body.record ?? /* istanbul ignore next */ null); 924 + 925 + await createDraft({ 926 + uri, 927 + userDid: user.did, 928 + collection: body.collection, 929 + rkey, 930 + record: draftRecord, 931 + recordCid: null, 932 + action: 'create', 933 + scheduledAt: nextFireAt.getTime(), 934 + scheduleId, 935 + }); 936 + 937 + await updateScheduleNextDraft(scheduleId, uri); 938 + notifyScheduler(); 939 + 940 + const updatedSchedule = await getSchedule(scheduleId); 941 + logger.info('Schedule created', { scheduleId, nextFireAt: nextFireAt.toISOString() }); 942 + 943 + return { 944 + encoding: 'application/json', 945 + body: { schedule: updatedSchedule }, 946 + }; 947 + }); 948 + 949 + server.method('town.roundabout.scheduledPosts.listSchedules', async (ctx: xrpc.HandlerContext) => { 950 + const user = await requireAuth(ctx.req.headers.authorization, ctx.req.headers.dpop as string | undefined); 951 + 952 + const params = ctx.params as { 953 + repo: string; 954 + status?: string; 955 + limit?: number; 956 + cursor?: string; 957 + }; 958 + 959 + if (params.repo !== user.did) { 960 + throw new xrpc.AuthRequiredError('You can only list your own schedules'); 961 + } 962 + 963 + const result = await listSchedules({ 964 + userDid: user.did, 965 + status: params.status, 966 + limit: Number(params.limit ?? /* istanbul ignore next */ 50), 967 + cursor: params.cursor, 968 + }); 969 + 970 + return { 971 + encoding: 'application/json', 972 + body: { 973 + schedules: result.schedules, 974 + cursor: result.cursor, 975 + }, 976 + }; 977 + }); 978 + 979 + server.method('town.roundabout.scheduledPosts.getSchedule', async (ctx: xrpc.HandlerContext) => { 980 + const user = await requireAuth(ctx.req.headers.authorization, ctx.req.headers.dpop as string | undefined); 981 + 982 + const params = ctx.params as { id: string }; 983 + /* istanbul ignore next */ 984 + if (!params.id) { 985 + throw new xrpc.InvalidRequestError('id is required'); 986 + } 987 + 988 + const schedule = await getSchedule(params.id); 989 + if (!schedule) { 990 + throw new xrpc.InvalidRequestError('Schedule not found', 'NotFound'); 991 + } 992 + 993 + // Verify ownership 994 + const raw = await getRawSchedule(params.id); 995 + if (raw?.user_did !== user.did) { 996 + throw new xrpc.AuthRequiredError('You can only view your own schedules'); 997 + } 998 + 999 + return { 1000 + encoding: 'application/json', 1001 + body: { schedule }, 1002 + }; 1003 + }); 1004 + 1005 + server.method('town.roundabout.scheduledPosts.updateSchedule', async (ctx: xrpc.HandlerContext) => { 1006 + const user = await requireAuth(ctx.req.headers.authorization, ctx.req.headers.dpop as string | undefined); 1007 + 1008 + const body = ctx.input?.body as { 1009 + id: string; 1010 + recurrenceRule?: Record<string, unknown>; 1011 + timezone?: string; 1012 + record?: Record<string, unknown> | null; 1013 + contentUrl?: string | null; 1014 + status?: 'active' | 'paused'; 1015 + }; 1016 + 1017 + /* istanbul ignore next */ 1018 + if (!body?.id) { 1019 + throw new xrpc.InvalidRequestError('id is required'); 1020 + } 1021 + 1022 + const raw = await getRawSchedule(body.id); 1023 + if (!raw) { 1024 + throw new xrpc.InvalidRequestError('Schedule not found', 'NotFound'); 1025 + } 1026 + if (raw.user_did !== user.did) { 1027 + throw new xrpc.AuthRequiredError('You can only update your own schedules'); 1028 + } 1029 + 1030 + const updateParams: Parameters<typeof updateSchedule>[1] = {}; 1031 + if ('record' in body) updateParams.record = body.record ?? /* istanbul ignore next */ null; 1032 + if ('contentUrl' in body) updateParams.contentUrl = body.contentUrl ?? /* istanbul ignore next */ null; 1033 + if (body.recurrenceRule !== undefined) updateParams.recurrenceRule = body.recurrenceRule; 1034 + if (body.timezone !== undefined) updateParams.timezone = body.timezone; 1035 + 1036 + // Handle pause/resume 1037 + if (body.status === 'paused' && raw.status === 'active') { 1038 + // Cancel the pending draft 1039 + if (raw.next_draft_uri) { 1040 + await cancelDraft(raw.next_draft_uri); 1041 + } 1042 + updateParams.status = 'paused'; 1043 + await updateScheduleNextDraft(body.id, null); 1044 + } else if (body.status === 'active' && raw.status === 'paused') { 1045 + // Resume: create a new next draft 1046 + const ruleJson = body.recurrenceRule ?? JSON.parse(raw.recurrence_rule) as Record<string, unknown>; 1047 + const rule = ruleJson as unknown as RecurrenceRule; 1048 + const nextFireAt = computeNextOccurrence(rule, new Date()); 1049 + if (nextFireAt) { 1050 + const rkey = `sched-${Date.now()}-${randomUUID().substring(0, 8)}`; 1051 + const collection = raw.collection; 1052 + const uri = buildAtUri(user.did, collection, rkey); 1053 + const draftRecord = raw.content_url ? null : (raw.record ? JSON.parse(raw.record) as Record<string, unknown> : null); 1054 + 1055 + await createDraft({ 1056 + uri, 1057 + userDid: user.did, 1058 + collection, 1059 + rkey, 1060 + record: draftRecord, 1061 + recordCid: null, 1062 + action: 'create', 1063 + scheduledAt: nextFireAt.getTime(), 1064 + scheduleId: body.id, 1065 + }); 1066 + 1067 + await updateScheduleNextDraft(body.id, uri); 1068 + notifyScheduler(); 1069 + } 1070 + updateParams.status = 'active'; 1071 + } else if (body.status !== undefined) { 1072 + updateParams.status = body.status; 1073 + } 1074 + 1075 + const schedule = await updateSchedule(body.id, updateParams); 1076 + 1077 + return { 1078 + encoding: 'application/json', 1079 + body: { schedule }, 1080 + }; 1081 + }); 1082 + 1083 + server.method('town.roundabout.scheduledPosts.deleteSchedule', async (ctx: xrpc.HandlerContext) => { 1084 + const user = await requireAuth(ctx.req.headers.authorization, ctx.req.headers.dpop as string | undefined); 1085 + 1086 + const body = ctx.input?.body as { id: string }; 1087 + /* istanbul ignore next */ 1088 + if (!body?.id) { 1089 + throw new xrpc.InvalidRequestError('id is required'); 1090 + } 1091 + 1092 + const raw = await getRawSchedule(body.id); 1093 + if (!raw) { 1094 + throw new xrpc.InvalidRequestError('Schedule not found', 'NotFound'); 1095 + } 1096 + if (raw.user_did !== user.did) { 1097 + throw new xrpc.AuthRequiredError('You can only delete your own schedules'); 1098 + } 1099 + 1100 + await deleteSchedule(body.id); 640 1101 notifyScheduler(); 641 1102 642 1103 return {
+227 -5
src/storage.ts
··· 1 - // ABOUTME: Database operations for ALF (Atproto Latency Fabric) service (drafts CRUD, user authorizations, blob storage) 1 + // ABOUTME: Database operations for ALF (Atproto Latency Fabric) service (drafts CRUD, user authorizations, blob storage, schedules) 2 2 3 3 import { Kysely } from 'kysely'; 4 - import type { Database, DraftView, DraftRow, DraftAction, DraftStatus } from './schema.js'; 5 - import { rowToDraftView } from './schema.js'; 4 + import type { Database, DraftView, DraftRow, DraftAction, DraftStatus, ScheduleRow, ScheduleView, ScheduleStatus } from './schema.js'; 5 + import { rowToDraftView, rowToScheduleView } from './schema.js'; 6 6 import { createLogger } from './logger.js'; 7 7 8 8 const logger = createLogger('Storage'); ··· 30 30 recordCid: string | null; 31 31 action: DraftAction; 32 32 scheduledAt?: number; 33 + triggerKeyHash?: string; 34 + triggerKeyEncrypted?: string; 35 + scheduleId?: string; 33 36 } 34 37 35 38 export async function createDraft(params: CreateDraftParams): Promise<DraftView> { 36 39 const now = Date.now(); 40 + // Draft status: if it has a trigger key, it waits as 'draft' (no scheduled_at) 41 + // If it has a scheduled_at, it's 'scheduled' 42 + // Otherwise it's 'draft' 37 43 const status: DraftStatus = params.scheduledAt ? 'scheduled' : 'draft'; 38 44 39 45 // Check if an active draft already exists for this AT-URI ··· 70 76 updated_at: now, 71 77 published_at: null, 72 78 failure_reason: null, 79 + trigger_key_hash: params.triggerKeyHash ?? null, 80 + trigger_key_encrypted: params.triggerKeyEncrypted ?? null, 81 + schedule_id: params.scheduleId ?? null, 73 82 }) 74 83 .execute(); 75 84 ··· 89 98 return row ? rowToDraftView(row) : null; 90 99 } 91 100 101 + /** 102 + * Get the raw DraftRow (including encrypted trigger key fields) 103 + */ 104 + export async function getDraftRawRow(uri: string): Promise<DraftRow | null> { 105 + const row = await getDb() 106 + .selectFrom('drafts') 107 + .selectAll() 108 + .where('uri', '=', uri) 109 + .executeTakeFirst(); 110 + return row ?? null; 111 + } 112 + 92 113 async function getDraftRow(uri: string): Promise<DraftRow> { 93 114 const row = await getDb() 94 115 .selectFrom('drafts') ··· 98 119 return row; 99 120 } 100 121 122 + /** 123 + * Look up a draft by its trigger key HMAC hash. 124 + * Returns the raw row (including encrypted key) or null if not found. 125 + */ 126 + export async function getDraftByTriggerKeyHash(hash: string): Promise<DraftRow | null> { 127 + const row = await getDb() 128 + .selectFrom('drafts') 129 + .selectAll() 130 + .where('trigger_key_hash', '=', hash) 131 + .executeTakeFirst(); 132 + return row ?? null; 133 + } 134 + 101 135 export async function listDrafts(params: { 102 136 userDid: string; 103 137 status?: string; 104 138 limit: number; 105 139 cursor?: string; 106 - }): Promise<{ drafts: DraftView[]; cursor?: string }> { 140 + }): Promise<{ drafts: Array<DraftView & { triggerKeyEncrypted?: string | null }>; cursor?: string }> { 107 141 let query = getDb() 108 142 .selectFrom('drafts') 109 143 .selectAll() ··· 145 179 } 146 180 147 181 return { 148 - drafts: rows.map(rowToDraftView), 182 + drafts: rows.map(row => ({ 183 + ...rowToDraftView(row), 184 + triggerKeyEncrypted: row.trigger_key_encrypted, 185 + })), 149 186 cursor: nextCursor, 150 187 }; 151 188 } ··· 486 523 .where('expires_at', '<=', now) 487 524 .execute(); 488 525 } 526 + 527 + // ---- Schedule Operations ---- 528 + 529 + export interface CreateScheduleParams { 530 + id: string; 531 + userDid: string; 532 + collection: string; 533 + record: Record<string, unknown> | null; 534 + contentUrl: string | null; 535 + recurrenceRule: Record<string, unknown>; 536 + timezone: string; 537 + } 538 + 539 + export async function createSchedule(params: CreateScheduleParams): Promise<ScheduleRow> { 540 + const now = Date.now(); 541 + await getDb() 542 + .insertInto('schedules') 543 + .values({ 544 + id: params.id, 545 + user_did: params.userDid, 546 + collection: params.collection, 547 + record: params.record ? JSON.stringify(params.record) : null, 548 + content_url: params.contentUrl, 549 + recurrence_rule: JSON.stringify(params.recurrenceRule), 550 + timezone: params.timezone, 551 + status: 'active', 552 + fire_count: 0, 553 + created_at: now, 554 + updated_at: now, 555 + last_fired_at: null, 556 + next_draft_uri: null, 557 + }) 558 + .execute(); 559 + 560 + return getScheduleRow(params.id); 561 + } 562 + 563 + async function getScheduleRow(id: string): Promise<ScheduleRow> { 564 + return getDb() 565 + .selectFrom('schedules') 566 + .selectAll() 567 + .where('id', '=', id) 568 + .executeTakeFirstOrThrow(); 569 + } 570 + 571 + export async function getSchedule(id: string): Promise<ScheduleView | null> { 572 + const row = await getDb() 573 + .selectFrom('schedules') 574 + .selectAll() 575 + .where('id', '=', id) 576 + .executeTakeFirst(); 577 + return row ? rowToScheduleView(row) : null; 578 + } 579 + 580 + export async function getRawSchedule(id: string): Promise<ScheduleRow | null> { 581 + const row = await getDb() 582 + .selectFrom('schedules') 583 + .selectAll() 584 + .where('id', '=', id) 585 + .executeTakeFirst(); 586 + return row ?? null; 587 + } 588 + 589 + export async function listSchedules(params: { 590 + userDid: string; 591 + status?: string; 592 + limit: number; 593 + cursor?: string; 594 + }): Promise<{ schedules: ScheduleView[]; cursor?: string }> { 595 + let query = getDb() 596 + .selectFrom('schedules') 597 + .selectAll() 598 + .where('user_did', '=', params.userDid) 599 + .orderBy('created_at', 'desc'); 600 + 601 + if (params.status) { 602 + query = query.where('status', '=', params.status as ScheduleStatus); 603 + } 604 + 605 + if (params.cursor) { 606 + const cursorTime = parseInt(params.cursor, 10); 607 + query = query.where('created_at', '<', cursorTime); 608 + } 609 + 610 + query = query.limit(params.limit + 1); 611 + 612 + const rows = await query.execute(); 613 + 614 + let nextCursor: string | undefined; 615 + if (rows.length > params.limit) { 616 + rows.pop(); 617 + const lastRow = rows[rows.length - 1]; 618 + if (lastRow) { 619 + nextCursor = String(lastRow.created_at); 620 + } 621 + } 622 + 623 + return { 624 + schedules: rows.map(rowToScheduleView), 625 + cursor: nextCursor, 626 + }; 627 + } 628 + 629 + export async function updateScheduleNextDraft(id: string, nextDraftUri: string | null): Promise<void> { 630 + const now = Date.now(); 631 + await getDb() 632 + .updateTable('schedules') 633 + .set({ next_draft_uri: nextDraftUri, updated_at: now }) 634 + .where('id', '=', id) 635 + .execute(); 636 + } 637 + 638 + export async function updateScheduleStatus(id: string, status: ScheduleStatus): Promise<void> { 639 + const now = Date.now(); 640 + await getDb() 641 + .updateTable('schedules') 642 + .set({ status, updated_at: now }) 643 + .where('id', '=', id) 644 + .execute(); 645 + } 646 + 647 + export async function incrementScheduleFireCount(id: string): Promise<void> { 648 + const now = Date.now(); 649 + await getDb() 650 + .updateTable('schedules') 651 + .set((eb) => ({ 652 + fire_count: eb('fire_count', '+', 1), 653 + last_fired_at: now, 654 + updated_at: now, 655 + })) 656 + .where('id', '=', id) 657 + .execute(); 658 + } 659 + 660 + export async function updateSchedule(id: string, params: { 661 + record?: Record<string, unknown> | null; 662 + contentUrl?: string | null; 663 + recurrenceRule?: Record<string, unknown>; 664 + timezone?: string; 665 + status?: ScheduleStatus; 666 + }): Promise<ScheduleView | null> { 667 + const now = Date.now(); 668 + const updates: Record<string, unknown> = { updated_at: now }; 669 + 670 + if ('record' in params) { 671 + updates.record = params.record ? JSON.stringify(params.record) : null; 672 + } 673 + if ('contentUrl' in params) { 674 + updates.content_url = params.contentUrl ?? null; 675 + } 676 + if (params.recurrenceRule !== undefined) { 677 + updates.recurrence_rule = JSON.stringify(params.recurrenceRule); 678 + } 679 + if (params.timezone !== undefined) { 680 + updates.timezone = params.timezone; 681 + } 682 + if (params.status !== undefined) { 683 + updates.status = params.status; 684 + } 685 + 686 + await getDb() 687 + .updateTable('schedules') 688 + .set(updates) 689 + .where('id', '=', id) 690 + .execute(); 691 + 692 + return getSchedule(id); 693 + } 694 + 695 + export async function deleteSchedule(id: string): Promise<void> { 696 + const now = Date.now(); 697 + // Cancel any pending draft linked to this schedule 698 + await getDb() 699 + .updateTable('drafts') 700 + .set({ status: 'cancelled', updated_at: now }) 701 + .where('schedule_id', '=', id) 702 + .where('status', 'in', ['draft', 'scheduled']) 703 + .execute(); 704 + 705 + await getDb() 706 + .updateTable('schedules') 707 + .set({ status: 'cancelled', next_draft_uri: null, updated_at: now }) 708 + .where('id', '=', id) 709 + .execute(); 710 + }