@mevdragon/vidfarm-devcli 0.5.3 → 0.7.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 (116) hide show
  1. package/README.md +3 -3
  2. package/SKILL.director.md +28 -7
  3. package/SKILL.platform.md +9 -5
  4. package/demo/README.md +28 -0
  5. package/demo/dist/app.css +1 -0
  6. package/demo/dist/app.js +1184 -0
  7. package/demo/dist/chunks/chunk-DXB73IDG.js +1 -0
  8. package/demo/dist/chunks/chunk-S7OWAJDS.js +36 -0
  9. package/demo/dist/chunks/chunk-VTIBZ6AN.js +1 -0
  10. package/demo/dist/chunks/dist-ADSJKBVE.js +332 -0
  11. package/demo/dist/chunks/domEditingLayers-VZMLL4AP-SGHWPND4.js +1 -0
  12. package/demo/dist/chunks/hyperframes-player-XB65TCD6.js +425 -0
  13. package/demo/dist/chunks/lib-XAQ37YOE.js +1 -0
  14. package/demo/dist/chunks/src-TJ2QYA4U.js +207 -0
  15. package/demo/dist/favicon.ico +0 -0
  16. package/demo/dist/icons/timeline/audio.svg +7 -0
  17. package/demo/dist/icons/timeline/captions.svg +5 -0
  18. package/demo/dist/icons/timeline/composition.svg +12 -0
  19. package/demo/dist/icons/timeline/image.svg +18 -0
  20. package/demo/dist/icons/timeline/music.svg +10 -0
  21. package/demo/dist/icons/timeline/text.svg +3 -0
  22. package/demo/dist/index.html +15 -0
  23. package/dist/src/account-pages-legacy.js +9396 -0
  24. package/dist/src/account-pages.js +61 -0
  25. package/dist/src/app.js +14663 -0
  26. package/dist/src/cli.js +60 -97
  27. package/dist/src/composition-runtime.js +613 -0
  28. package/dist/src/config.js +173 -0
  29. package/dist/src/context.js +447 -0
  30. package/dist/src/dev-app-legacy.js +739 -0
  31. package/dist/src/dev-app.js +6 -0
  32. package/dist/src/domain.js +2 -0
  33. package/dist/src/editor-chat-history.js +82 -0
  34. package/dist/src/editor-chat.js +449 -0
  35. package/dist/src/editor-dark-theme.js +1128 -0
  36. package/dist/src/frontend/debug.js +71 -0
  37. package/dist/src/frontend/flockposter-cache-store.js +124 -0
  38. package/dist/src/frontend/homepage-client.js +182 -0
  39. package/dist/src/frontend/homepage-shared.js +4 -0
  40. package/dist/src/frontend/homepage-store.js +28 -0
  41. package/dist/src/frontend/homepage-view.js +547 -0
  42. package/dist/src/frontend/page-runtime-client.js +132 -0
  43. package/dist/src/frontend/page-runtime-store.js +9 -0
  44. package/dist/src/frontend/sentry.js +42 -0
  45. package/dist/src/frontend/template-editor-chat.js +3960 -0
  46. package/dist/src/help-page.js +346 -0
  47. package/dist/src/homepage.js +1235 -0
  48. package/dist/src/hyperframes/composition.js +180 -0
  49. package/dist/src/index.js +16 -0
  50. package/dist/src/instrument.js +30 -0
  51. package/dist/src/lib/crypto.js +45 -0
  52. package/dist/src/lib/dev-log.js +54 -0
  53. package/dist/src/lib/display-name.js +11 -0
  54. package/dist/src/lib/ids.js +24 -0
  55. package/dist/src/lib/images.js +19 -0
  56. package/dist/src/lib/json.js +15 -0
  57. package/dist/src/lib/package-root.js +47 -0
  58. package/dist/src/lib/template-paths.js +28 -0
  59. package/dist/src/lib/time.js +7 -0
  60. package/dist/src/lib/url-clean.js +85 -0
  61. package/dist/src/page-runtime.js +2 -0
  62. package/dist/src/page-shell.js +1381 -0
  63. package/dist/src/primitive-context.js +357 -0
  64. package/dist/src/primitive-registry.js +2561 -0
  65. package/dist/src/primitive-sdk.js +4 -0
  66. package/dist/src/primitives/hyperframes-media.js +108 -0
  67. package/dist/src/react-page-shell.js +35 -0
  68. package/dist/src/ready-post-schedule-component.js +1540 -0
  69. package/dist/src/registry.js +296 -0
  70. package/dist/src/runtime.js +35 -0
  71. package/dist/src/services/api-call-history.js +249 -0
  72. package/dist/src/services/auth.js +152 -0
  73. package/dist/src/services/billing-pricing.js +39 -0
  74. package/dist/src/services/billing.js +228 -0
  75. package/dist/src/services/cast.js +127 -0
  76. package/dist/src/services/chat-threads.js +92 -0
  77. package/dist/src/services/composition-sanitize.js +124 -0
  78. package/dist/src/services/composition-watch.js +79 -0
  79. package/dist/src/services/fork-access.js +93 -0
  80. package/dist/src/services/fork-manifest.js +42 -0
  81. package/dist/src/services/ghostcut.js +179 -0
  82. package/dist/src/services/hyperframes.js +2307 -0
  83. package/dist/src/services/job-capacity.js +14 -0
  84. package/dist/src/services/job-logs.js +197 -0
  85. package/dist/src/services/jobs.js +136 -0
  86. package/dist/src/services/local-dynamo.js +0 -0
  87. package/dist/src/services/media-processing.js +766 -0
  88. package/dist/src/services/primitive-media-lambda.js +280 -0
  89. package/dist/src/services/providers.js +2926 -0
  90. package/dist/src/services/rate-limits.js +262 -0
  91. package/dist/src/services/serverless-auth.js +382 -0
  92. package/dist/src/services/serverless-jobs.js +1082 -0
  93. package/dist/src/services/serverless-provider-keys.js +409 -0
  94. package/dist/src/services/serverless-records.js +1385 -0
  95. package/dist/src/services/serverless-template-configs.js +75 -0
  96. package/dist/src/services/storage.js +383 -0
  97. package/dist/src/services/template-certification.js +413 -0
  98. package/dist/src/services/template-loader.js +99 -0
  99. package/dist/src/services/template-runtime-bundles.js +217 -0
  100. package/dist/src/services/template-sources.js +1017 -0
  101. package/dist/src/services/upstream.js +248 -0
  102. package/dist/src/services/video-normalization.js +2 -0
  103. package/dist/src/services/webhooks.js +62 -0
  104. package/dist/src/template-editor-pages.js +2576 -0
  105. package/dist/src/template-editor-shell.js +2840 -0
  106. package/dist/src/template-sdk.js +4 -0
  107. package/dist/src/worker.js +17 -0
  108. package/package.json +6 -4
  109. package/public/assets/homepage-app.js +54 -0
  110. package/public/assets/homepage-client-app.js +80 -0
  111. package/public/assets/page-runtime-client-app.js +94 -0
  112. package/src/assets/SELLING_AWARENESS_STAGES.md +579 -0
  113. package/src/assets/SELLING_WITH_HOOKS.md +377 -0
  114. package/src/assets/SELLING_WITH_VSLS.md +606 -0
  115. package/src/assets/favicon.ico +0 -0
  116. package/src/assets/logo-vidfarm.png +0 -0
@@ -0,0 +1,1385 @@
1
+ import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
2
+ import { DeleteCommand, DynamoDBDocumentClient, GetCommand, PutCommand, QueryCommand, ScanCommand, UpdateCommand } from "@aws-sdk/lib-dynamodb";
3
+ import { config } from "../config.js";
4
+ import { devLog } from "../lib/dev-log.js";
5
+ import { displayNameFromEmail } from "../lib/display-name.js";
6
+ import { createId, createIdV7 } from "../lib/ids.js";
7
+ import { nowIso } from "../lib/time.js";
8
+ import { ServerlessAuthService } from "./serverless-auth.js";
9
+ import { createLocalDynamoClient } from "./local-dynamo.js";
10
+ // In local (`vidfarm serve`) mode every DynamoDB call is serviced from disk by
11
+ // the shim; deployed environments keep the real client. Call sites are
12
+ // identical either way — only this construction switches.
13
+ const dynamodb = config.RECORDS_DRIVER === "local"
14
+ ? createLocalDynamoClient()
15
+ : DynamoDBDocumentClient.from(new DynamoDBClient({}), {
16
+ marshallOptions: {
17
+ removeUndefinedValues: true
18
+ }
19
+ });
20
+ const METHOD_COLLECTIONS = {
21
+ archiveReadyPost: "ready_post",
22
+ createCompositionFork: "composition_fork",
23
+ deleteCompositionFork: "composition_fork",
24
+ getCompositionFork: "composition_fork",
25
+ listCompositionForksForCustomer: "composition_fork",
26
+ listCompositionForksForTemplate: "composition_fork",
27
+ updateCompositionFork: "composition_fork",
28
+ createInspiration: "inspiration",
29
+ getInspiration: "inspiration",
30
+ findInspirationByOriginalUrl: "inspiration",
31
+ updateInspiration: "inspiration",
32
+ listInspirationsForCustomer: "inspiration",
33
+ listInspirationsFeed: "inspiration",
34
+ createTemplate: "template",
35
+ getTemplate: "template",
36
+ updateTemplate: "template",
37
+ deleteTemplate: "template",
38
+ deleteInspiration: "inspiration",
39
+ listTemplatesFeed: "template",
40
+ listTemplatesForCustomer: "template",
41
+ createEmailChannel: "email_channel",
42
+ createPostSchedule: "post_schedule",
43
+ createReadyPost: "ready_post",
44
+ createTemplateRelease: "template_release",
45
+ createTemplateSource: "template_source",
46
+ createUserAttachment: "user_attachment",
47
+ createUserFileFolder: "user_file_folder",
48
+ createUserTemporaryFile: "user_temporary_file",
49
+ deleteEmailChannel: "email_channel",
50
+ deleteReadyPost: "ready_post",
51
+ deleteUserAttachment: "user_attachment",
52
+ deleteUserFileFolder: "user_file_folder",
53
+ deleteUserTemporaryFile: "user_temporary_file",
54
+ getEmailChannel: "email_channel",
55
+ getEmailChannelByEmail: "email_channel",
56
+ getPostSchedule: "post_schedule",
57
+ getReadyPost: "ready_post",
58
+ getTemplateRelease: "template_release",
59
+ getTemplateSource: "template_source",
60
+ getTemplateSourceByLocation: "template_source",
61
+ getTemplateSourceBySlugId: "template_source",
62
+ getTemplateSourceByTemplateId: "template_source",
63
+ getUserAttachment: "user_attachment",
64
+ getUserTemporaryFile: "user_temporary_file",
65
+ listEmailChannels: "email_channel",
66
+ listPostSchedulesForCalendar: "post_schedule",
67
+ listPostSchedulesForPost: "post_schedule",
68
+ listReadyPosts: "ready_post",
69
+ listTemplateReleases: "template_release",
70
+ listTemplateSources: "template_source",
71
+ listUserAttachments: "user_attachment",
72
+ listUserFileFolders: "user_file_folder",
73
+ listUserTemporaryFiles: "user_temporary_file",
74
+ markPostScheduleCancelled: "post_schedule",
75
+ markPostScheduleFailed: "post_schedule",
76
+ moveUserAttachmentFolder: "user_attachment",
77
+ moveUserFileFolder: "user_file_folder",
78
+ moveUserTemporaryFolder: "user_temporary_file",
79
+ queueWebhookDelivery: "webhook_delivery",
80
+ setReadyPostZip: "ready_post",
81
+ updateEmailChannel: "email_channel",
82
+ updateEmailChannelAvatar: "email_channel",
83
+ updateEmailChannelVerification: "email_channel",
84
+ updatePostScheduleProvider: "post_schedule",
85
+ updateTemplateSource: "template_source",
86
+ upsertExternalPostSchedule: "post_schedule"
87
+ };
88
+ class ServerlessRecordsServiceImpl {
89
+ auth = new ServerlessAuthService();
90
+ async getCustomerById(customerId) {
91
+ return this.auth.getCustomerById(customerId);
92
+ }
93
+ async getCustomerByEmail(email) {
94
+ return this.auth.getCustomerByEmailPublic(email);
95
+ }
96
+ async upsertCustomer(input) {
97
+ return this.auth.upsertCustomer(input);
98
+ }
99
+ async insertApiKey(input) {
100
+ return this.auth.insertApiKey(input);
101
+ }
102
+ async getFirstPaidCustomer() {
103
+ const response = await dynamodb.send(new ScanCommand({
104
+ TableName: requireMainTableName(),
105
+ FilterExpression: "entity_type = :type AND is_paid_plan = :paid",
106
+ ExpressionAttributeValues: {
107
+ ":type": "Customer",
108
+ ":paid": true
109
+ },
110
+ Limit: 1
111
+ }));
112
+ return response.Items?.[0] ? itemToCustomer(response.Items[0]) : null;
113
+ }
114
+ async setCustomerAccessByEmail(input) {
115
+ const normalizedEmail = input.email.trim().toLowerCase();
116
+ const existing = await this.auth.getCustomerByEmailPublic(normalizedEmail);
117
+ return this.auth.upsertCustomer({
118
+ id: existing?.id ?? createId("cus"),
119
+ email: normalizedEmail,
120
+ name: input.name?.trim() || existing?.name || displayNameFromEmail(normalizedEmail),
121
+ defaultWebhookUrl: existing?.defaultWebhookUrl ?? null,
122
+ isDeveloper: input.isDeveloper ?? existing?.isDeveloper ?? false,
123
+ isAgency: input.isAgency ?? existing?.isAgency ?? false,
124
+ isPaidPlan: input.isPaidPlan ?? existing?.isPaidPlan ?? false,
125
+ planTier: input.planTier ?? existing?.planTier ?? "client"
126
+ });
127
+ }
128
+ async updateCustomerPaidPlan(customerId, isPaidPlan, planTier = "client") {
129
+ const existing = await this.auth.getCustomerById(customerId);
130
+ if (!existing)
131
+ return null;
132
+ return this.auth.upsertCustomer({ ...existing, isPaidPlan, planTier });
133
+ }
134
+ async updateCustomerAgencyStatus(customerId, isAgency) {
135
+ const existing = await this.auth.getCustomerById(customerId);
136
+ if (!existing)
137
+ return null;
138
+ return this.auth.upsertCustomer({ ...existing, isAgency, planTier: isAgency ? "agency" : existing.planTier });
139
+ }
140
+ async updateCustomerProfile(input) {
141
+ const existing = await this.auth.getCustomerById(input.customerId);
142
+ if (!existing)
143
+ return null;
144
+ return this.auth.upsertCustomer({
145
+ ...existing,
146
+ name: input.name === undefined ? existing.name : input.name,
147
+ about: input.about === undefined ? existing.about : input.about,
148
+ defaultWebhookUrl: input.defaultWebhookUrl === undefined ? existing.defaultWebhookUrl : input.defaultWebhookUrl,
149
+ groupchatUrl: input.groupchatUrl === undefined ? existing.groupchatUrl : input.groupchatUrl,
150
+ flockposterApiKey: input.flockposterApiKey === undefined ? existing.flockposterApiKey : input.flockposterApiKey
151
+ });
152
+ }
153
+ async getLatestApiKeyForCustomer(customerId) {
154
+ const response = await dynamodb.send(new QueryCommand({
155
+ TableName: requireMainTableName(),
156
+ IndexName: "gsi1",
157
+ KeyConditionExpression: "gsi1pk = :pk",
158
+ FilterExpression: "#status = :status",
159
+ ExpressionAttributeNames: {
160
+ "#status": "status"
161
+ },
162
+ ExpressionAttributeValues: {
163
+ ":pk": `CUSTOMER#${customerId}#APIKEYS`,
164
+ ":status": "active"
165
+ },
166
+ ScanIndexForward: false,
167
+ Limit: 1
168
+ }));
169
+ return response.Items?.[0] ?? null;
170
+ }
171
+ async hasCustomerCreditGrant(customerId, grantType) {
172
+ return Boolean(await this.getById("credit_grant", `${customerId}:${grantType}`));
173
+ }
174
+ async insertCustomerCreditGrant(input) {
175
+ await this.putRecord("credit_grant", {
176
+ ...input,
177
+ id: `${input.customerId}:${input.grantType}`,
178
+ customerId: input.customerId
179
+ });
180
+ }
181
+ async grantAgencyClientAccess(input) {
182
+ return this.putRecord("agency_access", {
183
+ id: `${input.agencyCustomerId}:${input.clientCustomerId}`,
184
+ customerId: input.agencyCustomerId,
185
+ agencyCustomerId: input.agencyCustomerId,
186
+ clientCustomerId: input.clientCustomerId
187
+ });
188
+ }
189
+ async revokeAgencyClientAccess(input) {
190
+ const record = await this.getById("agency_access", `${input.agencyCustomerId}:${input.clientCustomerId}`);
191
+ await this.deleteById("agency_access", `${input.agencyCustomerId}:${input.clientCustomerId}`);
192
+ return record;
193
+ }
194
+ async listAgencyClients(input) {
195
+ const links = await this.listByCustomer("agency_access", input.agencyCustomerId);
196
+ const clients = [];
197
+ for (const link of links) {
198
+ const clientId = String(link.clientCustomerId ?? "");
199
+ const customer = clientId ? await this.auth.getCustomerById(clientId) : null;
200
+ if (customer)
201
+ clients.push({ ...link, customer });
202
+ }
203
+ return clients;
204
+ }
205
+ async getAgencyClient(agencyCustomerId, clientCustomerId) {
206
+ return this.getById("agency_access", `${agencyCustomerId}:${clientCustomerId}`);
207
+ }
208
+ async createCompositionFork(input) {
209
+ const timestamp = nowIso();
210
+ return this.putRecord("composition_fork", {
211
+ id: input.id ?? createIdV7("fork"),
212
+ customerId: input.customerId,
213
+ templateId: input.templateId,
214
+ parentForkId: input.parentForkId ?? null,
215
+ parentVersion: input.parentVersion ?? null,
216
+ title: input.title ?? null,
217
+ visibility: input.visibility ?? "private",
218
+ latestVersion: 0,
219
+ currentRenderJobId: null,
220
+ deletedAt: null,
221
+ createdAt: timestamp,
222
+ updatedAt: timestamp
223
+ });
224
+ }
225
+ async getCompositionFork(forkId) {
226
+ const fork = await this.getById("composition_fork", forkId);
227
+ if (!fork)
228
+ return null;
229
+ return {
230
+ ...fork,
231
+ parentForkId: fork.parentForkId ?? null,
232
+ parentVersion: fork.parentVersion ?? null,
233
+ visibility: fork.visibility ?? "private",
234
+ latestVersion: fork.latestVersion ?? 0
235
+ };
236
+ }
237
+ async listCompositionForksForTemplate(input) {
238
+ const owned = await this.listByCustomer("composition_fork", input.customerId);
239
+ return owned.filter((fork) => fork.templateId === input.templateId && !fork.deletedAt);
240
+ }
241
+ async listCompositionForksForCustomer(customerId) {
242
+ return this.listByCustomer("composition_fork", customerId);
243
+ }
244
+ async listPublicCompositionForks() {
245
+ return (await this.scanCollection("composition_fork"))
246
+ .filter((fork) => !fork.deletedAt && fork.visibility === "public");
247
+ }
248
+ async updateCompositionFork(input) {
249
+ const existing = await this.getCompositionFork(input.forkId);
250
+ if (!existing)
251
+ return null;
252
+ return this.putRecord("composition_fork", {
253
+ ...existing,
254
+ ...input.patch,
255
+ id: existing.id,
256
+ customerId: existing.customerId,
257
+ updatedAt: nowIso()
258
+ });
259
+ }
260
+ async deleteCompositionFork(input) {
261
+ const existing = await this.getCompositionFork(input.forkId);
262
+ if (!existing || existing.customerId !== input.customerId)
263
+ return null;
264
+ return this.putRecord("composition_fork", {
265
+ ...existing,
266
+ deletedAt: nowIso(),
267
+ updatedAt: nowIso()
268
+ });
269
+ }
270
+ async grantForkPermission(input) {
271
+ const existing = (await this.listForkPermissions(input.forkId))
272
+ .find((perm) => perm.granteeType === input.granteeType && perm.granteeId === input.granteeId);
273
+ const timestamp = nowIso();
274
+ const id = existing?.id ?? createId("perm");
275
+ return this.putRecord("fork_permission", {
276
+ id,
277
+ forkId: input.forkId,
278
+ ownerCustomerId: input.ownerCustomerId,
279
+ granteeType: input.granteeType,
280
+ granteeId: input.granteeId,
281
+ role: input.role,
282
+ grantedBy: input.grantedBy,
283
+ expiresAt: input.expiresAt ?? null,
284
+ createdAt: existing?.createdAt ?? timestamp,
285
+ updatedAt: timestamp
286
+ }, { partitionKey: forkPartitionPk("fork_permission", input.forkId) });
287
+ }
288
+ async listForkPermissions(forkId) {
289
+ return this.listByPartition("fork_permission", forkPartitionPk("fork_permission", forkId));
290
+ }
291
+ async listForkPermissionsForGrantee(customerId) {
292
+ return (await this.scanCollection("fork_permission"))
293
+ .filter((perm) => perm.granteeType === "customer" && perm.granteeId === customerId);
294
+ }
295
+ async deleteForkPermission(input) {
296
+ const existing = (await this.listForkPermissions(input.forkId)).find((perm) => perm.id === input.permissionId);
297
+ if (!existing)
298
+ return null;
299
+ await this.deleteById("fork_permission", input.permissionId);
300
+ return existing;
301
+ }
302
+ async createForkShareLink(input) {
303
+ const timestamp = nowIso();
304
+ return this.putRecord("fork_share_link", {
305
+ id: input.token,
306
+ forkId: input.forkId,
307
+ ownerCustomerId: input.ownerCustomerId,
308
+ token: input.token,
309
+ role: input.role,
310
+ createdBy: input.createdBy,
311
+ expiresAt: input.expiresAt ?? null,
312
+ revokedAt: null,
313
+ createdAt: timestamp,
314
+ updatedAt: timestamp
315
+ }, { partitionKey: forkPartitionPk("fork_share_link", input.forkId) });
316
+ }
317
+ async getForkShareLinkByToken(token) {
318
+ return this.getById("fork_share_link", token);
319
+ }
320
+ async listForkShareLinks(forkId) {
321
+ return this.listByPartition("fork_share_link", forkPartitionPk("fork_share_link", forkId));
322
+ }
323
+ async revokeForkShareLink(input) {
324
+ const existing = await this.getForkShareLinkByToken(input.token);
325
+ if (!existing || existing.forkId !== input.forkId)
326
+ return null;
327
+ return this.putRecord("fork_share_link", {
328
+ ...existing,
329
+ revokedAt: nowIso(),
330
+ updatedAt: nowIso()
331
+ }, { partitionKey: forkPartitionPk("fork_share_link", input.forkId) });
332
+ }
333
+ async createForkVersion(input) {
334
+ const timestamp = nowIso();
335
+ return this.putRecord("fork_version", {
336
+ id: `${input.forkId}:${input.version}`,
337
+ forkId: input.forkId,
338
+ ownerCustomerId: input.ownerCustomerId,
339
+ version: input.version,
340
+ reason: input.reason,
341
+ message: input.message ?? null,
342
+ createdBy: input.createdBy,
343
+ storagePrefix: input.storagePrefix,
344
+ renderJobId: input.renderJobId ?? null,
345
+ createdAt: timestamp,
346
+ updatedAt: timestamp
347
+ }, { partitionKey: forkPartitionPk("fork_version", input.forkId) });
348
+ }
349
+ async getForkVersion(forkId, version) {
350
+ return this.getById("fork_version", `${forkId}:${version}`);
351
+ }
352
+ async listForkVersions(input) {
353
+ const limit = Math.max(1, Math.min(input.limit ?? 25, 200));
354
+ let ExclusiveStartKey = decodeDynamoCursor(input.cursor);
355
+ const items = [];
356
+ do {
357
+ const response = await dynamodb.send(new QueryCommand({
358
+ TableName: requireMainTableName(),
359
+ IndexName: "gsi1",
360
+ KeyConditionExpression: "gsi1pk = :pk",
361
+ ExpressionAttributeValues: {
362
+ ":pk": forkPartitionPk("fork_version", input.forkId)
363
+ },
364
+ ScanIndexForward: false,
365
+ Limit: Math.max(1, limit - items.length),
366
+ ExclusiveStartKey
367
+ }));
368
+ items.push(...(response.Items ?? []).map((item) => item.data));
369
+ ExclusiveStartKey = response.LastEvaluatedKey;
370
+ } while (ExclusiveStartKey && items.length < limit);
371
+ return { items, nextCursor: encodeDynamoCursor(ExclusiveStartKey) };
372
+ }
373
+ async createInspiration(input) {
374
+ const timestamp = nowIso();
375
+ return this.putRecord("inspiration", {
376
+ id: input.id ?? createIdV7("inspiration"),
377
+ customerId: input.customerId,
378
+ sourceUrl: input.sourceUrl,
379
+ originalUrl: input.originalUrl,
380
+ sourceHost: input.sourceHost,
381
+ status: "downloading",
382
+ downloadJobId: input.downloadJobId ?? null,
383
+ videoStorageKey: null,
384
+ videoUrl: null,
385
+ thumbnailUrl: null,
386
+ title: null,
387
+ durationSeconds: null,
388
+ songName: null,
389
+ songUrl: null,
390
+ templateId: null,
391
+ trendTagline: input.trendTagline ?? null,
392
+ visibility: input.visibility ?? "public",
393
+ notes: input.notes ?? null,
394
+ errorMessage: null,
395
+ createdAt: timestamp,
396
+ updatedAt: timestamp
397
+ });
398
+ }
399
+ async getInspiration(inspirationId) {
400
+ const record = await this.getById("inspiration", inspirationId);
401
+ return record ? normalizeInspirationVisibility(record) : null;
402
+ }
403
+ async findInspirationByOriginalUrl(originalUrl) {
404
+ const record = await this.findByScan("inspiration", (item) => item.originalUrl === originalUrl || item.sourceUrl === originalUrl);
405
+ return record ? normalizeInspirationVisibility(record) : null;
406
+ }
407
+ async updateInspiration(input) {
408
+ const existing = await this.getInspiration(input.inspirationId);
409
+ if (!existing)
410
+ return null;
411
+ return this.putRecord("inspiration", {
412
+ ...existing,
413
+ ...input.patch,
414
+ id: existing.id,
415
+ customerId: existing.customerId,
416
+ updatedAt: nowIso()
417
+ });
418
+ }
419
+ async listInspirationsForCustomer(customerId) {
420
+ const records = await this.listByCustomer("inspiration", customerId);
421
+ return records.map(normalizeInspirationVisibility);
422
+ }
423
+ async listInspirationsFeed(input = {}) {
424
+ const limit = Math.max(1, Math.min(input.limit ?? 50, 200));
425
+ const all = (await this.scanCollection("inspiration"))
426
+ .filter((video) => video.status === "ready" && video.visibility !== "private");
427
+ const ranked = all
428
+ .map(normalizeInspirationVisibility)
429
+ .sort((a, b) => String(b.createdAt ?? "").localeCompare(String(a.createdAt ?? "")));
430
+ return filterAndRankBySearch(ranked, input.query).slice(0, limit);
431
+ }
432
+ async createTemplate(input) {
433
+ const timestamp = nowIso();
434
+ return this.putRecord("template", {
435
+ id: input.id ?? createIdV7("template"),
436
+ inspirationId: input.inspirationId,
437
+ customerId: input.customerId,
438
+ originalUrl: input.originalUrl,
439
+ sourceHost: input.sourceHost,
440
+ title: input.title ?? null,
441
+ thumbnailUrl: input.thumbnailUrl ?? null,
442
+ videoUrl: input.videoUrl ?? null,
443
+ durationSeconds: input.durationSeconds ?? null,
444
+ songName: input.songName ?? null,
445
+ songUrl: input.songUrl ?? null,
446
+ viralDna: input.viralDna ?? null,
447
+ defaultForkId: input.defaultForkId ?? null,
448
+ visibility: input.visibility ?? "public",
449
+ notes: input.notes ?? null,
450
+ createdAt: timestamp,
451
+ updatedAt: timestamp
452
+ }).then((record) => {
453
+ this.bustTemplatesFeedCache();
454
+ return record;
455
+ });
456
+ }
457
+ async getTemplate(templateId) {
458
+ const record = await this.getById("template", templateId);
459
+ return record ? normalizeTemplateVisibility(record) : null;
460
+ }
461
+ async listTemplatesForCustomer(customerId) {
462
+ const records = await this.listByCustomer("template", customerId);
463
+ return records.map(normalizeTemplateVisibility);
464
+ }
465
+ async deleteTemplate(templateId) {
466
+ await this.deleteById("template", templateId);
467
+ this.bustTemplatesFeedCache();
468
+ }
469
+ async deleteInspiration(inspirationId) {
470
+ await this.deleteById("inspiration", inspirationId);
471
+ }
472
+ async updateTemplate(input) {
473
+ const existing = await this.getTemplate(input.templateId);
474
+ if (!existing)
475
+ return null;
476
+ return this.putRecord("template", {
477
+ ...existing,
478
+ ...input.patch,
479
+ id: existing.id,
480
+ customerId: existing.customerId,
481
+ updatedAt: nowIso()
482
+ }).then((record) => {
483
+ this.bustTemplatesFeedCache();
484
+ return record;
485
+ });
486
+ }
487
+ // Atomically claim this template's default fork for the FIRST decompose only.
488
+ // Uses a conditional DynamoDB UpdateCommand (attribute_not_exists / == null)
489
+ // so concurrent first-decomposes can't both win — the loser's write is
490
+ // rejected at the storage layer and it re-reads the winner. A plain
491
+ // read-check-then-updateTemplate() is racy (both callers pass the JS guard,
492
+ // the later putRecord clobbers the earlier) AND clobbers concurrent edits to
493
+ // other template fields; this touches only defaultForkId + the derived index
494
+ // keys (gsi1sk recency, gsi2sk feed tier — decomposed templates promote from
495
+ // tier "0" to "1"). Returns whether THIS call became the default and the
496
+ // resulting template (the winner's, if we lost the race).
497
+ async stampDefaultForkIfUnset(input) {
498
+ const existing = await this.getTemplate(input.templateId);
499
+ if (!existing)
500
+ return { template: null, becameDefault: false };
501
+ if (existing.defaultForkId)
502
+ return { template: existing, becameDefault: false };
503
+ const now = nowIso();
504
+ const names = {
505
+ "#data": "data",
506
+ "#dfk": "defaultForkId",
507
+ "#updatedAt": "updatedAt"
508
+ };
509
+ const values = {
510
+ ":forkId": input.forkId,
511
+ ":now": now,
512
+ ":gsi1sk": `${now}#${existing.id}`,
513
+ ":null": null
514
+ };
515
+ const setClauses = ["#data.#dfk = :forkId", "#data.#updatedAt = :now", "gsi1sk = :gsi1sk"];
516
+ // Only public/unlisted templates carry the sparse gsi2 feed keys; a private
517
+ // template has no gsi2pk, so writing a bare gsi2sk would be inconsistent.
518
+ if (existing.visibility !== "private") {
519
+ values[":gsi2sk"] = templateFeedSortKey({ defaultForkId: input.forkId, createdAt: existing.createdAt, id: existing.id });
520
+ setClauses.push("gsi2sk = :gsi2sk");
521
+ }
522
+ try {
523
+ await dynamodb.send(new UpdateCommand({
524
+ TableName: requireMainTableName(),
525
+ Key: recordKey("template", existing.id),
526
+ UpdateExpression: `SET ${setClauses.join(", ")}`,
527
+ ConditionExpression: "attribute_not_exists(#data.#dfk) OR #data.#dfk = :null",
528
+ ExpressionAttributeNames: names,
529
+ ExpressionAttributeValues: values
530
+ }));
531
+ this.bustTemplatesFeedCache();
532
+ return { template: { ...existing, defaultForkId: input.forkId, updatedAt: now }, becameDefault: true };
533
+ }
534
+ catch (error) {
535
+ // Lost the race: another decompose already stamped a default fork. Not an
536
+ // error — re-read so callers see the winning fork id.
537
+ if (error?.name === "ConditionalCheckFailedException") {
538
+ const winner = await this.getTemplate(input.templateId);
539
+ return { template: winner, becameDefault: false };
540
+ }
541
+ throw error;
542
+ }
543
+ }
544
+ // The discover feed is global (identical for every caller) and read on every
545
+ // homepage load, but templates live in per-customer gsi1 partitions — so
546
+ // template items also carry a FEED#template gsi2 partition whose sort key
547
+ // tiers decomposed templates above undecomposed ingests, each newest-first
548
+ // under one descending Query. The 60s per-container cache keeps warm hits at
549
+ // zero DynamoDB reads; template writes bust it so new content shows up
550
+ // immediately on the container that wrote it.
551
+ templatesFeedCache = null;
552
+ static TEMPLATES_FEED_TTL_MS = 60_000;
553
+ static TEMPLATES_FEED_MAX = 200;
554
+ bustTemplatesFeedCache() {
555
+ this.templatesFeedCache = null;
556
+ }
557
+ async listTemplatesFeed(input = {}) {
558
+ const limit = Math.max(1, Math.min(input.limit ?? 50, ServerlessRecordsServiceImpl.TEMPLATES_FEED_MAX));
559
+ const now = Date.now();
560
+ if (!this.templatesFeedCache || now - this.templatesFeedCache.fetchedAt > ServerlessRecordsServiceImpl.TEMPLATES_FEED_TTL_MS) {
561
+ let records = await this.queryTemplatesFeed();
562
+ if (records.length === 0) {
563
+ // Feed partition empty — either no templates exist or the gsi2
564
+ // attributes have not been backfilled yet (scripts/
565
+ // backfill-template-feed-gsi.ts). Fall back to the legacy scan with
566
+ // the same tiered ordering so the homepage never goes blank.
567
+ devLog("serverless_records.templates_feed_fallback_scan", {}, "warn");
568
+ records = (await this.scanCollection("template"))
569
+ .sort((a, b) => templateFeedSortKey(b).localeCompare(templateFeedSortKey(a)));
570
+ }
571
+ // Private templates are excluded from the FEED partition at write time,
572
+ // but filter here too so the scan fallback (and any legacy feed items)
573
+ // never leak customer-scoped templates into the public feed.
574
+ records = records
575
+ .filter((record) => record.visibility !== "private")
576
+ .map(normalizeTemplateVisibility);
577
+ this.templatesFeedCache = { records, fetchedAt: now };
578
+ }
579
+ // Search is applied over the cached full feed (not baked into the cache key)
580
+ // so the coarse keyword filter never fights the TTL cache.
581
+ return filterAndRankBySearch(this.templatesFeedCache.records, input.query).slice(0, limit);
582
+ }
583
+ async queryTemplatesFeed() {
584
+ const response = await dynamodb.send(new QueryCommand({
585
+ TableName: requireMainTableName(),
586
+ IndexName: "gsi2",
587
+ KeyConditionExpression: "gsi2pk = :pk",
588
+ ExpressionAttributeValues: {
589
+ ":pk": TEMPLATE_FEED_PARTITION
590
+ },
591
+ ScanIndexForward: false,
592
+ Limit: ServerlessRecordsServiceImpl.TEMPLATES_FEED_MAX
593
+ }));
594
+ return (response.Items ?? []).map((item) => item.data);
595
+ }
596
+ async createReadyPost(input) {
597
+ const timestamp = nowIso();
598
+ return this.putRecord("ready_post", {
599
+ id: input.id ?? createId("post"),
600
+ customerId: input.customerId,
601
+ status: input.status ?? "ready",
602
+ source: input.source ?? "hyperframes",
603
+ compositionId: input.compositionId ?? null,
604
+ tracer: input.tracer ?? null,
605
+ title: input.title ?? null,
606
+ caption: input.caption ?? "",
607
+ pinnedComment: input.pinnedComment ?? null,
608
+ mediaAssets: input.mediaAssets ?? [],
609
+ sharePasswordHash: input.sharePasswordHash ?? null,
610
+ zipStorageKey: input.zipStorageKey ?? null,
611
+ zipPublicUrl: input.zipPublicUrl ?? null,
612
+ archivedAt: input.archivedAt ?? null,
613
+ deletedAt: input.deletedAt ?? null,
614
+ createdAt: input.createdAt ?? timestamp,
615
+ updatedAt: timestamp
616
+ });
617
+ }
618
+ async getReadyPost(postId) {
619
+ return this.getById("ready_post", postId);
620
+ }
621
+ async listReadyPosts(customerId) {
622
+ return this.listByCustomer("ready_post", customerId);
623
+ }
624
+ async archiveReadyPost(customerId, postId) {
625
+ const post = await this.getReadyPost(postId);
626
+ if (!post || post.customerId !== customerId)
627
+ return null;
628
+ return this.putRecord("ready_post", { ...post, status: "archived", archivedAt: nowIso(), updatedAt: nowIso() });
629
+ }
630
+ async deleteReadyPost(customerId, postId) {
631
+ const post = await this.getReadyPost(postId);
632
+ if (!post || post.customerId !== customerId)
633
+ return null;
634
+ const updated = await this.putRecord("ready_post", { ...post, status: "deleted", deletedAt: nowIso(), updatedAt: nowIso() });
635
+ return updated;
636
+ }
637
+ async setReadyPostZip(input) {
638
+ const post = await this.getReadyPost(input.postId);
639
+ if (!post || post.customerId !== input.customerId)
640
+ return null;
641
+ return this.putRecord("ready_post", {
642
+ ...post,
643
+ zipStorageKey: input.zipStorageKey,
644
+ zipPublicUrl: input.zipPublicUrl,
645
+ updatedAt: nowIso()
646
+ });
647
+ }
648
+ async createPostSchedule(input) {
649
+ const timestamp = nowIso();
650
+ return this.putRecord("post_schedule", {
651
+ id: input.id ?? createId("sched"),
652
+ customerId: input.customerId,
653
+ readyPostId: input.readyPostId ?? null,
654
+ managedBy: input.managedBy ?? "vidfarm",
655
+ provider: input.provider ?? "flockposter",
656
+ destinationType: input.destinationType ?? "flockposter",
657
+ destinationId: input.destinationId ?? "",
658
+ scheduledAt: input.scheduledAt ?? timestamp,
659
+ timezone: input.timezone ?? null,
660
+ status: input.status ?? "scheduled",
661
+ providerStatus: input.providerStatus ?? null,
662
+ providerScheduleId: input.providerScheduleId ?? null,
663
+ title: input.title ?? null,
664
+ summary: input.summary ?? null,
665
+ url: input.url ?? null,
666
+ additionalNotes: input.additionalNotes ?? null,
667
+ providerPayload: input.providerPayload ?? {},
668
+ providerResponse: input.providerResponse ?? {},
669
+ lastSyncedAt: input.lastSyncedAt ?? null,
670
+ lastError: input.lastError ?? null,
671
+ cancelledAt: input.cancelledAt ?? null,
672
+ sentAt: input.sentAt ?? null,
673
+ createdAt: input.createdAt ?? timestamp,
674
+ updatedAt: timestamp
675
+ });
676
+ }
677
+ async getPostSchedule(customerId, scheduleId) {
678
+ const schedule = await this.getById("post_schedule", scheduleId);
679
+ return schedule?.customerId === customerId ? schedule : null;
680
+ }
681
+ async listPostSchedulesForPost(customerId, readyPostId) {
682
+ return (await this.listByCustomer("post_schedule", customerId)).filter((schedule) => schedule.readyPostId === readyPostId);
683
+ }
684
+ async listPostSchedulesForCalendar(input) {
685
+ return (await this.listByCustomer("post_schedule", input.customerId)).filter((schedule) => {
686
+ const scheduledAt = String(schedule.scheduledAt ?? "");
687
+ if (input.startTime && scheduledAt < input.startTime)
688
+ return false;
689
+ if (input.endTime && scheduledAt > input.endTime)
690
+ return false;
691
+ return true;
692
+ });
693
+ }
694
+ async updatePostScheduleProvider(input) {
695
+ return this.mergeByIdForCustomer("post_schedule", input.customerId, input.scheduleId, input);
696
+ }
697
+ async markPostScheduleCancelled(input) {
698
+ return this.mergeByIdForCustomer("post_schedule", input.customerId, input.scheduleId, {
699
+ ...input,
700
+ status: "cancelled",
701
+ cancelledAt: nowIso()
702
+ });
703
+ }
704
+ async markPostScheduleFailed(input) {
705
+ return this.mergeByIdForCustomer("post_schedule", input.customerId, input.scheduleId, {
706
+ ...input,
707
+ status: "failed",
708
+ lastError: input.lastError ?? "Schedule failed"
709
+ });
710
+ }
711
+ async upsertExternalPostSchedule(input) {
712
+ const existing = (await this.listByCustomer("post_schedule", input.customerId))
713
+ .find((schedule) => schedule.providerScheduleId === input.providerScheduleId);
714
+ return this.createPostSchedule({ ...existing, ...input, managedBy: "external" });
715
+ }
716
+ async createEmailChannel(input) {
717
+ const timestamp = nowIso();
718
+ return this.putRecord("email_channel", {
719
+ id: input.id ?? createId("email"),
720
+ customerId: input.customerId,
721
+ title: input.title ?? input.email,
722
+ email: input.email,
723
+ note: input.note ?? null,
724
+ avatarUrl: input.avatarUrl ?? null,
725
+ avatarStorageKey: input.avatarStorageKey ?? null,
726
+ status: input.status ?? "unverified",
727
+ verificationTokenHash: input.verificationTokenHash ?? null,
728
+ verificationSentAt: input.verificationSentAt ?? null,
729
+ verifiedAt: input.verifiedAt ?? null,
730
+ createdAt: input.createdAt ?? timestamp,
731
+ updatedAt: timestamp
732
+ });
733
+ }
734
+ async listEmailChannels(customerId) {
735
+ return this.listByCustomer("email_channel", customerId);
736
+ }
737
+ async getEmailChannel(customerId, channelId) {
738
+ const channel = await this.getById("email_channel", channelId);
739
+ return channel?.customerId === customerId ? channel : null;
740
+ }
741
+ async getEmailChannelByEmail(customerId, email) {
742
+ return (await this.listEmailChannels(customerId)).find((channel) => channel.email.toLowerCase() === email.toLowerCase()) ?? null;
743
+ }
744
+ async updateEmailChannel(input) {
745
+ return this.mergeByIdForCustomer("email_channel", input.customerId, input.channelId, input);
746
+ }
747
+ async updateEmailChannelAvatar(input) {
748
+ await this.updateEmailChannel(input);
749
+ }
750
+ async updateEmailChannelVerification(input) {
751
+ return this.updateEmailChannel(input);
752
+ }
753
+ async verifyEmailChannelByTokenHash(tokenHash) {
754
+ const response = await dynamodb.send(new ScanCommand({
755
+ TableName: requireMainTableName(),
756
+ FilterExpression: "entity_type = :type AND verificationTokenHash = :token",
757
+ ExpressionAttributeValues: {
758
+ ":type": "serverless_record#email_channel",
759
+ ":token": tokenHash
760
+ },
761
+ Limit: 1
762
+ }));
763
+ const channel = response.Items?.[0]?.data;
764
+ if (!channel)
765
+ return null;
766
+ return this.putRecord("email_channel", {
767
+ ...channel,
768
+ status: "verified",
769
+ verifiedAt: nowIso(),
770
+ verificationTokenHash: null,
771
+ updatedAt: nowIso()
772
+ });
773
+ }
774
+ async deleteEmailChannel(customerId, channelId) {
775
+ const existing = await this.getEmailChannel(customerId, channelId);
776
+ if (existing)
777
+ await this.deleteById("email_channel", channelId);
778
+ }
779
+ async createUserAttachment(input) {
780
+ const timestamp = nowIso();
781
+ return this.putRecord("user_attachment", {
782
+ id: input.id ?? createId("att"),
783
+ customerId: input.customerId,
784
+ fileName: input.fileName,
785
+ contentType: input.contentType ?? "application/octet-stream",
786
+ sizeBytes: input.sizeBytes ?? 0,
787
+ storageKey: input.storageKey,
788
+ folderPath: normalizeFolder(input.folderPath),
789
+ publicUrl: input.publicUrl ?? null,
790
+ createdAt: input.createdAt ?? timestamp
791
+ });
792
+ }
793
+ async listUserAttachments(customerId) {
794
+ return this.listByCustomer("user_attachment", customerId);
795
+ }
796
+ async getUserAttachment(customerId, attachmentId) {
797
+ const attachment = await this.getById("user_attachment", attachmentId);
798
+ return attachment?.customerId === customerId ? attachment : null;
799
+ }
800
+ async deleteUserAttachment(customerId, attachmentId) {
801
+ const existing = await this.getUserAttachment(customerId, attachmentId);
802
+ if (existing)
803
+ await this.deleteById("user_attachment", attachmentId);
804
+ }
805
+ async createUserTemporaryFile(input) {
806
+ const timestamp = nowIso();
807
+ return this.putRecord("user_temporary_file", {
808
+ id: input.id ?? createId("tmp"),
809
+ customerId: input.customerId,
810
+ fileName: input.fileName,
811
+ contentType: input.contentType ?? "application/octet-stream",
812
+ sizeBytes: input.sizeBytes ?? 0,
813
+ storageKey: input.storageKey,
814
+ folderPath: normalizeFolder(input.folderPath),
815
+ expiresAt: input.expiresAt ?? timestamp,
816
+ createdAt: input.createdAt ?? timestamp
817
+ });
818
+ }
819
+ async listUserTemporaryFiles(customerId) {
820
+ return this.listByCustomer("user_temporary_file", customerId);
821
+ }
822
+ async getUserTemporaryFile(customerId, fileId) {
823
+ const file = await this.getById("user_temporary_file", fileId);
824
+ return file?.customerId === customerId ? file : null;
825
+ }
826
+ async deleteUserTemporaryFile(customerId, fileId) {
827
+ const existing = await this.getUserTemporaryFile(customerId, fileId);
828
+ if (existing)
829
+ await this.deleteById("user_temporary_file", fileId);
830
+ }
831
+ async deleteExpiredUserTemporaryFiles() {
832
+ const now = nowIso();
833
+ const files = (await this.scanCollection("user_temporary_file"))
834
+ .filter(isExpiredUserTemporaryFileRecord);
835
+ const expired = files.filter((file) => file.expiresAt <= now);
836
+ for (const file of expired)
837
+ await this.deleteById("user_temporary_file", file.id);
838
+ return expired;
839
+ }
840
+ async createUserFileFolder(customerId, scope, folderPath) {
841
+ const normalized = normalizeFolder(folderPath);
842
+ await this.putRecord("user_file_folder", {
843
+ id: `${customerId}:${scope}:${normalized}`,
844
+ customerId,
845
+ scope,
846
+ folderPath: normalized
847
+ });
848
+ }
849
+ async listUserFileFolders(customerId, scope) {
850
+ return (await this.listByCustomer("user_file_folder", customerId))
851
+ .filter((folder) => folder.scope === scope)
852
+ .map((folder) => String(folder.folderPath));
853
+ }
854
+ async moveUserFileFolder(customerId, scope, fromFolder, toFolder) {
855
+ await this.deleteUserFileFolder(customerId, scope, fromFolder);
856
+ await this.createUserFileFolder(customerId, scope, toFolder);
857
+ }
858
+ async deleteUserFileFolder(customerId, scope, folderPath) {
859
+ await this.deleteById("user_file_folder", `${customerId}:${scope}:${normalizeFolder(folderPath)}`);
860
+ }
861
+ async moveUserAttachmentFolder(customerId, fromFolder, toFolder) {
862
+ await this.moveCollectionFolder("user_attachment", customerId, fromFolder, toFolder);
863
+ }
864
+ async deleteUserAttachmentFolder(customerId, folderPath) {
865
+ return this.deleteCollectionFolder("user_attachment", customerId, folderPath);
866
+ }
867
+ async moveUserTemporaryFolder(customerId, fromFolder, toFolder) {
868
+ await this.moveCollectionFolder("user_temporary_file", customerId, fromFolder, toFolder);
869
+ }
870
+ async deleteUserTemporaryFolder(customerId, folderPath) {
871
+ return this.deleteCollectionFolder("user_temporary_file", customerId, folderPath);
872
+ }
873
+ async queueWebhookDelivery(input) {
874
+ const timestamp = nowIso();
875
+ await this.putRecord("webhook_delivery", {
876
+ id: typeof input.id === "string" ? input.id : createId("webhook"),
877
+ customerId: typeof input.customerId === "string" ? input.customerId : undefined,
878
+ jobId: input.jobId,
879
+ destinationUrl: input.destinationUrl,
880
+ destination_url: input.destinationUrl,
881
+ eventType: input.eventType,
882
+ event_type: input.eventType,
883
+ payload: input.payload,
884
+ payload_json: JSON.stringify(input.payload ?? {}),
885
+ status: "pending",
886
+ attemptCount: 0,
887
+ attempt_count: 0,
888
+ nextAttemptAt: timestamp,
889
+ next_attempt_at: timestamp,
890
+ createdAt: timestamp,
891
+ updatedAt: timestamp
892
+ });
893
+ }
894
+ async listPendingWebhookDeliveries(limit = 100) {
895
+ const response = await dynamodb.send(new ScanCommand({
896
+ TableName: requireMainTableName(),
897
+ FilterExpression: "entity_type = :type AND #data.#status = :status",
898
+ ExpressionAttributeNames: { "#data": "data", "#status": "status" },
899
+ ExpressionAttributeValues: {
900
+ ":type": "serverless_record#webhook_delivery",
901
+ ":status": "pending"
902
+ },
903
+ Limit: limit
904
+ }));
905
+ return (response.Items ?? []).map((item) => item.data);
906
+ }
907
+ async markWebhookDelivery(input) {
908
+ const existing = await this.getById("webhook_delivery", input.id);
909
+ if (!existing)
910
+ return;
911
+ await this.putRecord("webhook_delivery", {
912
+ ...existing,
913
+ ...input,
914
+ attempt_count: input.attemptCount ?? input.attempt_count ?? existing.attempt_count,
915
+ next_attempt_at: input.nextAttemptAt ?? input.next_attempt_at ?? existing.next_attempt_at,
916
+ updatedAt: nowIso()
917
+ });
918
+ }
919
+ async listChatThreadMetas(input) {
920
+ const limit = Math.max(1, Math.min(input.limit ?? 100, 500));
921
+ return (await this.listByCustomer("chat_thread", input.customerId))
922
+ .filter((thread) => !input.templateId || thread.templateId === input.templateId)
923
+ .sort((a, b) => String(b.updatedAt ?? "").localeCompare(String(a.updatedAt ?? "")))
924
+ .slice(0, limit);
925
+ }
926
+ async getChatThreadMeta(customerId, threadId) {
927
+ const thread = await this.getById("chat_thread", threadId);
928
+ return thread?.customerId === customerId ? thread : null;
929
+ }
930
+ async appendChatThreadMessages(input) {
931
+ const existing = await this.getChatThreadMeta(input.customerId, input.threadId);
932
+ const timestamp = nowIso();
933
+ const thread = {
934
+ id: input.threadId,
935
+ customerId: input.customerId,
936
+ templateId: input.templateId,
937
+ title: input.title,
938
+ storagePrefix: input.storagePrefix,
939
+ tracers: input.tracers,
940
+ messageCount: (existing?.messageCount ?? 0) + input.messageCountDelta,
941
+ lastMessageAt: input.lastMessageAt,
942
+ hiddenFromView: existing?.hiddenFromView ?? false,
943
+ archivedAt: existing?.archivedAt ?? null,
944
+ createdAt: existing?.createdAt ?? timestamp,
945
+ updatedAt: timestamp,
946
+ messages: []
947
+ };
948
+ return this.putRecord("chat_thread", thread);
949
+ }
950
+ async updateChatThreadState(input) {
951
+ const existing = await this.getChatThreadMeta(input.customerId, input.threadId);
952
+ if (!existing)
953
+ return null;
954
+ return this.putRecord("chat_thread", {
955
+ ...existing,
956
+ title: input.title ?? existing.title,
957
+ tracers: input.tracers ?? existing.tracers,
958
+ hiddenFromView: input.hiddenFromView ?? existing.hiddenFromView,
959
+ archivedAt: input.archivedAt === undefined ? existing.archivedAt : input.archivedAt,
960
+ updatedAt: nowIso()
961
+ });
962
+ }
963
+ async deleteChatThread(customerId, threadId) {
964
+ const existing = await this.getChatThreadMeta(customerId, threadId);
965
+ if (!existing)
966
+ return null;
967
+ await this.deleteById("chat_thread", threadId);
968
+ return existing;
969
+ }
970
+ async createTemplateSource(input) {
971
+ return this.putRecord("template_source", { id: input.id ?? createId("tsrc"), ...input });
972
+ }
973
+ async updateTemplateSource(input) {
974
+ const existing = await this.getById("template_source", String(input.id));
975
+ return this.putRecord("template_source", { ...existing, ...input, updatedAt: nowIso() });
976
+ }
977
+ async getTemplateSource(sourceId) {
978
+ return this.getById("template_source", sourceId);
979
+ }
980
+ async getTemplateSourceByTemplateId(templateId) {
981
+ return this.findByScan("template_source", (item) => item.templateId === templateId);
982
+ }
983
+ async getTemplateSourceBySlugId(slugId) {
984
+ return this.findByScan("template_source", (item) => item.slugId === slugId);
985
+ }
986
+ async getTemplateSourceByLocation(repoUrl, branch, templateModulePath) {
987
+ return this.findByScan("template_source", (item) => item.repoUrl === repoUrl && item.branch === branch && item.templateModulePath === templateModulePath);
988
+ }
989
+ async listTemplateSources() {
990
+ return this.scanCollection("template_source");
991
+ }
992
+ async createTemplateRelease(input) {
993
+ return this.putRecord("template_release", { id: input.id ?? createId("trel"), ...input });
994
+ }
995
+ async getTemplateRelease(releaseId) {
996
+ return this.getById("template_release", releaseId);
997
+ }
998
+ async getTemplateReleaseBySourceAndCommit(sourceId, commitSha) {
999
+ return this.findByScan("template_release", (item) => item.sourceId === sourceId && item.commitSha === commitSha);
1000
+ }
1001
+ async getLatestTemplateReleaseForSource(sourceId) {
1002
+ return (await this.scanCollection("template_release"))
1003
+ .filter((release) => release.sourceId === sourceId)
1004
+ .sort((a, b) => String(b.createdAt ?? "").localeCompare(String(a.createdAt ?? "")))[0] ?? null;
1005
+ }
1006
+ async listTemplateReleases() {
1007
+ return this.scanCollection("template_release");
1008
+ }
1009
+ async listActiveTemplateReleases() {
1010
+ const releases = [];
1011
+ let cursor = null;
1012
+ do {
1013
+ const page = await this.listActiveTemplateReleasePage({ cursor, limit: 500 });
1014
+ releases.push(...page.items);
1015
+ cursor = page.nextCursor;
1016
+ } while (cursor);
1017
+ return releases;
1018
+ }
1019
+ async listActiveTemplateReleasePage(input = {}) {
1020
+ const limit = Math.max(1, Math.min(input.limit ?? 100, 500));
1021
+ const releases = [];
1022
+ let ExclusiveStartKey = decodeDynamoCursor(input.cursor);
1023
+ do {
1024
+ const response = await dynamodb.send(new QueryCommand({
1025
+ TableName: requireMainTableName(),
1026
+ IndexName: "gsi1",
1027
+ KeyConditionExpression: "gsi1pk = :pk",
1028
+ FilterExpression: "#data.#record_kind = :kind",
1029
+ ExpressionAttributeNames: {
1030
+ "#data": "data",
1031
+ "#record_kind": "recordKind"
1032
+ },
1033
+ ExpressionAttributeValues: {
1034
+ ":pk": collectionPk("active_template"),
1035
+ ":kind": "active_template_release"
1036
+ },
1037
+ ScanIndexForward: false,
1038
+ Limit: Math.max(1, limit - releases.length),
1039
+ ExclusiveStartKey
1040
+ }));
1041
+ releases.push(...(response.Items ?? []).map((item) => item.data));
1042
+ ExclusiveStartKey = response.LastEvaluatedKey;
1043
+ } while (ExclusiveStartKey && releases.length < limit);
1044
+ return {
1045
+ items: releases,
1046
+ nextCursor: encodeDynamoCursor(ExclusiveStartKey)
1047
+ };
1048
+ }
1049
+ async getActiveTemplateReleaseByTemplateId(templateId) {
1050
+ const direct = await this.getById("active_template", templateId);
1051
+ if (direct?.recordKind === "active_template_release") {
1052
+ return direct;
1053
+ }
1054
+ const alias = await this.getById("active_template", activeTemplateSlugAliasId(templateId));
1055
+ if (alias?.recordKind === "active_template_slug_alias" && typeof alias.templateId === "string") {
1056
+ const release = await this.getById("active_template", alias.templateId);
1057
+ return release?.recordKind === "active_template_release"
1058
+ ? release
1059
+ : null;
1060
+ }
1061
+ return null;
1062
+ }
1063
+ async putActiveTemplateRelease(release) {
1064
+ const slugId = release.templateSnapshot?.slugId;
1065
+ const data = await this.putRecord("active_template", {
1066
+ ...release,
1067
+ id: release.templateId,
1068
+ recordKind: "active_template_release",
1069
+ updatedAt: release.activatedAt ?? release.updatedAt ?? nowIso()
1070
+ });
1071
+ if (slugId) {
1072
+ await this.putRecord("active_template", {
1073
+ id: activeTemplateSlugAliasId(slugId),
1074
+ recordKind: "active_template_slug_alias",
1075
+ templateId: release.templateId,
1076
+ slugId,
1077
+ releaseId: release.id,
1078
+ updatedAt: release.activatedAt ?? release.updatedAt ?? nowIso()
1079
+ });
1080
+ }
1081
+ return data;
1082
+ }
1083
+ async updateTemplateReleaseStatus(input) {
1084
+ const existing = await this.getById("template_release", input.releaseId);
1085
+ if (existing)
1086
+ await this.putRecord("template_release", { ...existing, ...input, id: input.releaseId, updatedAt: nowIso() });
1087
+ }
1088
+ async clearActiveTemplateReleases(templateId) {
1089
+ const active = await this.getActiveTemplateReleaseByTemplateId(templateId);
1090
+ if (active) {
1091
+ await this.putRecord("template_release", { ...active, id: String(active.id), status: "approved", activatedAt: null, updatedAt: nowIso() });
1092
+ await this.deleteById("active_template", active.templateId);
1093
+ if (active.templateSnapshot?.slugId) {
1094
+ await this.deleteById("active_template", activeTemplateSlugAliasId(active.templateSnapshot.slugId));
1095
+ }
1096
+ }
1097
+ }
1098
+ async call(method, args) {
1099
+ const collection = METHOD_COLLECTIONS[method];
1100
+ if (!collection) {
1101
+ throw new Error(`Serverless record method ${method} is not implemented.`);
1102
+ }
1103
+ devLog("serverless_records.dynamic_call", { method, collection });
1104
+ throw new Error(`Serverless record method ${method} requires a typed implementation.`);
1105
+ }
1106
+ async mergeByIdForCustomer(collection, customerId, id, patch) {
1107
+ const existing = await this.getById(collection, id);
1108
+ if (!existing || existing.customerId !== customerId)
1109
+ return null;
1110
+ return this.putRecord(collection, { ...existing, ...patch, id, customerId, updatedAt: nowIso() });
1111
+ }
1112
+ async moveCollectionFolder(collection, customerId, fromFolder, toFolder) {
1113
+ for (const record of await this.listByCustomer(collection, customerId)) {
1114
+ if (normalizeFolder(String(record.folderPath ?? "")) === normalizeFolder(fromFolder)) {
1115
+ await this.putRecord(collection, { ...record, folderPath: normalizeFolder(toFolder), updatedAt: nowIso() });
1116
+ }
1117
+ }
1118
+ }
1119
+ async deleteCollectionFolder(collection, customerId, folderPath) {
1120
+ const deleted = [];
1121
+ for (const record of await this.listByCustomer(collection, customerId)) {
1122
+ if (normalizeFolder(String(record.folderPath ?? "")) === normalizeFolder(folderPath)) {
1123
+ await this.deleteById(collection, String(record.id));
1124
+ deleted.push(record);
1125
+ }
1126
+ }
1127
+ return deleted;
1128
+ }
1129
+ async findByScan(collection, predicate) {
1130
+ return (await this.scanCollection(collection)).find(predicate) ?? null;
1131
+ }
1132
+ async scanCollection(collection) {
1133
+ const results = [];
1134
+ let exclusiveStartKey;
1135
+ // DDB Scan with FilterExpression only reads one page at a time; matching
1136
+ // items later in the table are silently dropped unless you follow
1137
+ // LastEvaluatedKey. Paginate until DDB stops returning a cursor.
1138
+ do {
1139
+ const response = await dynamodb.send(new ScanCommand({
1140
+ TableName: requireMainTableName(),
1141
+ FilterExpression: "entity_type = :type",
1142
+ ExpressionAttributeValues: {
1143
+ ":type": entityType(collection)
1144
+ },
1145
+ ExclusiveStartKey: exclusiveStartKey
1146
+ }));
1147
+ for (const item of response.Items ?? []) {
1148
+ results.push(item.data);
1149
+ }
1150
+ exclusiveStartKey = response.LastEvaluatedKey;
1151
+ } while (exclusiveStartKey);
1152
+ return results;
1153
+ }
1154
+ async listByCustomer(collection, customerId) {
1155
+ return this.listByPartition(collection, customerCollectionPk(collection, customerId));
1156
+ }
1157
+ async listByPartition(_collection, partitionKey) {
1158
+ const response = await dynamodb.send(new QueryCommand({
1159
+ TableName: requireMainTableName(),
1160
+ IndexName: "gsi1",
1161
+ KeyConditionExpression: "gsi1pk = :pk",
1162
+ ExpressionAttributeValues: {
1163
+ ":pk": partitionKey
1164
+ },
1165
+ ScanIndexForward: false
1166
+ }));
1167
+ return (response.Items ?? []).map((item) => item.data);
1168
+ }
1169
+ async getById(collection, id) {
1170
+ const response = await dynamodb.send(new GetCommand({
1171
+ TableName: requireMainTableName(),
1172
+ Key: recordKey(collection, id)
1173
+ }));
1174
+ return response.Item?.data ?? null;
1175
+ }
1176
+ async deleteById(collection, id) {
1177
+ await dynamodb.send(new DeleteCommand({
1178
+ TableName: requireMainTableName(),
1179
+ Key: recordKey(collection, id)
1180
+ }));
1181
+ devLog("serverless_records.deleted", { collection, id });
1182
+ }
1183
+ async putRecord(collection, record, options) {
1184
+ const timestamp = nowIso();
1185
+ const id = typeof record.id === "string" && record.id ? record.id : createId("rec");
1186
+ const data = {
1187
+ ...record,
1188
+ id,
1189
+ createdAt: record.createdAt ?? timestamp,
1190
+ updatedAt: record.updatedAt ?? timestamp
1191
+ };
1192
+ const gsi1pk = options?.partitionKey
1193
+ ?? (data.customerId ? customerCollectionPk(collection, String(data.customerId)) : collectionPk(collection));
1194
+ const item = {
1195
+ ...recordKey(collection, id),
1196
+ entity_type: entityType(collection),
1197
+ gsi1pk,
1198
+ gsi1sk: `${String(data.updatedAt)}#${id}`,
1199
+ data
1200
+ };
1201
+ // Private templates stay out of the global FEED partition (sparse gsi2):
1202
+ // they are only reachable via their owner's gsi1 customer partition.
1203
+ if (collection === "template" && data.visibility !== "private") {
1204
+ item.gsi2pk = TEMPLATE_FEED_PARTITION;
1205
+ item.gsi2sk = templateFeedSortKey(data);
1206
+ }
1207
+ await dynamodb.send(new PutCommand({
1208
+ TableName: requireMainTableName(),
1209
+ Item: item
1210
+ }));
1211
+ devLog("serverless_records.put", {
1212
+ collection,
1213
+ id,
1214
+ customer_id: data.customerId ?? null,
1215
+ partition_key: gsi1pk
1216
+ });
1217
+ return data;
1218
+ }
1219
+ }
1220
+ function forkPartitionPk(collection, forkId) {
1221
+ return `FORK#${forkId}#${collection}`;
1222
+ }
1223
+ // Records written before the visibility/notes fields existed have no such
1224
+ // attributes — they are all the seeded public catalog, so default to public.
1225
+ function normalizeTemplateVisibility(record) {
1226
+ return {
1227
+ ...record,
1228
+ visibility: record.visibility ?? "public",
1229
+ notes: record.notes ?? null
1230
+ };
1231
+ }
1232
+ function normalizeInspirationVisibility(record) {
1233
+ return {
1234
+ ...record,
1235
+ visibility: record.visibility ?? "public",
1236
+ notes: record.notes ?? null
1237
+ };
1238
+ }
1239
+ function contentSearchHaystack(record) {
1240
+ const parts = [];
1241
+ const push = (value) => {
1242
+ if (typeof value === "string") {
1243
+ if (value.trim())
1244
+ parts.push(value.toLowerCase());
1245
+ }
1246
+ else if (Array.isArray(value)) {
1247
+ for (const item of value) {
1248
+ if (typeof item === "string" && item.trim())
1249
+ parts.push(item.toLowerCase());
1250
+ }
1251
+ }
1252
+ };
1253
+ push(record.title);
1254
+ push(record.trendTagline);
1255
+ push(record.notes);
1256
+ push(record.viralDna);
1257
+ push(record.searchSummary);
1258
+ push(record.promotions);
1259
+ push(record.keywords);
1260
+ return parts.join("  ");
1261
+ }
1262
+ export function tokenizeSearchQuery(query) {
1263
+ if (!query)
1264
+ return [];
1265
+ return Array.from(new Set(query
1266
+ .toLowerCase()
1267
+ .split(/[^a-z0-9]+/i)
1268
+ .map((token) => token.trim())
1269
+ .filter((token) => token.length >= 2)));
1270
+ }
1271
+ // Exported predicate for callers that interleave records outside the feed
1272
+ // methods (e.g. a logged-in user's own templates) and want them filtered by the
1273
+ // same coarse keyword rule. An empty/whitespace query matches everything.
1274
+ export function recordMatchesSearchQuery(record, query) {
1275
+ const tokens = tokenizeSearchQuery(query);
1276
+ return tokens.length === 0 || scoreContentMatch(record, tokens) > 0;
1277
+ }
1278
+ function scoreContentMatch(record, tokens) {
1279
+ if (tokens.length === 0)
1280
+ return 1;
1281
+ const haystack = contentSearchHaystack(record);
1282
+ let score = 0;
1283
+ for (const token of tokens) {
1284
+ if (haystack.includes(token))
1285
+ score += 1;
1286
+ }
1287
+ return score;
1288
+ }
1289
+ // Filter to records matching at least one query token, ranked by token-hit
1290
+ // count (descending), preserving the caller's incoming order on ties. Returns
1291
+ // the input unchanged when the query is empty.
1292
+ function filterAndRankBySearch(records, query) {
1293
+ const tokens = tokenizeSearchQuery(query);
1294
+ if (tokens.length === 0)
1295
+ return records;
1296
+ return records
1297
+ .map((record, index) => ({ record, index, score: scoreContentMatch(record, tokens) }))
1298
+ .filter((entry) => entry.score > 0)
1299
+ .sort((a, b) => (b.score - a.score) || (a.index - b.index))
1300
+ .map((entry) => entry.record);
1301
+ }
1302
+ // Global discover-feed partition on gsi2 (job records use gsi2 under
1303
+ // CUSTOMER#... partitions — no overlap). The sort key tiers the feed:
1304
+ // "1#createdAt#id" (decomposed, has defaultForkId) sorts above
1305
+ // "0#createdAt#id" (undecomposed ingest) in a descending query, giving
1306
+ // decomposed templates newest-first, then fresh ingests newest-first.
1307
+ export const TEMPLATE_FEED_PARTITION = "FEED#template";
1308
+ export function templateFeedSortKey(record) {
1309
+ const tier = record.defaultForkId ? "1" : "0";
1310
+ return `${tier}#${String(record.createdAt ?? "")}#${String(record.id ?? "")}`;
1311
+ }
1312
+ function requireMainTableName() {
1313
+ // In local mode the shim keys files by this name only, so a fixed default is
1314
+ // fine and the env var is not required.
1315
+ if (config.RECORDS_DRIVER === "local") {
1316
+ return config.VIDFARM_SERVERLESS_MAIN_TABLE_NAME || "vidfarm-local-main";
1317
+ }
1318
+ if (!config.VIDFARM_SERVERLESS_MAIN_TABLE_NAME) {
1319
+ throw new Error("Serverless record store requires VIDFARM_SERVERLESS_MAIN_TABLE_NAME.");
1320
+ }
1321
+ return config.VIDFARM_SERVERLESS_MAIN_TABLE_NAME;
1322
+ }
1323
+ function recordKey(collection, id) {
1324
+ return {
1325
+ pk: `${collectionPk(collection)}#${id}`,
1326
+ sk: "META"
1327
+ };
1328
+ }
1329
+ function collectionPk(collection) {
1330
+ return `SERVERLESS_RECORD#${collection}`;
1331
+ }
1332
+ function activeTemplateSlugAliasId(slugId) {
1333
+ return `slug#${slugId}`;
1334
+ }
1335
+ function encodeDynamoCursor(value) {
1336
+ return value ? Buffer.from(JSON.stringify(value), "utf8").toString("base64url") : null;
1337
+ }
1338
+ function decodeDynamoCursor(raw) {
1339
+ if (!raw) {
1340
+ return undefined;
1341
+ }
1342
+ try {
1343
+ const parsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
1344
+ return parsed && typeof parsed === "object" ? parsed : undefined;
1345
+ }
1346
+ catch {
1347
+ return undefined;
1348
+ }
1349
+ }
1350
+ function customerCollectionPk(collection, customerId) {
1351
+ return `CUSTOMER#${customerId}#${collection}`;
1352
+ }
1353
+ function entityType(collection) {
1354
+ return `serverless_record#${collection}`;
1355
+ }
1356
+ function normalizeFolder(value) {
1357
+ if (typeof value !== "string")
1358
+ return "";
1359
+ return value.trim().replace(/^\/+|\/+$/g, "");
1360
+ }
1361
+ function isExpiredUserTemporaryFileRecord(record) {
1362
+ return typeof record.id === "string"
1363
+ && record.id.length > 0
1364
+ && typeof record.expiresAt === "string"
1365
+ && record.expiresAt.length > 0;
1366
+ }
1367
+ function itemToCustomer(item) {
1368
+ return {
1369
+ id: String(item.customer_id),
1370
+ email: String(item.email),
1371
+ name: typeof item.name === "string" ? item.name : null,
1372
+ defaultWebhookUrl: typeof item.default_webhook_url === "string" ? item.default_webhook_url : null,
1373
+ isDeveloper: Boolean(item.is_developer),
1374
+ isAgency: Boolean(item.is_agency),
1375
+ isPaidPlan: Boolean(item.is_paid_plan),
1376
+ planTier: item.plan_tier === "standalone" || item.plan_tier === "agency" ? item.plan_tier : "client",
1377
+ about: typeof item.about === "string" ? item.about : null,
1378
+ groupchatUrl: typeof item.groupchat_url === "string" ? item.groupchat_url : null,
1379
+ flockposterApiKey: typeof item.flockposter_api_key === "string" ? item.flockposter_api_key : null,
1380
+ createdAt: typeof item.created_at === "string" ? item.created_at : nowIso(),
1381
+ updatedAt: typeof item.updated_at === "string" ? item.updated_at : nowIso()
1382
+ };
1383
+ }
1384
+ export const serverlessRecords = new ServerlessRecordsServiceImpl();
1385
+ //# sourceMappingURL=serverless-records.js.map