@podosoft/podokit 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. package/README.md +2 -1
  2. package/dist/app-proxy-timeouts.d.ts +27 -0
  3. package/dist/app-proxy-timeouts.js +180 -0
  4. package/dist/assemble.d.ts +5 -2
  5. package/dist/assemble.js +14 -4
  6. package/dist/deploy-checks.d.ts +44 -0
  7. package/dist/deploy-checks.js +267 -0
  8. package/dist/deploy-compose-profile.d.ts +6 -2
  9. package/dist/deploy-compose-profile.js +15 -6
  10. package/dist/deploy-compose-render.d.ts +12 -4
  11. package/dist/deploy-compose-render.js +47 -18
  12. package/dist/deploy-compose.d.ts +2 -5
  13. package/dist/deploy-compose.js +7 -2
  14. package/dist/deploy-profile.d.ts +6 -2
  15. package/dist/deploy-profile.js +11 -7
  16. package/dist/deploy-render.d.ts +4 -1
  17. package/dist/deploy-render.js +27 -21
  18. package/dist/deploy-schema.d.ts +56 -2
  19. package/dist/deploy-schema.js +130 -14
  20. package/dist/deploy.d.ts +2 -5
  21. package/dist/deploy.js +5 -2
  22. package/dist/dev.d.ts +21 -0
  23. package/dist/dev.js +78 -3
  24. package/dist/eject.js +2 -0
  25. package/dist/index.js +85 -5
  26. package/dist/lib.d.ts +2 -0
  27. package/dist/lib.js +5 -2
  28. package/dist/lockfile.d.ts +24 -0
  29. package/dist/lockfile.js +74 -4
  30. package/dist/provider.js +10 -5
  31. package/dist/remove.js +1 -1
  32. package/dist/template-files.d.ts +42 -0
  33. package/dist/template-files.js +102 -0
  34. package/dist/templates/fullstack/AGENTS.md +28 -4
  35. package/dist/templates/fullstack/README.md +8 -7
  36. package/dist/templates/fullstack/apps/api/package.json +4 -4
  37. package/dist/templates/fullstack/apps/api/scripts/build.extensions.mjs +6 -0
  38. package/dist/templates/fullstack/apps/api/scripts/build.mjs +65 -16
  39. package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +7 -3
  40. package/dist/templates/fullstack/apps/api/scripts/verify-api-contract.ts +5 -1
  41. package/dist/templates/fullstack/apps/api/src/app.spec.ts +22 -0
  42. package/dist/templates/fullstack/apps/api/src/app.ts +41 -25
  43. package/dist/templates/fullstack/apps/api/src/build.spec.ts +118 -0
  44. package/dist/templates/fullstack/apps/api/src/config/env.extensions.ts +20 -0
  45. package/dist/templates/fullstack/apps/api/src/config/env.validation.spec.ts +76 -0
  46. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +18 -3
  47. package/dist/templates/fullstack/apps/api/src/config/provider-selection.spec.ts +53 -0
  48. package/dist/templates/fullstack/apps/api/src/config/provider-selection.ts +79 -0
  49. package/dist/templates/fullstack/apps/api/src/config/providers.ts +7 -3
  50. package/dist/templates/fullstack/apps/api/src/core/api-contract.extensions.ts +6 -0
  51. package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +29 -0
  52. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +39 -2
  53. package/dist/templates/fullstack/apps/api/src/core/services.ts +13 -0
  54. package/dist/templates/fullstack/apps/api/src/database/data-source.spec.ts +27 -0
  55. package/dist/templates/fullstack/apps/api/src/database/data-source.ts +7 -10
  56. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +12 -0
  57. package/dist/templates/fullstack/apps/api/src/database/database.ts +2 -3
  58. package/dist/templates/fullstack/apps/api/src/database/entities.ts +12 -0
  59. package/dist/templates/fullstack/apps/api/src/database/migrate.ts +12 -2
  60. package/dist/templates/fullstack/apps/api/src/database/migration-files.spec.ts +116 -0
  61. package/dist/templates/fullstack/apps/api/src/database/migration-files.ts +134 -0
  62. package/dist/templates/fullstack/apps/api/src/database/migration-readiness.spec.ts +36 -0
  63. package/dist/templates/fullstack/apps/api/src/database/migration-readiness.ts +25 -0
  64. package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +12 -1
  65. package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.ts +2 -36
  66. package/dist/templates/fullstack/apps/api/src/main.ts +2 -2
  67. package/dist/templates/fullstack/apps/api/tsconfig.json +2 -0
  68. package/dist/templates/fullstack/apps/web/package.json +1 -1
  69. package/dist/templates/fullstack/apps/web/src/app.css +3 -0
  70. package/dist/templates/fullstack/apps/web/src/app.extensions.css +11 -0
  71. package/dist/templates/fullstack/apps/web/src/lib/components/site-runtime.svelte +3 -1
  72. package/dist/templates/fullstack/apps/web/src/lib/server/api.test.ts +19 -0
  73. package/dist/templates/fullstack/apps/web/src/lib/server/api.ts +4 -1
  74. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +79 -0
  75. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +55 -1
  76. package/dist/templates/fullstack/apps/web/src/lib/server/proxy-timeouts.ts +2 -1
  77. package/dist/templates/fullstack/apps/web/tsconfig.json +1 -1
  78. package/dist/templates/fullstack/apps/web/vite.config.test.ts +33 -0
  79. package/dist/templates/fullstack/apps/web/vite.config.ts +55 -13
  80. package/dist/templates/fullstack/apps/web/vite.extensions.ts +23 -0
  81. package/dist/templates/fullstack/compose.dev.yaml +7 -4
  82. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/SKILL.md +2 -2
  83. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +12 -3
  84. package/dist/templates/fullstack/dot-claude/skills/podokit-update/SKILL.md +5 -2
  85. package/dist/templates/fullstack/dot-env.docker.example +5 -2
  86. package/dist/templates/fullstack/dot-env.example +4 -0
  87. package/dist/templates/fullstack/dot-podokit/dev.json +1 -1
  88. package/dist/templates/fullstack/infra/k3s/configmap.yaml +1 -0
  89. package/dist/templates/fullstack/tests/README.md +40 -1
  90. package/dist/templates/fullstack/tests/helpers/app-paths.ts +18 -0
  91. package/dist/templates/fullstack/tests/helpers/paths.ts +33 -0
  92. package/dist/templates/fullstack/tests/package.json +2 -2
  93. package/dist/templates/fullstack/tests/playwright.config.ts +7 -3
  94. package/dist/templates/fullstack/tests/playwright.extensions.ts +88 -1
  95. package/dist/templates/fullstack/tests/ui/smoke.ui.spec.ts +5 -4
  96. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +37 -1
  97. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +15 -6
  98. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.test.ts +31 -0
  99. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.ts +31 -0
  100. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flags.ts +12 -0
  101. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-nav.ts +11 -0
  102. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.test.ts +39 -0
  103. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.ts +35 -0
  104. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/registry.svelte.ts +15 -0
  105. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +11 -1
  106. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/admin-sidebar.svelte +2 -23
  107. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +7 -2
  108. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.test.ts +30 -0
  109. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/impersonation-banner.svelte +3 -2
  110. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/settings/app-flags-settings.svelte +65 -0
  111. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +32 -23
  112. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.test.ts +27 -0
  113. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-footer.svelte +4 -1
  114. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +3 -1
  115. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -0
  116. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -0
  117. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/app-hooks.ts +11 -0
  118. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/non-indexable-paths.ts +8 -0
  119. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.test.ts +31 -0
  120. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.ts +5 -1
  121. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +1 -1
  122. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.server.ts +2 -1
  123. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.svelte +1 -5
  124. package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -0
  125. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +1 -1
  126. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +159 -170
  127. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +72 -81
  128. package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +1 -6
  129. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +1 -1
  130. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +43 -53
  131. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +3 -1
  132. package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +24 -6
  133. package/dist/templates/modules/admin-dashboard/files/tests/helpers/app-accounts.ts +24 -0
  134. package/dist/templates/modules/admin-dashboard/files/tests/helpers/devices.ts +41 -11
  135. package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +5 -3
  136. package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +3 -9
  137. package/dist/templates/modules/admin-dashboard/files/tests/helpers/sms.ts +2 -4
  138. package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +11 -5
  139. package/dist/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts +4 -5
  140. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +55 -55
  141. package/dist/templates/modules/admin-dashboard/files/tests/ui/app-flags.ui.spec.ts +63 -0
  142. package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +12 -16
  143. package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +3 -2
  144. package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +32 -35
  145. package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +4 -2
  146. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +1 -2
  147. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -2
  148. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +82 -91
  149. package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +6 -2
  150. package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +16 -20
  151. package/dist/templates/modules/admin-dashboard/files/tests/ui/impersonation.ui.spec.ts +6 -3
  152. package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -3
  153. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +2 -4
  154. package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +5 -2
  155. package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +7 -2
  156. package/dist/templates/modules/admin-dashboard/files/tests/ui/same-origin-requests.ui.spec.ts +89 -0
  157. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +5 -4
  158. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +67 -73
  159. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +84 -93
  160. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +8 -7
  161. package/dist/templates/modules/admin-dashboard/files/tests/ui/theme-mode.ui.spec.ts +29 -0
  162. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +51 -54
  163. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +10 -9
  164. package/dist/templates/modules/admin-dashboard/module.manifest.json +7 -2
  165. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +5 -1
  166. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +17 -0
  167. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +18 -0
  168. package/dist/templates/modules/audit-log/module.manifest.json +15 -3
  169. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-app-name.spec.ts +21 -0
  170. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +13 -1
  171. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-environment.ts +9 -2
  172. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +19 -3
  173. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +2 -0
  174. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.runtime.spec.ts +10 -0
  175. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +7 -5
  176. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.spec.ts +86 -0
  177. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.ts +44 -0
  178. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +2 -1
  179. package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -1
  180. package/dist/templates/modules/auth/files/apps/api/src/settings/app-flags.ts +9 -0
  181. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.spec.ts +43 -0
  182. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +30 -5
  183. package/dist/templates/modules/auth/module.manifest.json +8 -2
  184. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +57 -1
  185. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +93 -9
  186. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.spec.ts +40 -0
  187. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.ts +52 -5
  188. package/dist/templates/modules/bullmq/files/apps/api/src/main-worker.ts +9 -7
  189. package/dist/templates/modules/bullmq/files/apps/api/src/worker.extensions.ts +13 -0
  190. package/dist/templates/modules/bullmq/module.manifest.json +5 -1
  191. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +2 -0
  192. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +87 -1
  193. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +93 -3
  194. package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
  195. package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.spec.ts +16 -0
  196. package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.ts +15 -10
  197. package/dist/templates/modules/mailer/module.manifest.json +1 -1
  198. package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +1 -1
  199. package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +5 -4
  200. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +1 -1
  201. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +45 -20
  202. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +1 -1
  203. package/dist/templates/todo/AGENTS.md +5 -1
  204. package/dist/templates/todo/README.md +6 -4
  205. package/dist/text-diff.d.ts +12 -0
  206. package/dist/text-diff.js +111 -0
  207. package/dist/update.d.ts +61 -3
  208. package/dist/update.js +152 -27
  209. package/package.json +2 -2
  210. package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +0 -75
  211. package/dist/templates/modules/admin-dashboard/files/tests/helpers/exclusive.ts +0 -80
@@ -1,6 +1,6 @@
1
1
  import { afterEach, describe, expect, it } from "bun:test";
2
2
  import { SQL } from "bun";
3
- import { LocalJobQueue } from "./local-job.queue";
3
+ import { LocalJobQueue, nextScheduledTime, type ScheduleTimer } from "./local-job.queue";
4
4
 
5
5
  let sql: SQL | undefined;
6
6
 
@@ -66,3 +66,89 @@ describe("local job queue", () => {
66
66
  queue.close();
67
67
  });
68
68
  });
69
+
70
+ /** A clock and timer the test moves by hand. */
71
+ function manualTime(start: number): { now: () => number; timer: ScheduleTimer; advance: (to: number) => void; waiting: () => number } {
72
+ let now = start;
73
+ const timers: Array<{ at: number; task: () => void; done: boolean }> = [];
74
+ return {
75
+ now: () => now,
76
+ timer: {
77
+ after: (delayMs, task) => {
78
+ const entry = { at: now + delayMs, task, done: false };
79
+ timers.push(entry);
80
+ return () => { entry.done = true; };
81
+ },
82
+ },
83
+ advance: (to) => {
84
+ for (;;) {
85
+ const due = timers.filter((entry) => !entry.done && entry.at <= to).sort((a, b) => a.at - b.at)[0];
86
+ if (!due) break;
87
+ due.done = true;
88
+ now = due.at;
89
+ due.task();
90
+ }
91
+ now = to;
92
+ },
93
+ waiting: () => timers.filter((entry) => !entry.done).length,
94
+ };
95
+ }
96
+
97
+ async function scheduledJobs(database: SQL): Promise<string[]> {
98
+ // Scheduled jobs are added without waiting; let those inserts finish.
99
+ await Bun.sleep(5);
100
+ const rows = await database<Array<{ id: string }>>`SELECT "id" FROM "podokit_job" ORDER BY "createdAt", "id"`;
101
+ return rows.map((row) => row.id);
102
+ }
103
+
104
+ describe("local job schedules", () => {
105
+ it("adds one job for each time of an interval and keeps going", async () => {
106
+ sql = new SQL(":memory:");
107
+ const time = manualTime(10_500);
108
+ const queue = new LocalJobQueue(sql, { pollIntervalMs: 60_000, now: time.now, timer: time.timer });
109
+ await queue.schedule("nightly", "report", { scope: "all" }, { everyMs: 1_000 });
110
+ await queue.start();
111
+ time.advance(11_000);
112
+ expect(await scheduledJobs(sql)).toEqual(["report:schedule:nightly:11000"]);
113
+ time.advance(13_200);
114
+ expect(await scheduledJobs(sql)).toEqual([
115
+ "report:schedule:nightly:11000",
116
+ "report:schedule:nightly:12000",
117
+ "report:schedule:nightly:13000",
118
+ ]);
119
+ await expect(queue.get("report:schedule:nightly:12000")).resolves.toMatchObject({ name: "report", status: "waiting" });
120
+ queue.close();
121
+ expect(time.waiting()).toBe(0);
122
+ });
123
+
124
+ it("replaces a schedule with the same id and stops one on unschedule", async () => {
125
+ sql = new SQL(":memory:");
126
+ const time = manualTime(0);
127
+ const queue = new LocalJobQueue(sql, { pollIntervalMs: 60_000, now: time.now, timer: time.timer });
128
+ await queue.start();
129
+ await queue.schedule("sync", "sync", {}, { everyMs: 1_000 });
130
+ await queue.schedule("sync", "sync", {}, { everyMs: 5_000 });
131
+ time.advance(5_000);
132
+ expect(await scheduledJobs(sql)).toEqual(["sync:schedule:sync:5000"]);
133
+ await queue.unschedule("sync");
134
+ time.advance(20_000);
135
+ expect(await scheduledJobs(sql)).toEqual(["sync:schedule:sync:5000"]);
136
+ queue.close();
137
+ });
138
+
139
+ it("reads cron expressions in the schedule's time zone, UTC by default", () => {
140
+ const after = Date.UTC(2026, 0, 1, 0, 0);
141
+ expect(nextScheduledTime({ cron: "0 3 * * *" }, after)).toBe(Date.UTC(2026, 0, 1, 3, 0));
142
+ expect(nextScheduledTime({ cron: "0 3 * * *", timezone: "Asia/Seoul" }, after)).toBe(Date.UTC(2026, 0, 1, 18, 0));
143
+ expect(nextScheduledTime({ everyMs: 60_000 }, after + 1)).toBe(after + 60_000);
144
+ });
145
+
146
+ it("rejects an invalid schedule before it is kept", async () => {
147
+ sql = new SQL(":memory:");
148
+ const queue = new LocalJobQueue(sql);
149
+ await expect(queue.schedule("bad", "report", {}, { cron: "61 * * * *" })).rejects.toThrow();
150
+ await expect(queue.schedule("bad", "report", {}, { cron: "0 3 * * *", timezone: "Mars/Olympus" })).rejects.toThrow();
151
+ await expect(queue.schedule("bad", "report", {}, { everyMs: 10 })).rejects.toThrow("at least 1000");
152
+ queue.close();
153
+ });
154
+ });
@@ -1,11 +1,14 @@
1
1
  import type { SQL } from "bun";
2
+ import cronParser from "cron-parser";
2
3
  import {
4
+ assertRepeatOptions,
3
5
  type EnqueueOptions,
4
6
  type JobHandler,
5
7
  type JobQueue,
6
8
  type JobReference,
7
9
  type JobSnapshot,
8
10
  type JobStatus,
11
+ type RepeatOptions,
9
12
  type Unsubscribe,
10
13
  } from "@podosoft/podokit-runtime";
11
14
 
@@ -22,15 +25,52 @@ interface JobRow {
22
25
  error: string | null;
23
26
  }
24
27
 
28
+ export interface ScheduleTimer {
29
+ /** Runs `task` once after `delayMs`; the returned function cancels it. */
30
+ after(delayMs: number, task: () => void): () => void;
31
+ }
32
+
25
33
  export interface LocalJobQueueOptions {
26
34
  pollIntervalMs?: number;
27
35
  now?: () => number;
36
+ /** Waits for schedules; an unref'd setTimeout by default. */
37
+ timer?: ScheduleTimer;
38
+ }
39
+
40
+ interface Schedule {
41
+ id: string;
42
+ name: string;
43
+ payload: unknown;
44
+ repeat: RepeatOptions;
45
+ cancel?: () => void;
46
+ }
47
+
48
+ // setTimeout fires at once for a delay above 2^31 - 1 ms, so longer waits are split.
49
+ const MAX_TIMER_DELAY_MS = 2 ** 31 - 1;
50
+
51
+ const unrefTimer: ScheduleTimer = {
52
+ after: (delayMs, task) => {
53
+ const timer = setTimeout(task, delayMs);
54
+ timer.unref?.();
55
+ return () => clearTimeout(timer);
56
+ },
57
+ };
58
+
59
+ /** The first time after `after` (epoch milliseconds) that `repeat` adds a job. */
60
+ export function nextScheduledTime(repeat: RepeatOptions, after: number): number {
61
+ if (repeat.everyMs !== undefined) return (Math.floor(after / repeat.everyMs) + 1) * repeat.everyMs;
62
+ return cronParser
63
+ .parseExpression(repeat.cron ?? "", { currentDate: new Date(after), tz: repeat.timezone ?? "UTC" })
64
+ .next()
65
+ .getTime();
28
66
  }
29
67
 
30
68
  export class LocalJobQueue implements JobQueue {
31
69
  private readonly handlers = new Map<string, JobHandler>();
70
+ private readonly schedules = new Map<string, Schedule>();
32
71
  private readonly pollIntervalMs: number;
33
72
  private readonly now: () => number;
73
+ private readonly scheduleTimer: ScheduleTimer;
34
74
  private timer?: ReturnType<typeof setTimeout>;
35
75
  private started = false;
36
76
  private initialized?: Promise<void>;
@@ -44,6 +84,7 @@ export class LocalJobQueue implements JobQueue {
44
84
  throw new Error("pollIntervalMs must be an integer of at least 10");
45
85
  }
46
86
  this.now = options.now ?? Date.now;
87
+ this.scheduleTimer = options.timer ?? unrefTimer;
47
88
  }
48
89
 
49
90
  initialize(): Promise<void> {
@@ -63,7 +104,8 @@ export class LocalJobQueue implements JobQueue {
63
104
  if (this.started) return;
64
105
  await this.initialize();
65
106
  this.started = true;
66
- this.schedule(0);
107
+ this.poll(0);
108
+ for (const schedule of this.schedules.values()) this.arm(schedule);
67
109
  }
68
110
 
69
111
  async enqueue(
@@ -118,6 +160,22 @@ export class LocalJobQueue implements JobQueue {
118
160
  });
119
161
  }
120
162
 
163
+ async schedule(id: string, name: string, payload: unknown, repeat: RepeatOptions): Promise<void> {
164
+ assertRepeatOptions(repeat);
165
+ // Rejects an invalid cron expression or time zone now rather than at the first run.
166
+ nextScheduledTime(repeat, this.now());
167
+ if (JSON.stringify(payload) === undefined) throw new Error("Job payload must be JSON serializable");
168
+ this.schedules.get(id)?.cancel?.();
169
+ const schedule: Schedule = { id, name, payload, repeat };
170
+ this.schedules.set(id, schedule);
171
+ if (this.started) this.arm(schedule);
172
+ }
173
+
174
+ async unschedule(id: string): Promise<void> {
175
+ this.schedules.get(id)?.cancel?.();
176
+ this.schedules.delete(id);
177
+ }
178
+
121
179
  async runOnce(): Promise<boolean> {
122
180
  await this.initialize();
123
181
  const now = this.now();
@@ -157,9 +215,41 @@ export class LocalJobQueue implements JobQueue {
157
215
  this.started = false;
158
216
  if (this.timer) clearTimeout(this.timer);
159
217
  this.timer = undefined;
218
+ for (const schedule of this.schedules.values()) schedule.cancel?.();
160
219
  this.handlers.clear();
161
220
  }
162
221
 
222
+ /**
223
+ * Adds the schedule's next job when its time comes. The job id names the
224
+ * schedule and the time, so a job is added once for each time.
225
+ */
226
+ private arm(schedule: Schedule, after = this.now()): void {
227
+ const at = nextScheduledTime(schedule.repeat, after);
228
+ schedule.cancel = this.waitUntil(at, () => {
229
+ if (!this.started || this.schedules.get(schedule.id) !== schedule) return;
230
+ void this.enqueue(schedule.name, schedule.payload, { deduplicationKey: `schedule:${schedule.id}:${at}` })
231
+ .catch((error: unknown) => {
232
+ const message = error instanceof Error ? error.message : String(error);
233
+ process.stderr.write(`Add scheduled job failed: ${schedule.id} ${message}\n`);
234
+ });
235
+ this.arm(schedule, Math.max(this.now(), at));
236
+ });
237
+ }
238
+
239
+ private waitUntil(at: number, task: () => void): () => void {
240
+ let cancel = (): void => undefined;
241
+ const step = (): void => {
242
+ const delay = at - this.now();
243
+ if (delay <= 0) {
244
+ task();
245
+ return;
246
+ }
247
+ cancel = this.scheduleTimer.after(Math.min(delay, MAX_TIMER_DELAY_MS), step);
248
+ };
249
+ cancel = this.scheduleTimer.after(Math.max(0, Math.min(at - this.now(), MAX_TIMER_DELAY_MS)), step);
250
+ return () => cancel();
251
+ }
252
+
163
253
  private async createTables(): Promise<void> {
164
254
  await this.sql`
165
255
  CREATE TABLE IF NOT EXISTS "podokit_job" (
@@ -181,7 +271,7 @@ export class LocalJobQueue implements JobQueue {
181
271
  `;
182
272
  }
183
273
 
184
- private schedule(delay: number): void {
274
+ private poll(delay: number): void {
185
275
  if (!this.started) return;
186
276
  this.timer = setTimeout(() => {
187
277
  void this.runOnce()
@@ -189,7 +279,7 @@ export class LocalJobQueue implements JobQueue {
189
279
  const message = error instanceof Error ? error.message : String(error);
190
280
  process.stderr.write(`Process local job failed: ${message}\n`);
191
281
  })
192
- .finally(() => this.schedule(this.pollIntervalMs));
282
+ .finally(() => this.poll(this.pollIntervalMs));
193
283
  }, delay);
194
284
  this.timer.unref?.();
195
285
  }
@@ -15,6 +15,9 @@
15
15
  "apps/api/src/jobs-local/**"
16
16
  ],
17
17
  "targetApp": "api",
18
+ "dependencies": {
19
+ "cron-parser": "^4.9.0"
20
+ },
18
21
  "env": [
19
22
  "LOCAL_JOBS_POLL_INTERVAL_MS=250"
20
23
  ],
@@ -0,0 +1,16 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { validateEnv } from "../config/env.validation";
3
+ import { loggingOptions } from "./logging.module";
4
+
5
+ describe("logging options", () => {
6
+ test("writes JSON without the development transport in production", () => {
7
+ expect(loggingOptions(validateEnv({ NODE_ENV: "production" })).transport).toBeUndefined();
8
+ });
9
+
10
+ test("pretty-prints single lines in development", () => {
11
+ expect(loggingOptions(validateEnv({})).transport).toEqual({
12
+ target: "pino-pretty",
13
+ options: { singleLine: true },
14
+ });
15
+ });
16
+ });
@@ -1,16 +1,21 @@
1
- import pino from "pino";
1
+ import pino, { type LoggerOptions } from "pino";
2
+ import type { AppEnv } from "../config/env.validation";
2
3
  import { LOGGER, type PodokitModule } from "../core/services";
3
4
 
5
+ /** Pretty single-line logs in development, JSON in production. */
6
+ export function loggingOptions(env: Pick<AppEnv, "NODE_ENV">): LoggerOptions {
7
+ return {
8
+ level: process.env.LOG_LEVEL ?? "info",
9
+ // pino-pretty is a development dependency, so production must not ask for it.
10
+ transport: env.NODE_ENV === "production"
11
+ ? undefined
12
+ : { target: "pino-pretty", options: { singleLine: true } },
13
+ };
14
+ }
15
+
4
16
  export const loggingModule: PodokitModule = {
5
17
  name: "logging",
6
- configure: (_env, services): void => {
7
- const isProduction = process.env.NODE_ENV === "production";
8
- const logger = pino({
9
- level: process.env.LOG_LEVEL ?? "info",
10
- transport: isProduction
11
- ? undefined
12
- : { target: "pino-pretty", options: { singleLine: true } },
13
- });
14
- services.override(LOGGER, logger);
18
+ configure: (env, services): void => {
19
+ services.override(LOGGER, pino(loggingOptions(env)));
15
20
  },
16
21
  };
@@ -5,7 +5,7 @@
5
5
  "targetApp": "api",
6
6
  "dependencies": {
7
7
  "nodemailer": "^9.0.5",
8
- "@podosoft/podokit-auth": "^0.5.0"
8
+ "@podosoft/podokit-auth": "^0.6.0"
9
9
  },
10
10
  "devDependencies": {
11
11
  "@types/nodemailer": "^8.0.1"
@@ -5,7 +5,7 @@ const origin = { origin: base };
5
5
  // The administrator seeded by the admin-dashboard Playwright setup. Inlined so this
6
6
  // spec also compiles in apps that do not install that module.
7
7
  const admin = {
8
- email: "admin@example.com",
8
+ email: process.env.E2E_ADMIN_EMAIL || "admin@example.com",
9
9
  password: process.env.E2E_ADMIN_PASSWORD || "Podokit3e-Str0ng!pw",
10
10
  };
11
11
 
@@ -24,12 +24,13 @@ services:
24
24
  profiles: ["dev"]
25
25
  depends_on:
26
26
  - minio
27
+ # -e stops at the first failing command, so a bucket that cannot be
28
+ # created fails this service instead of leaving uploads to fail later.
27
29
  entrypoint: >
28
- /bin/sh -c "
30
+ /bin/sh -ec "
29
31
  until mc alias set local http://minio:9000 ${S3_ACCESS_KEY_ID:-podokit} ${S3_SECRET_ACCESS_KEY:-podokitsecret}; do echo 'waiting for minio'; sleep 1; done;
30
- mc mb -p local/${S3_BUCKET:-podokit};
31
- echo 'bucket ready';
32
- exit 0"
32
+ mc mb --ignore-existing local/${S3_BUCKET:-podokit};
33
+ echo 'bucket ready'"
33
34
 
34
35
  volumes:
35
36
  minio-data:
@@ -5,7 +5,7 @@ const origin = { origin: base };
5
5
  // The administrator seeded by the admin-dashboard Playwright setup. Inlined so this
6
6
  // spec also compiles in apps that do not install that module.
7
7
  const admin = {
8
- email: "admin@example.com",
8
+ email: process.env.E2E_ADMIN_EMAIL || "admin@example.com",
9
9
  password: process.env.E2E_ADMIN_PASSWORD || "Podokit3e-Str0ng!pw",
10
10
  };
11
11
 
@@ -1,4 +1,10 @@
1
- import { expect, test, type APIResponse } from "@playwright/test";
1
+ import {
2
+ expect,
3
+ test,
4
+ type APIRequestContext,
5
+ type APIResponse,
6
+ type PlaywrightWorkerArgs,
7
+ } from "@playwright/test";
2
8
 
3
9
  const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
4
10
  const origin = { origin: base };
@@ -19,20 +25,42 @@ const limit = Number(process.env.RATE_LIMIT_MAX ?? 300);
19
25
  const authLimit = Number(process.env.RATE_LIMIT_AUTH_MAX ?? 20);
20
26
  const runtimeLimit = Number(process.env.RATE_LIMIT_RUNTIME_MAX ?? 1000);
21
27
 
22
- async function session(
23
- playwright: import("@playwright/test").PlaywrightWorkerArgs["playwright"],
24
- ) {
25
- const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
26
- const email = `rate-limit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`;
27
- const signup = await ctx.post("/api/auth/sign-up/email", {
28
- data: {
29
- email,
30
- password: "Podokit3e-Str0ng!pw",
31
- name: "Rate Limit",
32
- },
28
+ type Playwright = PlaywrightWorkerArgs["playwright"];
29
+ const password = "Podokit3e-Str0ng!pw";
30
+
31
+ function uniqueEmail(): string {
32
+ return `rate-limit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`;
33
+ }
34
+
35
+ // Sign-ups and sign-ins are authentication writes. From an anonymous client the
36
+ // limiter counts them against the per-address budget that the setup project's
37
+ // and every other spec's sign-ins share. Only this worker's first one is
38
+ // anonymous: the later ones carry its session cookie, so the limiter counts them
39
+ // against that session instead.
40
+ let carrier: string | undefined;
41
+
42
+ /** Run one authentication write and return a context holding the session it set. */
43
+ async function authenticated(
44
+ playwright: Playwright,
45
+ path: "/api/auth/sign-up/email" | "/api/auth/sign-in/email",
46
+ data: Record<string, string>,
47
+ ): Promise<APIRequestContext> {
48
+ const writer = await playwright.request.newContext({
49
+ baseURL: base,
50
+ extraHTTPHeaders: { ...origin, ...(carrier ? { cookie: carrier } : {}) },
33
51
  });
34
- expect(signup.ok()).toBeTruthy();
35
- return ctx;
52
+ const response = await writer.post(path, { data });
53
+ expect(response.ok(), `${path}: ${response.status()}`).toBeTruthy();
54
+ const storageState = await writer.storageState();
55
+ await writer.dispose();
56
+ const token = storageState.cookies.find((cookie) => cookie.name.endsWith("session_token"));
57
+ carrier ??= token ? `${token.name}=${token.value}` : undefined;
58
+ return playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin, storageState });
59
+ }
60
+
61
+ /** A new account, signed in. */
62
+ async function session(playwright: Playwright, email = uniqueEmail()): Promise<APIRequestContext> {
63
+ return authenticated(playwright, "/api/auth/sign-up/email", { email, password, name: "Rate Limit" });
36
64
  }
37
65
 
38
66
  test("rate limit: health probes stay available while ordinary routes return 429 @smoke", async ({
@@ -73,10 +101,8 @@ test("rate limit: health probes stay available while ordinary routes return 429
73
101
  test("rate limit: a user's sessions share one counter and forged sessions do not get one", async ({
74
102
  playwright,
75
103
  }) => {
76
- const email = `rate-limit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`;
77
- const password = "Podokit3e-Str0ng!pw";
78
- const first = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
79
- expect((await first.post("/api/auth/sign-up/email", { data: { email, password, name: "Rate Limit" } })).ok()).toBeTruthy();
104
+ const email = uniqueEmail();
105
+ const first = await session(playwright, email);
80
106
  const probe = await first.get("/api/cache/rate-limit-probe");
81
107
  test.skip(probe.status() >= 500, "throttler storage (redis) not reachable");
82
108
  const remaining = (response: APIResponse): number => Number(response.headers()["ratelimit-remaining"]);
@@ -86,8 +112,7 @@ test("rate limit: a user's sessions share one counter and forged sessions do not
86
112
  // session. The probe requires a session, so each request below resolves it; a
87
113
  // session counts under its user from the request after the API first resolves
88
114
  // it (or from that request when authentication runs before the limiter).
89
- const second = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
90
- expect((await second.post("/api/auth/sign-in/email", { data: { email, password } })).ok()).toBeTruthy();
115
+ const second = await authenticated(playwright, "/api/auth/sign-in/email", { email, password });
91
116
  const cookie = (await first.storageState()).cookies.find((c) => c.name.endsWith("session_token"));
92
117
  expect(cookie, "sign-up set a session cookie").toBeTruthy();
93
118
  const bearer = await playwright.request.newContext({
@@ -6,7 +6,7 @@ const origin = { origin: base };
6
6
  // The administrator seeded by the admin-dashboard Playwright setup. Inlined so this
7
7
  // spec also compiles in apps that do not install that module.
8
8
  const admin = {
9
- email: "admin@example.com",
9
+ email: process.env.E2E_ADMIN_EMAIL || "admin@example.com",
10
10
  password: process.env.E2E_ADMIN_PASSWORD || "Podokit3e-Str0ng!pw",
11
11
  };
12
12
 
@@ -87,7 +87,11 @@ Merge added module settings from `.env.example` into an existing `.env`.
87
87
  Better Auth schema before the application migrations. `{{apiRun}} migration:run` runs
88
88
  only the application migrations and never creates Better Auth tables;
89
89
  `{{apiRun}} migration:revert` reverts the latest application migration.
90
- - e2e (Playwright, ships in `tests/`): `{{rootRun}} test:e2e`.
90
+ - e2e (Playwright, ships in `tests/`): `{{rootRun}} test:e2e`. Run the whole suite on more
91
+ workers with `E2E_WORKERS=2`, never `--workers`. Tag a test `{ tag: "@exclusive" }` when
92
+ it changes state other tests rely on (a server-wide setting, a seeded account's profile);
93
+ tagged tests run alone after the rest. Give each test its own mail and SMS recipients and
94
+ never empty the shared Mailpit inbox or SMS sink. See `tests/README.md`.
91
95
  - Playwright officially runs on Node, so `bunx playwright` respects its Node
92
96
  shebang. Node LTS is a test-tool dependency only; API, web, worker, migrations,
93
97
  builds, and unit tests run on Bun.
@@ -55,8 +55,9 @@ cp .env.example .env
55
55
  ```
56
56
 
57
57
  Open the URL printed by the command; a new project defaults to
58
- **http://{{projectName}}.localhost**. The committed `.podokit/dev.json` is the
59
- source of truth if you change the hostname. The first running PodoKit project starts
58
+ **http://{{devHostname}}**. The committed `.podokit/dev.json` is the
59
+ source of truth if you change the hostname: `podo dev` sets `CORS_ORIGIN` and
60
+ `BETTER_AUTH_URL` for the web and API containers from it. The first running PodoKit project starts
60
61
  one user-level Traefik gateway on `127.0.0.1:80`; additional projects reuse it and
61
62
  route by that hostname. Even a single app uses
62
63
  the same topology, with one route and no project-specific host port.
@@ -164,5 +165,6 @@ PodoKit [deployment guide](https://github.com/podosoft-dev/podokit/blob/main/doc
164
165
  For a Docker Compose deployment with API WebSockets, list their exact paths under
165
166
  `exposure.webSocketPaths`. The driver keeps one published port, routes those paths
166
167
  to API, and sends every other request to web. Large-upload routes listed under
167
- `exposure.apiPaths` reach the API the same way, with `/api` removed. If an external reverse proxy supplies
168
- forwarding headers, list only its direct CIDR blocks in `exposure.trustedProxyCidrs`.
168
+ `exposure.apiPaths` reach the API the same way, with `/api` removed. Set `exposure.edgeProxies` to the
169
+ number of reverse proxies in front of the deployment, and with the gateway list only their direct CIDR
170
+ blocks in `exposure.trustedProxyCidrs`.
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Line-based unified diff for `podo diff <path>`. It is dependency-free and
3
+ * meant for source files of a few thousand lines; above `MAX_CELLS` it reports
4
+ * the whole file as replaced instead of building the LCS table.
5
+ */
6
+ export interface UnifiedDiffOptions {
7
+ fromLabel: string;
8
+ toLabel: string;
9
+ context?: number;
10
+ }
11
+ /** Unified diff from `from` to `to`, or an empty string when they are equal. */
12
+ export declare function unifiedDiff(from: string, to: string, options: UnifiedDiffOptions): string;
@@ -0,0 +1,111 @@
1
+ "use strict";
2
+ /**
3
+ * Line-based unified diff for `podo diff <path>`. It is dependency-free and
4
+ * meant for source files of a few thousand lines; above `MAX_CELLS` it reports
5
+ * the whole file as replaced instead of building the LCS table.
6
+ */
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.unifiedDiff = unifiedDiff;
9
+ const MAX_CELLS = 25_000_000;
10
+ function splitLines(text) {
11
+ if (text === "")
12
+ return [];
13
+ const lines = text.split("\n");
14
+ if (lines[lines.length - 1] === "")
15
+ lines.pop();
16
+ return lines;
17
+ }
18
+ function operations(a, b) {
19
+ let start = 0;
20
+ while (start < a.length && start < b.length && a[start] === b[start])
21
+ start += 1;
22
+ let endA = a.length;
23
+ let endB = b.length;
24
+ while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
25
+ endA -= 1;
26
+ endB -= 1;
27
+ }
28
+ const head = a.slice(0, start).map((line) => ({ kind: " ", line }));
29
+ const tail = a.slice(endA).map((line) => ({ kind: " ", line }));
30
+ const midA = a.slice(start, endA);
31
+ const midB = b.slice(start, endB);
32
+ const m = midA.length;
33
+ const n = midB.length;
34
+ const middle = [];
35
+ if ((m + 1) * (n + 1) > MAX_CELLS) {
36
+ middle.push(...midA.map((line) => ({ kind: "-", line })));
37
+ middle.push(...midB.map((line) => ({ kind: "+", line })));
38
+ return [...head, ...middle, ...tail];
39
+ }
40
+ const width = n + 1;
41
+ const table = new Uint32Array((m + 1) * width);
42
+ for (let i = m - 1; i >= 0; i -= 1) {
43
+ for (let j = n - 1; j >= 0; j -= 1) {
44
+ table[i * width + j] = midA[i] === midB[j]
45
+ ? table[(i + 1) * width + j + 1] + 1
46
+ : Math.max(table[(i + 1) * width + j], table[i * width + j + 1]);
47
+ }
48
+ }
49
+ let i = 0;
50
+ let j = 0;
51
+ while (i < m || j < n) {
52
+ if (i < m && j < n && midA[i] === midB[j]) {
53
+ middle.push({ kind: " ", line: midA[i] });
54
+ i += 1;
55
+ j += 1;
56
+ }
57
+ else if (i < m && (j === n || table[(i + 1) * width + j] >= table[i * width + j + 1])) {
58
+ middle.push({ kind: "-", line: midA[i] });
59
+ i += 1;
60
+ }
61
+ else {
62
+ middle.push({ kind: "+", line: midB[j] });
63
+ j += 1;
64
+ }
65
+ }
66
+ return [...head, ...middle, ...tail];
67
+ }
68
+ function range(start, count) {
69
+ if (count === 0)
70
+ return `${start},0`;
71
+ return count === 1 ? `${start + 1}` : `${start + 1},${count}`;
72
+ }
73
+ /** Unified diff from `from` to `to`, or an empty string when they are equal. */
74
+ function unifiedDiff(from, to, options) {
75
+ if (from === to)
76
+ return "";
77
+ const context = options.context ?? 3;
78
+ const ops = operations(splitLines(from), splitLines(to));
79
+ const changed = ops.flatMap((op, index) => (op.kind === " " ? [] : [index]));
80
+ const out = [`--- ${options.fromLabel}`, `+++ ${options.toLabel}`];
81
+ if (!changed.length) {
82
+ // Only the trailing newline differs.
83
+ out.push(from.endsWith("\n") ? "\\ the template ends with a newline" : "\\ the template has no newline at end of file", to.endsWith("\n") ? "\\ the file ends with a newline" : "\\ the file has no newline at end of file");
84
+ return `${out.join("\n")}\n`;
85
+ }
86
+ let hunkStart = 0;
87
+ while (hunkStart < changed.length) {
88
+ let hunkEnd = hunkStart;
89
+ while (hunkEnd + 1 < changed.length &&
90
+ changed[hunkEnd + 1] - changed[hunkEnd] <= context * 2 + 1) {
91
+ hunkEnd += 1;
92
+ }
93
+ const first = Math.max(0, changed[hunkStart] - context);
94
+ const last = Math.min(ops.length - 1, changed[hunkEnd] + context);
95
+ let fromLine = 0;
96
+ let toLine = 0;
97
+ for (let index = 0; index < first; index += 1) {
98
+ if (ops[index].kind !== "+")
99
+ fromLine += 1;
100
+ if (ops[index].kind !== "-")
101
+ toLine += 1;
102
+ }
103
+ const body = ops.slice(first, last + 1);
104
+ const fromCount = body.filter((op) => op.kind !== "+").length;
105
+ const toCount = body.filter((op) => op.kind !== "-").length;
106
+ out.push(`@@ -${range(fromLine, fromCount)} +${range(toLine, toCount)} @@`);
107
+ out.push(...body.map((op) => `${op.kind}${op.line}`));
108
+ hunkStart = hunkEnd + 1;
109
+ }
110
+ return `${out.join("\n")}\n`;
111
+ }