@agent-native/core 0.128.0 → 0.128.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +27 -0
- package/corpus/core/package.json +3 -1
- package/corpus/core/src/server/builder-design-systems.ts +299 -82
- package/corpus/core/src/server/email-template.ts +33 -0
- package/corpus/core/src/server/index.ts +8 -0
- package/corpus/templates/analytics/_gitignore +2 -0
- package/corpus/templates/assets/_gitignore +2 -0
- package/corpus/templates/calendar/_gitignore +2 -0
- package/corpus/templates/chat/_gitignore +2 -0
- package/corpus/templates/clips/AGENTS.md +6 -1
- package/corpus/templates/clips/_gitignore +2 -0
- package/corpus/templates/clips/actions/complete-transactional-email-summary.ts +56 -0
- package/corpus/templates/clips/actions/import-loom-recording.ts +258 -76
- package/corpus/templates/clips/actions/lib/direct-video.ts +96 -0
- package/corpus/templates/clips/actions/lib/loom-import-job.ts +53 -2
- package/corpus/templates/clips/actions/lib/loom-video.ts +2 -62
- package/corpus/templates/clips/actions/lib/video-download-limits.ts +62 -0
- package/corpus/templates/clips/actions/list-transactional-email-ai-requests.ts +249 -0
- package/corpus/templates/clips/app/components/library/library-grid.tsx +65 -0
- package/corpus/templates/clips/app/components/library/library-layout.tsx +55 -0
- package/corpus/templates/clips/app/components/recorder/pre-record-panel.tsx +28 -126
- package/corpus/templates/clips/app/global.css +23 -0
- package/corpus/templates/clips/app/hooks/use-transactional-email-bridge.ts +98 -0
- package/corpus/templates/clips/app/i18n/en-US.ts +20 -0
- package/corpus/templates/clips/app/routes/_app.tsx +2 -0
- package/corpus/templates/clips/app/routes/import.tsx +396 -0
- package/corpus/templates/clips/app/routes/r.$recordingId.tsx +8 -6
- package/corpus/templates/clips/app/routes/record.tsx +13 -71
- package/corpus/templates/clips/changelog/2026-07-25-two-clip-emails-now-include-a-concise-summary-generated-secu.md +6 -0
- package/corpus/templates/clips/data/clips-transactional-emails/config.json +3 -0
- package/corpus/templates/clips/server/jobs/transactional-emails.ts +1034 -0
- package/corpus/templates/clips/server/lib/transactional-email-store.ts +865 -0
- package/corpus/templates/clips/server/lib/transactional-email-templates.ts +270 -0
- package/corpus/templates/clips/server/plugins/calendar-jobs.ts +2 -0
- package/corpus/templates/clips/server/routes/api/view-event.post.ts +59 -2
- package/corpus/templates/content/.agents/skills/document-editing/references/databases.md +8 -2
- package/corpus/templates/content/AGENTS.md +8 -0
- package/corpus/templates/content/_gitignore +2 -0
- package/corpus/templates/content/actions/_content-database-personal-view.ts +28 -3
- package/corpus/templates/content/actions/_property-utils.ts +32 -2
- package/corpus/templates/content/actions/configure-document-property.ts +12 -2
- package/corpus/templates/content/actions/delete-document-property.ts +13 -3
- package/corpus/templates/content/actions/duplicate-document-property.ts +13 -3
- package/corpus/templates/content/actions/list-content-spaces.ts +5 -2
- package/corpus/templates/content/actions/list-document-properties.ts +12 -3
- package/corpus/templates/content/actions/move-database-item.ts +74 -55
- package/corpus/templates/content/actions/reorder-document-property.ts +20 -4
- package/corpus/templates/content/actions/set-document-property.ts +10 -1
- package/corpus/templates/content/actions/update-content-database-personal-view.ts +7 -0
- package/corpus/templates/content/app/components/editor/DocumentBlockFields.tsx +31 -21
- package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +30 -3
- package/corpus/templates/content/app/components/editor/DocumentInfoPanel.tsx +8 -1
- package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +72 -20
- package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +79 -10
- package/corpus/templates/content/app/components/editor/database/TimelineView.tsx +8 -1
- package/corpus/templates/content/app/components/editor/database/settings.tsx +6 -0
- package/corpus/templates/content/app/components/editor/database/sidebar.tsx +246 -26
- package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +491 -197
- package/corpus/templates/content/app/components/sidebar/DocumentTreeItem.tsx +3 -3
- package/corpus/templates/content/app/components/sidebar/sidebar-reorder.tsx +409 -0
- package/corpus/templates/content/app/hooks/use-content-database.ts +117 -3
- package/corpus/templates/content/app/hooks/use-content-spaces.ts +1 -0
- package/corpus/templates/content/app/hooks/use-document-properties.ts +62 -18
- package/corpus/templates/content/app/hooks/use-documents.ts +13 -5
- package/corpus/templates/content/app/i18n-data.ts +261 -3
- package/corpus/templates/content/app/routes/_app.page.$id.tsx +9 -2
- package/corpus/templates/content/changelog/2026-07-24-database-page-properties-keep-their-context.md +6 -0
- package/corpus/templates/content/changelog/2026-07-26-sidebar-references-can-now-be-personally-reordered.md +6 -0
- package/corpus/templates/content/docs/solutions/2026-07-24-database-property-context-switch-diagnosis.md +249 -0
- package/corpus/templates/content/docs/solutions/content-sidebar-order-shape.md +389 -0
- package/corpus/templates/content/shared/api.ts +28 -0
- package/corpus/templates/design/_gitignore +2 -0
- package/corpus/templates/design/app/i18n-data.ts +10 -0
- package/corpus/templates/design/app/lib/builder-design-system-upload.ts +280 -0
- package/corpus/templates/design/app/pages/DesignSystemSetup.tsx +90 -54
- package/corpus/templates/design/changelog/2026-07-28-figma-fig-uploads-now-show-indexing-progress-and-an-open-in-.md +6 -0
- package/corpus/templates/design/server/handlers/design-system-decode-job-status.ts +49 -0
- package/corpus/templates/design/server/handlers/design-system-upload-start.ts +87 -0
- package/corpus/templates/design/server/handlers/index-design-system-sources.ts +82 -0
- package/corpus/templates/design/server/routes/api/design-system-decode-job-status.get.ts +1 -0
- package/corpus/templates/design/server/routes/api/design-system-upload-start.post.ts +1 -0
- package/corpus/templates/design/server/routes/api/index-design-system-sources.post.ts +1 -0
- package/corpus/templates/dispatch/_gitignore +2 -0
- package/corpus/templates/forms/_gitignore +2 -0
- package/corpus/templates/macros/_gitignore +2 -0
- package/corpus/templates/mail/_gitignore +2 -0
- package/corpus/templates/plan/_gitignore +2 -0
- package/corpus/templates/slides/_gitignore +2 -0
- package/corpus/templates/slides/app/components/design-system/DesignSystemSetup.tsx +126 -53
- package/corpus/templates/slides/app/components/design-system/builder-design-system-upload.ts +271 -0
- package/corpus/templates/slides/app/components/design-system/builder-index-response.ts +1 -1
- package/corpus/templates/slides/app/i18n/en-US.ts +1 -0
- package/corpus/templates/slides/server/handlers/design-system-decode-job-status.ts +49 -0
- package/corpus/templates/slides/server/handlers/design-system-upload-start.ts +87 -0
- package/corpus/templates/slides/server/handlers/index-design-system-sources.ts +79 -0
- package/corpus/templates/slides/server/routes/api/design-system-decode-job-status.get.ts +1 -0
- package/corpus/templates/slides/server/routes/api/design-system-upload-start.post.ts +1 -0
- package/corpus/templates/slides/server/routes/api/index-design-system-sources.post.ts +1 -0
- package/corpus/templates/tasks/_gitignore +2 -0
- package/corpus/toolkit/CHANGELOG.md +6 -0
- package/corpus/toolkit/package.json +1 -1
- package/corpus/toolkit/src/editor/DragHandle.ts +63 -9
- package/dist/collab/routes.d.ts +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/observability/routes.d.ts +3 -3
- package/dist/progress/routes.d.ts +1 -1
- package/dist/provider-api/actions/custom-provider-registration.d.ts +10 -10
- package/dist/provider-api/actions/provider-api.d.ts +8 -8
- package/dist/server/builder-design-systems.d.ts +49 -0
- package/dist/server/builder-design-systems.d.ts.map +1 -1
- package/dist/server/builder-design-systems.js +221 -69
- package/dist/server/builder-design-systems.js.map +1 -1
- package/dist/server/email-template.d.ts +7 -0
- package/dist/server/email-template.d.ts.map +1 -1
- package/dist/server/email-template.js +24 -0
- package/dist/server/email-template.js.map +1 -1
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/templates/chat/_gitignore +2 -0
- package/package.json +5 -3
- package/src/server/builder-design-systems.ts +299 -82
- package/src/server/email-template.ts +33 -0
- package/src/server/index.ts +8 -0
- package/src/templates/chat/_gitignore +2 -0
- package/corpus/templates/design/server/handlers/index-design-system-with-builder.ts +0 -151
- package/corpus/templates/design/server/routes/api/index-design-system-with-builder.post.ts +0 -1
- package/corpus/templates/slides/server/handlers/index-design-system-with-builder.ts +0 -119
- package/corpus/templates/slides/server/routes/api/index-design-system-with-builder.post.ts +0 -1
|
@@ -0,0 +1,865 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
link,
|
|
4
|
+
mkdir,
|
|
5
|
+
open,
|
|
6
|
+
readFile,
|
|
7
|
+
readdir,
|
|
8
|
+
rename,
|
|
9
|
+
rm,
|
|
10
|
+
stat,
|
|
11
|
+
unlink,
|
|
12
|
+
writeFile,
|
|
13
|
+
} from "node:fs/promises";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
|
|
18
|
+
const timestampSchema = z.string().datetime({ offset: true });
|
|
19
|
+
const nonEmptyStringSchema = z.string().trim().min(1);
|
|
20
|
+
export const transactionalEmailRecipientSchema = z.string().email();
|
|
21
|
+
const recipientSchema = transactionalEmailRecipientSchema;
|
|
22
|
+
const recordingIdSchema = nonEmptyStringSchema;
|
|
23
|
+
|
|
24
|
+
export const transactionalEmailStateSchema = z.enum([
|
|
25
|
+
"pending",
|
|
26
|
+
"awaiting_ai",
|
|
27
|
+
"ai_dispatched",
|
|
28
|
+
"ready",
|
|
29
|
+
"sending",
|
|
30
|
+
"sent",
|
|
31
|
+
"cancelled",
|
|
32
|
+
"failed",
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
const commonPayloadFields = {
|
|
36
|
+
recipient: recipientSchema,
|
|
37
|
+
shareId: nonEmptyStringSchema.optional(),
|
|
38
|
+
requestedBy: nonEmptyStringSchema.optional(),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const transactionalEmailPayloadSchema = z.discriminatedUnion("type", [
|
|
42
|
+
z
|
|
43
|
+
.object({
|
|
44
|
+
type: z.literal("first-view"),
|
|
45
|
+
...commonPayloadFields,
|
|
46
|
+
recordingIds: z.array(recordingIdSchema).length(1),
|
|
47
|
+
})
|
|
48
|
+
.strict(),
|
|
49
|
+
z
|
|
50
|
+
.object({
|
|
51
|
+
type: z.literal("unviewed-reminder"),
|
|
52
|
+
...commonPayloadFields,
|
|
53
|
+
recordingIds: z.array(recordingIdSchema).length(1),
|
|
54
|
+
})
|
|
55
|
+
.strict(),
|
|
56
|
+
z
|
|
57
|
+
.object({
|
|
58
|
+
type: z.literal("first-import"),
|
|
59
|
+
...commonPayloadFields,
|
|
60
|
+
recordingIds: z.array(recordingIdSchema).length(1),
|
|
61
|
+
})
|
|
62
|
+
.strict(),
|
|
63
|
+
z
|
|
64
|
+
.object({
|
|
65
|
+
type: z.literal("two-clips"),
|
|
66
|
+
...commonPayloadFields,
|
|
67
|
+
recordingIds: z.array(recordingIdSchema).length(2),
|
|
68
|
+
})
|
|
69
|
+
.strict(),
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
const reconciliationCursorSchema = z
|
|
73
|
+
.object({
|
|
74
|
+
createdAt: timestampSchema,
|
|
75
|
+
id: nonEmptyStringSchema,
|
|
76
|
+
})
|
|
77
|
+
.strict()
|
|
78
|
+
.nullable()
|
|
79
|
+
.optional();
|
|
80
|
+
|
|
81
|
+
export const transactionalEmailConfigSchema = z
|
|
82
|
+
.object({
|
|
83
|
+
enabledAt: timestampSchema,
|
|
84
|
+
reconciliationCursor: reconciliationCursorSchema,
|
|
85
|
+
shareDiscoveryCursor: reconciliationCursorSchema,
|
|
86
|
+
reminderCursor: reconciliationCursorSchema,
|
|
87
|
+
firstViewCursor: reconciliationCursorSchema,
|
|
88
|
+
firstImportCursor: reconciliationCursorSchema,
|
|
89
|
+
})
|
|
90
|
+
.strict();
|
|
91
|
+
|
|
92
|
+
export const transactionalEmailCursorNameSchema = z.enum([
|
|
93
|
+
"shareDiscoveryCursor",
|
|
94
|
+
"reminderCursor",
|
|
95
|
+
"firstViewCursor",
|
|
96
|
+
"firstImportCursor",
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
export const transactionalEmailJobSchema = z
|
|
100
|
+
.object({
|
|
101
|
+
logicalKey: nonEmptyStringSchema,
|
|
102
|
+
type: z.enum([
|
|
103
|
+
"first-view",
|
|
104
|
+
"unviewed-reminder",
|
|
105
|
+
"first-import",
|
|
106
|
+
"two-clips",
|
|
107
|
+
]),
|
|
108
|
+
state: transactionalEmailStateSchema,
|
|
109
|
+
recipient: recipientSchema,
|
|
110
|
+
recordingIds: z.array(recordingIdSchema).min(1),
|
|
111
|
+
shareId: nonEmptyStringSchema.optional(),
|
|
112
|
+
requestedBy: nonEmptyStringSchema.optional(),
|
|
113
|
+
generatedSummary: z.string().max(20_000).optional(),
|
|
114
|
+
attempts: z.number().int().nonnegative(),
|
|
115
|
+
createdAt: timestampSchema,
|
|
116
|
+
updatedAt: timestampSchema,
|
|
117
|
+
aiDispatchedAt: timestampSchema.optional(),
|
|
118
|
+
aiClaimedBy: recipientSchema.optional(),
|
|
119
|
+
readyAt: timestampSchema.optional(),
|
|
120
|
+
sendingAt: timestampSchema.optional(),
|
|
121
|
+
sentAt: timestampSchema.optional(),
|
|
122
|
+
cancelledAt: timestampSchema.optional(),
|
|
123
|
+
failedAt: timestampSchema.optional(),
|
|
124
|
+
lastError: z.string().max(4_000).nullable(),
|
|
125
|
+
leaseUntil: timestampSchema.nullable(),
|
|
126
|
+
leaseToken: nonEmptyStringSchema.nullable(),
|
|
127
|
+
})
|
|
128
|
+
.strict()
|
|
129
|
+
.superRefine((job, context) => {
|
|
130
|
+
const parsedPayload = transactionalEmailPayloadSchema.safeParse({
|
|
131
|
+
type: job.type,
|
|
132
|
+
recipient: job.recipient,
|
|
133
|
+
recordingIds: job.recordingIds,
|
|
134
|
+
shareId: job.shareId,
|
|
135
|
+
requestedBy: job.requestedBy,
|
|
136
|
+
});
|
|
137
|
+
if (!parsedPayload.success) {
|
|
138
|
+
context.addIssue({
|
|
139
|
+
code: "custom",
|
|
140
|
+
message: "Job payload does not match its transactional email type",
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
if (
|
|
144
|
+
job.state === "sending" &&
|
|
145
|
+
(job.leaseUntil === null || job.leaseToken === null)
|
|
146
|
+
) {
|
|
147
|
+
context.addIssue({
|
|
148
|
+
code: "custom",
|
|
149
|
+
message: "Sending jobs require leaseUntil and leaseToken",
|
|
150
|
+
path: job.leaseUntil === null ? ["leaseUntil"] : ["leaseToken"],
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
if (
|
|
154
|
+
job.state !== "sending" &&
|
|
155
|
+
(job.leaseUntil !== null || job.leaseToken !== null)
|
|
156
|
+
) {
|
|
157
|
+
context.addIssue({
|
|
158
|
+
code: "custom",
|
|
159
|
+
message: "Only sending jobs may have leaseUntil and leaseToken",
|
|
160
|
+
path: job.leaseUntil !== null ? ["leaseUntil"] : ["leaseToken"],
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
export type TransactionalEmailState = z.infer<
|
|
166
|
+
typeof transactionalEmailStateSchema
|
|
167
|
+
>;
|
|
168
|
+
export type TransactionalEmailPayload = z.infer<
|
|
169
|
+
typeof transactionalEmailPayloadSchema
|
|
170
|
+
>;
|
|
171
|
+
export type TransactionalEmailConfig = z.infer<
|
|
172
|
+
typeof transactionalEmailConfigSchema
|
|
173
|
+
>;
|
|
174
|
+
export type TransactionalEmailJob = z.infer<typeof transactionalEmailJobSchema>;
|
|
175
|
+
|
|
176
|
+
export type TransactionalEmailStoreOptions = {
|
|
177
|
+
root?: string;
|
|
178
|
+
now?: () => Date;
|
|
179
|
+
testHooks?: {
|
|
180
|
+
afterInitialJobTempSynced?: () => Promise<void>;
|
|
181
|
+
afterStaleLockSnapshot?: () => Promise<void>;
|
|
182
|
+
afterJobLockAcquired?: () => Promise<void>;
|
|
183
|
+
};
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const LOCK_STALE_MS = 30_000;
|
|
187
|
+
export const AI_DISPATCH_STALE_MS = 30 * 60 * 1000;
|
|
188
|
+
|
|
189
|
+
const allowedTransitions: Record<
|
|
190
|
+
TransactionalEmailState,
|
|
191
|
+
ReadonlySet<TransactionalEmailState>
|
|
192
|
+
> = {
|
|
193
|
+
pending: new Set(["awaiting_ai", "ready", "cancelled", "failed"]),
|
|
194
|
+
awaiting_ai: new Set(["ai_dispatched", "cancelled", "failed"]),
|
|
195
|
+
ai_dispatched: new Set(["ready", "cancelled", "failed"]),
|
|
196
|
+
ready: new Set(["sending", "cancelled", "failed"]),
|
|
197
|
+
sending: new Set(["ready", "sent", "cancelled", "failed"]),
|
|
198
|
+
sent: new Set(),
|
|
199
|
+
cancelled: new Set(),
|
|
200
|
+
failed: new Set(),
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
function isNodeError(error: unknown, code: string): boolean {
|
|
204
|
+
return (
|
|
205
|
+
error instanceof Error &&
|
|
206
|
+
"code" in error &&
|
|
207
|
+
(error as NodeJS.ErrnoException).code === code
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function jobHash(logicalKey: string): string {
|
|
212
|
+
return createHash("sha256").update(logicalKey).digest("hex");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function stateTimestampField(
|
|
216
|
+
state: TransactionalEmailState,
|
|
217
|
+
): keyof TransactionalEmailJob | null {
|
|
218
|
+
if (state === "ai_dispatched") return "aiDispatchedAt";
|
|
219
|
+
if (state === "ready") return "readyAt";
|
|
220
|
+
if (state === "sending") return "sendingAt";
|
|
221
|
+
if (state === "sent") return "sentAt";
|
|
222
|
+
if (state === "cancelled") return "cancelledAt";
|
|
223
|
+
if (state === "failed") return "failedAt";
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function createTransactionalEmailStore(
|
|
228
|
+
options: TransactionalEmailStoreOptions = {},
|
|
229
|
+
) {
|
|
230
|
+
const root =
|
|
231
|
+
options.root ??
|
|
232
|
+
path.join(process.cwd(), "data", "clips-transactional-emails");
|
|
233
|
+
const jobsDirectory = path.join(root, "jobs");
|
|
234
|
+
const locksDirectory = path.join(root, "locks");
|
|
235
|
+
const configFile = path.join(root, "config.json");
|
|
236
|
+
const now = options.now ?? (() => new Date());
|
|
237
|
+
const testHooks = options.testHooks;
|
|
238
|
+
|
|
239
|
+
const jobFile = (logicalKey: string) =>
|
|
240
|
+
path.join(jobsDirectory, `${jobHash(logicalKey)}.json`);
|
|
241
|
+
const lockFile = (logicalKey: string) =>
|
|
242
|
+
path.join(locksDirectory, `${jobHash(logicalKey)}.lock`);
|
|
243
|
+
|
|
244
|
+
async function ensureDirectories(): Promise<void> {
|
|
245
|
+
await Promise.all([
|
|
246
|
+
mkdir(jobsDirectory, { recursive: true, mode: 0o700 }),
|
|
247
|
+
mkdir(locksDirectory, { recursive: true, mode: 0o700 }),
|
|
248
|
+
]);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function parseJsonFile<T>(
|
|
252
|
+
file: string,
|
|
253
|
+
schema: z.ZodType<T>,
|
|
254
|
+
description: string,
|
|
255
|
+
): Promise<T> {
|
|
256
|
+
let parsed: unknown;
|
|
257
|
+
try {
|
|
258
|
+
parsed = JSON.parse(await readFile(file, "utf8"));
|
|
259
|
+
} catch (error) {
|
|
260
|
+
if (isNodeError(error, "ENOENT")) throw error;
|
|
261
|
+
const detail = error instanceof Error ? `: ${error.message}` : "";
|
|
262
|
+
throw new Error(`Invalid ${description} JSON at ${file}${detail}`);
|
|
263
|
+
}
|
|
264
|
+
const result = schema.safeParse(parsed);
|
|
265
|
+
if (!result.success) {
|
|
266
|
+
throw new Error(
|
|
267
|
+
`Invalid ${description} at ${file}: ${result.error.message}`,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
return result.data;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function writeJsonAtomic(file: string, value: unknown): Promise<void> {
|
|
274
|
+
const temporaryFile = path.join(
|
|
275
|
+
path.dirname(file),
|
|
276
|
+
`.${path.basename(file)}.${process.pid}.${randomUUID()}.tmp`,
|
|
277
|
+
);
|
|
278
|
+
try {
|
|
279
|
+
await writeFile(temporaryFile, `${JSON.stringify(value, null, 2)}\n`, {
|
|
280
|
+
encoding: "utf8",
|
|
281
|
+
flag: "wx",
|
|
282
|
+
mode: 0o600,
|
|
283
|
+
});
|
|
284
|
+
await rename(temporaryFile, file);
|
|
285
|
+
} catch (error) {
|
|
286
|
+
await rm(temporaryFile, { force: true }).catch(() => undefined);
|
|
287
|
+
throw error;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function publishJsonExclusive(
|
|
292
|
+
file: string,
|
|
293
|
+
value: unknown,
|
|
294
|
+
): Promise<boolean> {
|
|
295
|
+
const temporaryFile = path.join(
|
|
296
|
+
path.dirname(file),
|
|
297
|
+
`.${path.basename(file)}.${process.pid}.${randomUUID()}.tmp`,
|
|
298
|
+
);
|
|
299
|
+
let handle;
|
|
300
|
+
try {
|
|
301
|
+
handle = await open(temporaryFile, "wx", 0o600);
|
|
302
|
+
await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
303
|
+
await handle.sync();
|
|
304
|
+
await handle.close();
|
|
305
|
+
handle = undefined;
|
|
306
|
+
await testHooks?.afterInitialJobTempSynced?.();
|
|
307
|
+
try {
|
|
308
|
+
await link(temporaryFile, file);
|
|
309
|
+
const directoryHandle = await open(path.dirname(file), "r");
|
|
310
|
+
try {
|
|
311
|
+
await directoryHandle.sync();
|
|
312
|
+
} finally {
|
|
313
|
+
await directoryHandle.close();
|
|
314
|
+
}
|
|
315
|
+
return true;
|
|
316
|
+
} catch (error) {
|
|
317
|
+
if (isNodeError(error, "EEXIST")) return false;
|
|
318
|
+
throw error;
|
|
319
|
+
}
|
|
320
|
+
} finally {
|
|
321
|
+
await handle?.close().catch(() => undefined);
|
|
322
|
+
await rm(temporaryFile, { force: true }).catch(() => undefined);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function readJob(
|
|
327
|
+
logicalKey: string,
|
|
328
|
+
): Promise<TransactionalEmailJob | null> {
|
|
329
|
+
const file = jobFile(logicalKey);
|
|
330
|
+
try {
|
|
331
|
+
const job = await parseJsonFile(
|
|
332
|
+
file,
|
|
333
|
+
transactionalEmailJobSchema,
|
|
334
|
+
"transactional email job",
|
|
335
|
+
);
|
|
336
|
+
if (job.logicalKey !== logicalKey) {
|
|
337
|
+
throw new Error(`Transactional email job key mismatch at ${file}`);
|
|
338
|
+
}
|
|
339
|
+
return job;
|
|
340
|
+
} catch (error) {
|
|
341
|
+
if (isNodeError(error, "ENOENT")) return null;
|
|
342
|
+
throw error;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function listJobs(): Promise<TransactionalEmailJob[]> {
|
|
347
|
+
await ensureDirectories();
|
|
348
|
+
const files = (await readdir(jobsDirectory))
|
|
349
|
+
.filter((file) => file.endsWith(".json"))
|
|
350
|
+
.sort();
|
|
351
|
+
const jobs = await Promise.all(
|
|
352
|
+
files.map(async (filename) => {
|
|
353
|
+
const file = path.join(jobsDirectory, filename);
|
|
354
|
+
const job = await parseJsonFile(
|
|
355
|
+
file,
|
|
356
|
+
transactionalEmailJobSchema,
|
|
357
|
+
"transactional email job",
|
|
358
|
+
);
|
|
359
|
+
if (filename !== `${jobHash(job.logicalKey)}.json`) {
|
|
360
|
+
throw new Error(`Transactional email job key mismatch at ${file}`);
|
|
361
|
+
}
|
|
362
|
+
return job;
|
|
363
|
+
}),
|
|
364
|
+
);
|
|
365
|
+
return jobs.sort((left, right) =>
|
|
366
|
+
left.createdAt.localeCompare(right.createdAt),
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
type FileIdentity = { dev: number; ino: number };
|
|
371
|
+
|
|
372
|
+
function identityOf(value: { dev: number; ino: number }): FileIdentity {
|
|
373
|
+
return { dev: value.dev, ino: value.ino };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function sameIdentity(
|
|
377
|
+
left: FileIdentity | null,
|
|
378
|
+
right: FileIdentity | null,
|
|
379
|
+
): boolean {
|
|
380
|
+
return (
|
|
381
|
+
left !== null &&
|
|
382
|
+
right !== null &&
|
|
383
|
+
left.dev === right.dev &&
|
|
384
|
+
left.ino === right.ino
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function fileIdentity(file: string): Promise<FileIdentity | null> {
|
|
389
|
+
const value = await stat(file).catch((error: unknown) => {
|
|
390
|
+
if (isNodeError(error, "ENOENT")) return null;
|
|
391
|
+
throw error;
|
|
392
|
+
});
|
|
393
|
+
return value ? identityOf(value) : null;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function unlinkIfIdentity(
|
|
397
|
+
file: string,
|
|
398
|
+
expected: FileIdentity,
|
|
399
|
+
): Promise<boolean> {
|
|
400
|
+
if (!sameIdentity(await fileIdentity(file), expected)) return false;
|
|
401
|
+
try {
|
|
402
|
+
await unlink(file);
|
|
403
|
+
return true;
|
|
404
|
+
} catch (error) {
|
|
405
|
+
if (isNodeError(error, "ENOENT")) return false;
|
|
406
|
+
throw error;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async function createLockOwner(file: string): Promise<FileIdentity> {
|
|
411
|
+
const handle = await open(file, "wx", 0o600);
|
|
412
|
+
try {
|
|
413
|
+
await handle.sync();
|
|
414
|
+
return identityOf(await handle.stat());
|
|
415
|
+
} finally {
|
|
416
|
+
await handle.close();
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function takeoverMarkerBlocks(file: string): Promise<boolean> {
|
|
421
|
+
const existing = await stat(file).catch((error: unknown) => {
|
|
422
|
+
if (isNodeError(error, "ENOENT")) return null;
|
|
423
|
+
throw error;
|
|
424
|
+
});
|
|
425
|
+
if (!existing) return false;
|
|
426
|
+
if (Date.now() - existing.mtimeMs <= LOCK_STALE_MS) return true;
|
|
427
|
+
return !(await unlinkIfIdentity(file, identityOf(existing)));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async function acquireTakeoverMarker(
|
|
431
|
+
ownerFile: string,
|
|
432
|
+
takeoverFile: string,
|
|
433
|
+
): Promise<boolean> {
|
|
434
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
435
|
+
try {
|
|
436
|
+
await link(ownerFile, takeoverFile);
|
|
437
|
+
return true;
|
|
438
|
+
} catch (error) {
|
|
439
|
+
if (!isNodeError(error, "EEXIST")) throw error;
|
|
440
|
+
if (await takeoverMarkerBlocks(takeoverFile)) return false;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return false;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async function withJobLock<T>(
|
|
447
|
+
logicalKey: string,
|
|
448
|
+
operation: () => Promise<T>,
|
|
449
|
+
): Promise<T | null> {
|
|
450
|
+
await ensureDirectories();
|
|
451
|
+
const file = lockFile(logicalKey);
|
|
452
|
+
const ownerFile = `${file}.${process.pid}.${randomUUID()}.owner`;
|
|
453
|
+
const takeoverFile = `${file}.takeover`;
|
|
454
|
+
const ownerIdentity = await createLockOwner(ownerFile);
|
|
455
|
+
let acquired = false;
|
|
456
|
+
let ownsTakeover = false;
|
|
457
|
+
try {
|
|
458
|
+
if (await takeoverMarkerBlocks(takeoverFile)) return null;
|
|
459
|
+
try {
|
|
460
|
+
await link(ownerFile, file);
|
|
461
|
+
acquired = true;
|
|
462
|
+
if (await takeoverMarkerBlocks(takeoverFile)) return null;
|
|
463
|
+
} catch (error) {
|
|
464
|
+
if (!isNodeError(error, "EEXIST")) throw error;
|
|
465
|
+
ownsTakeover = await acquireTakeoverMarker(ownerFile, takeoverFile);
|
|
466
|
+
if (!ownsTakeover) return null;
|
|
467
|
+
const lockStat = await stat(file).catch(() => null);
|
|
468
|
+
if (!lockStat || Date.now() - lockStat.mtimeMs <= LOCK_STALE_MS) {
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
const staleIdentity = identityOf(lockStat);
|
|
472
|
+
await testHooks?.afterStaleLockSnapshot?.();
|
|
473
|
+
if (!sameIdentity(await fileIdentity(file), staleIdentity)) return null;
|
|
474
|
+
if (!(await unlinkIfIdentity(file, staleIdentity))) return null;
|
|
475
|
+
try {
|
|
476
|
+
await link(ownerFile, file);
|
|
477
|
+
acquired = true;
|
|
478
|
+
} catch (retryError) {
|
|
479
|
+
if (isNodeError(retryError, "EEXIST")) return null;
|
|
480
|
+
throw retryError;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
await testHooks?.afterJobLockAcquired?.();
|
|
484
|
+
if (!sameIdentity(await fileIdentity(file), ownerIdentity)) return null;
|
|
485
|
+
return await operation();
|
|
486
|
+
} finally {
|
|
487
|
+
if (acquired && !ownsTakeover) {
|
|
488
|
+
ownsTakeover = await acquireTakeoverMarker(ownerFile, takeoverFile);
|
|
489
|
+
}
|
|
490
|
+
if (acquired && ownsTakeover) {
|
|
491
|
+
await unlinkIfIdentity(file, ownerIdentity);
|
|
492
|
+
}
|
|
493
|
+
if (ownsTakeover) {
|
|
494
|
+
await unlinkIfIdentity(takeoverFile, ownerIdentity);
|
|
495
|
+
}
|
|
496
|
+
await rm(ownerFile, { force: true });
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async function enqueue(
|
|
501
|
+
logicalKey: string,
|
|
502
|
+
payload: TransactionalEmailPayload,
|
|
503
|
+
initialState: "pending" | "awaiting_ai" = "pending",
|
|
504
|
+
): Promise<{ created: boolean; job: TransactionalEmailJob }> {
|
|
505
|
+
const parsedKey = nonEmptyStringSchema.parse(logicalKey);
|
|
506
|
+
const parsedPayload = transactionalEmailPayloadSchema.parse(payload);
|
|
507
|
+
await ensureDirectories();
|
|
508
|
+
const timestamp = now().toISOString();
|
|
509
|
+
const job = transactionalEmailJobSchema.parse({
|
|
510
|
+
logicalKey: parsedKey,
|
|
511
|
+
...parsedPayload,
|
|
512
|
+
state: initialState,
|
|
513
|
+
attempts: 0,
|
|
514
|
+
createdAt: timestamp,
|
|
515
|
+
updatedAt: timestamp,
|
|
516
|
+
lastError: null,
|
|
517
|
+
leaseUntil: null,
|
|
518
|
+
leaseToken: null,
|
|
519
|
+
});
|
|
520
|
+
if (await publishJsonExclusive(jobFile(parsedKey), job)) {
|
|
521
|
+
return { created: true, job };
|
|
522
|
+
}
|
|
523
|
+
const existing = await readJob(parsedKey);
|
|
524
|
+
if (!existing) {
|
|
525
|
+
throw new Error(`Transactional email job disappeared for ${parsedKey}`);
|
|
526
|
+
}
|
|
527
|
+
return { created: false, job: existing };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async function enqueueOrConvergeFirstImport(
|
|
531
|
+
recipient: string,
|
|
532
|
+
recordingId: string,
|
|
533
|
+
requestedBy: string,
|
|
534
|
+
): Promise<{ created: boolean; job: TransactionalEmailJob }> {
|
|
535
|
+
const logicalKey = `first-import:${recipient.trim().toLowerCase()}`;
|
|
536
|
+
const enqueued = await enqueue(logicalKey, {
|
|
537
|
+
type: "first-import",
|
|
538
|
+
recipient,
|
|
539
|
+
recordingIds: [recordingId],
|
|
540
|
+
requestedBy,
|
|
541
|
+
});
|
|
542
|
+
if (enqueued.created || enqueued.job.state === "sent") return enqueued;
|
|
543
|
+
|
|
544
|
+
const converged = await withJobLock(logicalKey, async () => {
|
|
545
|
+
const job = await readJob(logicalKey);
|
|
546
|
+
if (
|
|
547
|
+
!job ||
|
|
548
|
+
job.type !== "first-import" ||
|
|
549
|
+
!["pending", "ready", "cancelled"].includes(job.state)
|
|
550
|
+
) {
|
|
551
|
+
return null;
|
|
552
|
+
}
|
|
553
|
+
if (job.recordingIds[0] === recordingId) return job;
|
|
554
|
+
const timestamp = now().toISOString();
|
|
555
|
+
const updated = transactionalEmailJobSchema.parse({
|
|
556
|
+
...job,
|
|
557
|
+
recipient,
|
|
558
|
+
recordingIds: [recordingId],
|
|
559
|
+
requestedBy,
|
|
560
|
+
state: "pending",
|
|
561
|
+
attempts: 0,
|
|
562
|
+
updatedAt: timestamp,
|
|
563
|
+
readyAt: undefined,
|
|
564
|
+
sendingAt: undefined,
|
|
565
|
+
cancelledAt: undefined,
|
|
566
|
+
failedAt: undefined,
|
|
567
|
+
lastError: null,
|
|
568
|
+
leaseUntil: null,
|
|
569
|
+
leaseToken: null,
|
|
570
|
+
});
|
|
571
|
+
await writeJsonAtomic(jobFile(logicalKey), updated);
|
|
572
|
+
return updated;
|
|
573
|
+
});
|
|
574
|
+
return { created: false, job: converged ?? enqueued.job };
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async function transition(
|
|
578
|
+
logicalKey: string,
|
|
579
|
+
expectedStates: readonly TransactionalEmailState[],
|
|
580
|
+
nextState: TransactionalEmailState,
|
|
581
|
+
changes: {
|
|
582
|
+
generatedSummary?: string;
|
|
583
|
+
lastError?: string | null;
|
|
584
|
+
} = {},
|
|
585
|
+
): Promise<TransactionalEmailJob | null> {
|
|
586
|
+
return withJobLock(logicalKey, async () => {
|
|
587
|
+
const job = await readJob(logicalKey);
|
|
588
|
+
if (!job || !expectedStates.includes(job.state)) return null;
|
|
589
|
+
if (job.state === "sending") return null;
|
|
590
|
+
if (!allowedTransitions[job.state].has(nextState)) {
|
|
591
|
+
throw new Error(
|
|
592
|
+
`Invalid transactional email transition: ${job.state} -> ${nextState}`,
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
const timestamp = now().toISOString();
|
|
596
|
+
const timestampField = stateTimestampField(nextState);
|
|
597
|
+
const updated = transactionalEmailJobSchema.parse({
|
|
598
|
+
...job,
|
|
599
|
+
...changes,
|
|
600
|
+
state: nextState,
|
|
601
|
+
updatedAt: timestamp,
|
|
602
|
+
leaseUntil: null,
|
|
603
|
+
leaseToken: null,
|
|
604
|
+
...(timestampField ? { [timestampField]: timestamp } : {}),
|
|
605
|
+
});
|
|
606
|
+
await writeJsonAtomic(jobFile(logicalKey), updated);
|
|
607
|
+
return updated;
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
async function claimAwaitingAi(
|
|
612
|
+
logicalKey: string,
|
|
613
|
+
claimantEmail: string,
|
|
614
|
+
): Promise<TransactionalEmailJob | null> {
|
|
615
|
+
const claimant = recipientSchema.parse(claimantEmail.trim().toLowerCase());
|
|
616
|
+
return withJobLock(logicalKey, async () => {
|
|
617
|
+
const job = await readJob(logicalKey);
|
|
618
|
+
if (!job || job.type !== "two-clips" || job.state !== "awaiting_ai") {
|
|
619
|
+
return null;
|
|
620
|
+
}
|
|
621
|
+
const timestamp = now().toISOString();
|
|
622
|
+
const claimed = transactionalEmailJobSchema.parse({
|
|
623
|
+
...job,
|
|
624
|
+
state: "ai_dispatched",
|
|
625
|
+
aiClaimedBy: claimant,
|
|
626
|
+
aiDispatchedAt: timestamp,
|
|
627
|
+
updatedAt: timestamp,
|
|
628
|
+
});
|
|
629
|
+
await writeJsonAtomic(jobFile(logicalKey), claimed);
|
|
630
|
+
return claimed;
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async function reclaimStaleAiDispatch(
|
|
635
|
+
logicalKey: string,
|
|
636
|
+
claimantEmail: string,
|
|
637
|
+
staleBefore: Date,
|
|
638
|
+
): Promise<TransactionalEmailJob | null> {
|
|
639
|
+
const claimant = recipientSchema.parse(claimantEmail.trim().toLowerCase());
|
|
640
|
+
return withJobLock(logicalKey, async () => {
|
|
641
|
+
const job = await readJob(logicalKey);
|
|
642
|
+
const dispatchedAt = job?.aiDispatchedAt ?? job?.updatedAt;
|
|
643
|
+
if (
|
|
644
|
+
!job ||
|
|
645
|
+
job.type !== "two-clips" ||
|
|
646
|
+
job.state !== "ai_dispatched" ||
|
|
647
|
+
!dispatchedAt ||
|
|
648
|
+
Date.parse(dispatchedAt) > staleBefore.getTime()
|
|
649
|
+
) {
|
|
650
|
+
return null;
|
|
651
|
+
}
|
|
652
|
+
const timestamp = now().toISOString();
|
|
653
|
+
const reclaimed = transactionalEmailJobSchema.parse({
|
|
654
|
+
...job,
|
|
655
|
+
aiClaimedBy: claimant,
|
|
656
|
+
aiDispatchedAt: timestamp,
|
|
657
|
+
updatedAt: timestamp,
|
|
658
|
+
});
|
|
659
|
+
await writeJsonAtomic(jobFile(logicalKey), reclaimed);
|
|
660
|
+
return reclaimed;
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
async function completeClaimedAi(
|
|
665
|
+
logicalKey: string,
|
|
666
|
+
claimantEmail: string,
|
|
667
|
+
generatedSummary: string,
|
|
668
|
+
): Promise<TransactionalEmailJob | null> {
|
|
669
|
+
const claimant = recipientSchema.parse(claimantEmail.trim().toLowerCase());
|
|
670
|
+
return withJobLock(logicalKey, async () => {
|
|
671
|
+
const job = await readJob(logicalKey);
|
|
672
|
+
if (
|
|
673
|
+
!job ||
|
|
674
|
+
job.type !== "two-clips" ||
|
|
675
|
+
job.state !== "ai_dispatched" ||
|
|
676
|
+
job.aiClaimedBy !== claimant
|
|
677
|
+
) {
|
|
678
|
+
return null;
|
|
679
|
+
}
|
|
680
|
+
const timestamp = now().toISOString();
|
|
681
|
+
const completed = transactionalEmailJobSchema.parse({
|
|
682
|
+
...job,
|
|
683
|
+
state: "ready",
|
|
684
|
+
generatedSummary,
|
|
685
|
+
readyAt: timestamp,
|
|
686
|
+
updatedAt: timestamp,
|
|
687
|
+
});
|
|
688
|
+
await writeJsonAtomic(jobFile(logicalKey), completed);
|
|
689
|
+
return completed;
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
async function claimNextAwaitingAi(): Promise<TransactionalEmailJob | null> {
|
|
694
|
+
const candidates = (await listJobs()).filter(
|
|
695
|
+
(job) => job.state === "awaiting_ai",
|
|
696
|
+
);
|
|
697
|
+
for (const candidate of candidates) {
|
|
698
|
+
const claimed = await transition(
|
|
699
|
+
candidate.logicalKey,
|
|
700
|
+
["awaiting_ai"],
|
|
701
|
+
"ai_dispatched",
|
|
702
|
+
);
|
|
703
|
+
if (claimed) return claimed;
|
|
704
|
+
}
|
|
705
|
+
return null;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
async function acquireSendingLease(
|
|
709
|
+
logicalKey: string,
|
|
710
|
+
leaseDurationMs: number,
|
|
711
|
+
): Promise<TransactionalEmailJob | null> {
|
|
712
|
+
if (!Number.isInteger(leaseDurationMs) || leaseDurationMs <= 0) {
|
|
713
|
+
throw new Error("leaseDurationMs must be a positive integer");
|
|
714
|
+
}
|
|
715
|
+
return withJobLock(logicalKey, async () => {
|
|
716
|
+
const job = await readJob(logicalKey);
|
|
717
|
+
if (!job) return null;
|
|
718
|
+
const currentTime = now();
|
|
719
|
+
const canAcquire =
|
|
720
|
+
job.state === "ready" ||
|
|
721
|
+
(job.state === "sending" &&
|
|
722
|
+
job.leaseUntil !== null &&
|
|
723
|
+
Date.parse(job.leaseUntil) <= currentTime.getTime());
|
|
724
|
+
if (!canAcquire) return null;
|
|
725
|
+
const timestamp = currentTime.toISOString();
|
|
726
|
+
const updated = transactionalEmailJobSchema.parse({
|
|
727
|
+
...job,
|
|
728
|
+
state: "sending",
|
|
729
|
+
attempts: job.attempts + 1,
|
|
730
|
+
sendingAt: timestamp,
|
|
731
|
+
updatedAt: timestamp,
|
|
732
|
+
lastError: null,
|
|
733
|
+
leaseUntil: new Date(
|
|
734
|
+
currentTime.getTime() + leaseDurationMs,
|
|
735
|
+
).toISOString(),
|
|
736
|
+
leaseToken: randomUUID(),
|
|
737
|
+
});
|
|
738
|
+
await writeJsonAtomic(jobFile(logicalKey), updated);
|
|
739
|
+
return updated;
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
async function transitionSending(
|
|
744
|
+
logicalKey: string,
|
|
745
|
+
leaseToken: string,
|
|
746
|
+
nextState: "sent" | "ready" | "cancelled" | "failed",
|
|
747
|
+
changes: { lastError?: string | null } = {},
|
|
748
|
+
): Promise<TransactionalEmailJob | null> {
|
|
749
|
+
const parsedLeaseToken = nonEmptyStringSchema.parse(leaseToken);
|
|
750
|
+
return withJobLock(logicalKey, async () => {
|
|
751
|
+
const job = await readJob(logicalKey);
|
|
752
|
+
if (
|
|
753
|
+
!job ||
|
|
754
|
+
job.state !== "sending" ||
|
|
755
|
+
job.leaseToken !== parsedLeaseToken
|
|
756
|
+
) {
|
|
757
|
+
return null;
|
|
758
|
+
}
|
|
759
|
+
if (!allowedTransitions.sending.has(nextState)) {
|
|
760
|
+
throw new Error(
|
|
761
|
+
`Invalid transactional email transition: sending -> ${nextState}`,
|
|
762
|
+
);
|
|
763
|
+
}
|
|
764
|
+
const timestamp = now().toISOString();
|
|
765
|
+
const timestampField = stateTimestampField(nextState);
|
|
766
|
+
const updated = transactionalEmailJobSchema.parse({
|
|
767
|
+
...job,
|
|
768
|
+
...changes,
|
|
769
|
+
state: nextState,
|
|
770
|
+
updatedAt: timestamp,
|
|
771
|
+
leaseUntil: null,
|
|
772
|
+
leaseToken: null,
|
|
773
|
+
...(timestampField ? { [timestampField]: timestamp } : {}),
|
|
774
|
+
});
|
|
775
|
+
await writeJsonAtomic(jobFile(logicalKey), updated);
|
|
776
|
+
return updated;
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
async function ensureEnabledAt(): Promise<TransactionalEmailConfig> {
|
|
781
|
+
await ensureDirectories();
|
|
782
|
+
const config = transactionalEmailConfigSchema.parse({
|
|
783
|
+
enabledAt: now().toISOString(),
|
|
784
|
+
});
|
|
785
|
+
try {
|
|
786
|
+
await writeFile(configFile, `${JSON.stringify(config, null, 2)}\n`, {
|
|
787
|
+
encoding: "utf8",
|
|
788
|
+
flag: "wx",
|
|
789
|
+
mode: 0o600,
|
|
790
|
+
});
|
|
791
|
+
return config;
|
|
792
|
+
} catch (error) {
|
|
793
|
+
if (!isNodeError(error, "EEXIST")) throw error;
|
|
794
|
+
return parseJsonFile(
|
|
795
|
+
configFile,
|
|
796
|
+
transactionalEmailConfigSchema,
|
|
797
|
+
"transactional email config",
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
async function updateReconciliationCursor(
|
|
803
|
+
cursorName: z.infer<typeof transactionalEmailCursorNameSchema>,
|
|
804
|
+
reconciliationCursor: NonNullable<
|
|
805
|
+
TransactionalEmailConfig["reconciliationCursor"]
|
|
806
|
+
> | null,
|
|
807
|
+
): Promise<TransactionalEmailConfig> {
|
|
808
|
+
const parsedCursorName =
|
|
809
|
+
transactionalEmailCursorNameSchema.parse(cursorName);
|
|
810
|
+
const parsedCursor = reconciliationCursorSchema.parse(reconciliationCursor);
|
|
811
|
+
const updated = await withJobLock(
|
|
812
|
+
"transactional-email-config",
|
|
813
|
+
async () => {
|
|
814
|
+
const config = await parseJsonFile(
|
|
815
|
+
configFile,
|
|
816
|
+
transactionalEmailConfigSchema,
|
|
817
|
+
"transactional email config",
|
|
818
|
+
);
|
|
819
|
+
const nextConfig = transactionalEmailConfigSchema.parse({
|
|
820
|
+
...config,
|
|
821
|
+
[parsedCursorName]: parsedCursor,
|
|
822
|
+
});
|
|
823
|
+
await writeJsonAtomic(configFile, nextConfig);
|
|
824
|
+
return nextConfig;
|
|
825
|
+
},
|
|
826
|
+
);
|
|
827
|
+
if (!updated) {
|
|
828
|
+
throw new Error("Transactional email config is being updated");
|
|
829
|
+
}
|
|
830
|
+
return updated;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
async function readConfig(): Promise<TransactionalEmailConfig | null> {
|
|
834
|
+
try {
|
|
835
|
+
return await parseJsonFile(
|
|
836
|
+
configFile,
|
|
837
|
+
transactionalEmailConfigSchema,
|
|
838
|
+
"transactional email config",
|
|
839
|
+
);
|
|
840
|
+
} catch (error) {
|
|
841
|
+
if (isNodeError(error, "ENOENT")) return null;
|
|
842
|
+
throw error;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
return {
|
|
847
|
+
root,
|
|
848
|
+
enqueue,
|
|
849
|
+
enqueueOrConvergeFirstImport,
|
|
850
|
+
readJob,
|
|
851
|
+
listJobs,
|
|
852
|
+
transition,
|
|
853
|
+
claimAwaitingAi,
|
|
854
|
+
reclaimStaleAiDispatch,
|
|
855
|
+
completeClaimedAi,
|
|
856
|
+
claimNextAwaitingAi,
|
|
857
|
+
acquireSendingLease,
|
|
858
|
+
transitionSending,
|
|
859
|
+
ensureEnabledAt,
|
|
860
|
+
updateReconciliationCursor,
|
|
861
|
+
readConfig,
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
export const transactionalEmailStore = createTransactionalEmailStore();
|