@mevdragon/vidfarm-devcli 0.20.6 → 0.20.9

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