@fabriktor/fx 0.0.151 → 0.0.153

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.
@@ -3,19 +3,24 @@
3
3
  // Licensed under the Apache License, Version 2.0 (the "License"); you may
4
4
  // not use this file except in compliance with the License. You may obtain
5
5
  // a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
6
- import { All, ColStorageFeed, PullTypeKeepAlive, ZeroID, } from "@fabriktor/schema";
7
- import { errResolveFailed, errValidation } from "../core/err.js";
6
+ import { All, PullTypeBatch, PullTypeSingle, PullTypeResearch, PullTypeReplies, PullTypeReferences, PullTypeReactions, PullTypeProfilePosts, PullTypeProfileReplies, PullTypeProfileMedias, FeedLike, FeedDislike, FeedReport, PullTypeKeepAlive, ZeroID, } from "@fabriktor/schema";
7
+ import { isClientErr, queryParams, } from "@fabriktor/client";
8
+ import { errResolveFailed, errValidation, errUnconfirmedMutation, } from "../core/err.js";
8
9
  import { requiredOperationID, resolvedID } from "../core/id.js";
9
- import { newPullSession, } from "../core/pull.js";
10
- import { runSearch, } from "../core/search.js";
11
- import { StorageUploadModeration, } from "../storage/storage.js";
10
+ import { startPullSession, PullSessionScope, } from "../core/pull.js";
11
+ import { runOwnerSearch, } from "../core/search.js";
12
+ import { validateStorageUploadFiles, } from "../storage/storage.js";
13
+ import { createEvent, EventBus } from "../core/events.js";
14
+ import { prepareFeedTextPost, validateFeedVisibility, feedPostInput, } from "./validation.js";
15
+ export * from "./validation.js";
16
+ export * from "./media.js";
17
+ import { saveFeedPost, newFeedPostSaveState, } from "./media.js";
18
+ export const FeedEvents = { Changed: createEvent() };
12
19
  const newFeedSessionToken = "";
13
20
  const resourceFeedSessionToken = "feed_session_token";
14
- const resourceFeedPostID = "feed_post_id";
15
21
  const resourceFeedPost = "feed_post";
16
22
  const resourceFeedReaction = "feed_reaction";
17
23
  const resourceFeedFavorite = "feed_favorite";
18
- const resourceFeedObject = "feed_object";
19
24
  const msgKeepAliveIntervalInvalid = "Keep-alive interval must be a finite number greater than zero.";
20
25
  const msgFeedSessionClosed = "Feed session is closed.";
21
26
  const msgFeedPullInProgress = "A feed pull is already in progress.";
@@ -36,10 +41,6 @@ const msgFeedReplyAlreadyExists = "A reply already exists for this post.";
36
41
  const msgFeedReplyMissing = "No reply exists for this post.";
37
42
  const msgFeedReferenceAlreadyExists = "A reference already exists for this post.";
38
43
  const msgFeedReferenceMissing = "No reference exists for this post.";
39
- const msgFeedObjectsMissing = "Storage download did not return any feed objects.";
40
- const msgFeedObjectURLMissing = "Storage object does not contain a public URL.";
41
- const msgFeedObjectMIMEMissing = "Storage object does not contain a MIME prefix.";
42
- const msgFeedObjectCaptionMissing = "Storage object does not contain a caption embedding.";
43
44
  export const FeedAction = {
44
45
  Reaction: "reaction",
45
46
  Favorite: "favorite",
@@ -56,6 +57,9 @@ export const FeedMediaPostStatus = {
56
57
  PatchFailed: "patch_failed",
57
58
  };
58
59
  export class FeedFX {
60
+ sessions = new PullSessionScope();
61
+ events;
62
+ mutating = false;
59
63
  feed;
60
64
  storage_fx;
61
65
  keep_alive_interval_ms;
@@ -66,6 +70,7 @@ export class FeedFX {
66
70
  constructor(opts) {
67
71
  validateFeedFXOptions(opts);
68
72
  this.feed = opts.feed;
73
+ this.events = opts.events ?? new EventBus();
69
74
  this.storage_fx = opts.storage_fx;
70
75
  this.keep_alive_interval_ms = opts.keep_alive_interval_ms;
71
76
  this.running_actions = new Map();
@@ -73,24 +78,231 @@ export class FeedFX {
73
78
  this.removed_actions = new Map();
74
79
  this.action_ids = new Map();
75
80
  }
81
+ reset() {
82
+ this.sessions.reset();
83
+ this.running_actions.clear();
84
+ this.completed_actions.clear();
85
+ this.removed_actions.clear();
86
+ this.action_ids.clear();
87
+ }
76
88
  async createFeedPost(opts) {
77
- const out = await this.feed.insertOneFeedPost(opts);
78
- const id = requiredOperationID(out, msgFeedCreatedPostIDMissing);
79
- return await this.findFeedPost(id);
89
+ return this.mutate(async (signal) => {
90
+ let id = resolvedID(opts.created_id);
91
+ if (!id) {
92
+ const input = prepareFeedTextPost(opts.in);
93
+ const parent_id = resolvedID(input.reply_to_post_id) ??
94
+ resolvedID(input.referenced_post_id);
95
+ if (parent_id) {
96
+ const parent = await this.requireFeedPost(parent_id, signal);
97
+ const existing = resolvedID(input.reply_to_post_id)
98
+ ? parent.self.reply_id
99
+ : parent.self.reference_id;
100
+ // A previous uncertain attempt may already have created the only child.
101
+ if (resolvedID(existing)) {
102
+ const child = await this.findFeedPost(existing, signal);
103
+ if (child.text.text !== input.text.text ||
104
+ child.visibility !== input.visibility ||
105
+ child.reply_to_post_id !== input.reply_to_post_id ||
106
+ child.referenced_post_id !== input.referenced_post_id ||
107
+ child.has_media ||
108
+ child.is_uploading ||
109
+ child.objects.length)
110
+ throw errValidation({
111
+ field: "text",
112
+ msg: "A reply or reference already exists.",
113
+ });
114
+ id = existing;
115
+ opts.on_created?.(id);
116
+ }
117
+ }
118
+ if (!id) {
119
+ if (opts.attempted)
120
+ throw errUnconfirmedMutation();
121
+ opts.before_request?.();
122
+ let out;
123
+ try {
124
+ out = await this.feed.insertOneFeedPost({
125
+ in: input,
126
+ signal,
127
+ idempotency_key: opts.idempotency_key,
128
+ });
129
+ }
130
+ catch (error) {
131
+ if (isClientErr(error) &&
132
+ (error.status === 400 || error.status === 422))
133
+ opts.on_rejected?.();
134
+ throw error;
135
+ }
136
+ id = requiredOperationID(out, msgFeedCreatedPostIDMissing);
137
+ opts.on_created?.(id);
138
+ }
139
+ }
140
+ signal.throwIfAborted();
141
+ const post = await this.requireFeedPost(id, signal);
142
+ this.changed(post.id);
143
+ if (resolvedID(post.reply_to_post_id))
144
+ this.changed(post.reply_to_post_id);
145
+ if (resolvedID(post.referenced_post_id))
146
+ this.changed(post.referenced_post_id);
147
+ return post;
148
+ }, opts.signal);
80
149
  }
81
150
  async replaceFeedPost(opts) {
82
- await this.feed.replaceOneFeedPost(opts);
83
- return await this.findFeedPost(opts.id);
151
+ return this.mutate(async (signal) => {
152
+ const current = await this.findFeedPost(opts.id, signal);
153
+ this.checkVersion(current, opts.expected);
154
+ // Text editing preserves link identity and cannot overwrite stored media.
155
+ const input = prepareFeedTextPost({
156
+ ...feedPostInput(current),
157
+ ...opts.in,
158
+ reply_to_post_id: current.reply_to_post_id,
159
+ referenced_post_id: current.referenced_post_id,
160
+ });
161
+ await this.feed.replaceOneFeedPost({ id: opts.id, in: input, signal });
162
+ signal.throwIfAborted();
163
+ this.changed(opts.id);
164
+ return this.requireFeedPost(opts.id, signal);
165
+ }, opts.signal);
84
166
  }
85
167
  async searchFeedPosts(opts) {
86
- return await runSearch({
87
- search: (searchOpts) => this.feed.searchManyFeedPosts(searchOpts),
88
- query: {},
89
- q: opts.q,
90
- page: opts.page,
91
- per_page: opts.per_page,
168
+ return runOwnerSearch({
169
+ ...opts,
170
+ search: (input) => this.feed.searchManyFeedPosts(input),
171
+ });
172
+ }
173
+ async readOwnReaction(opts) {
174
+ const signal = this.sessions.signal(opts.signal);
175
+ signal.throwIfAborted();
176
+ const id = requireFeedID(opts.id);
177
+ try {
178
+ const out = await this.feed.findOneFeedReaction({ id, signal });
179
+ signal.throwIfAborted();
180
+ return out.value ?? null;
181
+ }
182
+ catch (error) {
183
+ signal.throwIfAborted();
184
+ if (isClientErr(error) && (error.status === 403 || error.status === 404))
185
+ return null;
186
+ throw error;
187
+ }
188
+ }
189
+ async readFeedPost(opts) {
190
+ const signal = this.sessions.signal(opts.signal);
191
+ signal.throwIfAborted();
192
+ const id = requireFeedID(opts.id);
193
+ try {
194
+ const out = await this.feed.pull({
195
+ contact_ids: [],
196
+ session_token: "",
197
+ is_new_session: true,
198
+ pull_type: PullTypeSingle,
199
+ entity_id: id,
200
+ research: "",
201
+ signal,
202
+ });
203
+ signal.throwIfAborted();
204
+ return out.value?.posts.find((post) => post.id === id) ?? null;
205
+ }
206
+ catch (error) {
207
+ signal.throwIfAborted();
208
+ if (isClientErr(error) && (error.status === 403 || error.status === 404))
209
+ return null;
210
+ throw error;
211
+ }
212
+ }
213
+ async listSavedFeedPosts(opts) {
214
+ const signal = this.sessions.signal(opts.signal);
215
+ signal.throwIfAborted();
216
+ const out = await this.feed.queryFeedFavorites({
217
+ query: queryParams({
218
+ owner_id: requireFeedID(opts.owner_id),
219
+ }),
220
+ signal,
221
+ });
222
+ signal.throwIfAborted();
223
+ const result = [];
224
+ // This backend collection exposes an unpaged query, not a search route.
225
+ for (const favorite of out.value ?? []) {
226
+ const post = await this.readFeedPost({ id: favorite.post_id, signal });
227
+ signal.throwIfAborted();
228
+ result.push({ ...favorite, post });
229
+ }
230
+ return result;
231
+ }
232
+ async removeSavedFeedPost(opts) {
233
+ return this.mutate(async (signal) => {
234
+ const out = await this.feed.findOneFeedFavorite({
235
+ id: requireFeedID(opts.id),
236
+ signal,
237
+ });
238
+ signal.throwIfAborted();
239
+ if (!out.value || out.value.owner_id !== requireFeedID(opts.owner_id))
240
+ throw errValidation({ msg: "Favorite is unavailable." });
241
+ await this.feed.deleteOneFeedFavorite({ id: opts.id, signal });
242
+ signal.throwIfAborted();
243
+ this.changed(out.value.post_id);
244
+ });
245
+ }
246
+ async deleteFeedPost(opts) {
247
+ return this.mutate(async (signal) => {
248
+ const current = await this.findFeedPost(requireFeedID(opts.record.id), signal);
249
+ this.checkVersion(current, {
250
+ ...opts.record,
251
+ owner_id: requireFeedID(opts.owner_id),
252
+ });
253
+ await this.feed.deleteOneFeedPost({ id: current.id, signal });
254
+ signal.throwIfAborted();
255
+ this.changed(current.id);
256
+ if (resolvedID(current.reply_to_post_id))
257
+ this.changed(current.reply_to_post_id);
258
+ if (resolvedID(current.referenced_post_id))
259
+ this.changed(current.referenced_post_id);
92
260
  });
93
261
  }
262
+ async restrictFeed(opts) {
263
+ validateFeedVisibility(opts.new_visibility);
264
+ return this.mutate(async (signal) => {
265
+ await this.feed.restrict({ ...opts, signal });
266
+ signal.throwIfAborted();
267
+ this.changed("");
268
+ });
269
+ }
270
+ checkVersion(current, expected) {
271
+ if (expected &&
272
+ (current.owner_id !== expected.owner_id ||
273
+ current.updated_at !== expected.updated_at))
274
+ throw errValidation({
275
+ field: "updated_at",
276
+ msg: "Reload the post before changing it.",
277
+ });
278
+ }
279
+ async requireFeedPost(id, signal) {
280
+ const post = await this.readFeedPost({ id, signal });
281
+ if (!post)
282
+ throw errResolveFailed({
283
+ resource: resourceFeedPost,
284
+ msg: msgFeedPostMissing,
285
+ });
286
+ return post;
287
+ }
288
+ changed(post_id) {
289
+ this.events.publish(FeedEvents.Changed, { post_id });
290
+ }
291
+ async mutate(fn, parent) {
292
+ if (this.mutating)
293
+ throw errValidation({ msg: msgFeedActionInProgress });
294
+ const signal = this.sessions.signal(parent);
295
+ signal.throwIfAborted();
296
+ this.mutating = true;
297
+ try {
298
+ const result = await fn(signal);
299
+ signal.throwIfAborted();
300
+ return result;
301
+ }
302
+ finally {
303
+ this.mutating = false;
304
+ }
305
+ }
94
306
  async createFeedReaction(opts) {
95
307
  const out = await this.feed.insertOneFeedReaction(opts);
96
308
  const id = requiredOperationID(out, msgCreatedFeedReactionIDMissing);
@@ -110,28 +322,54 @@ export class FeedFX {
110
322
  return await this.findFeedFavorite(opts.id);
111
323
  }
112
324
  async startSession(opts) {
113
- const { on_error, ...context } = opts;
114
- const initial = await this.feed.pull({
115
- ...context,
116
- session_token: newFeedSessionToken,
117
- is_new_session: true,
118
- });
119
- const session_token = feedSessionToken(initial);
120
- return newPullSession({
121
- initial,
122
- session_token,
325
+ const { on_error, signal: parentSignal, ...input } = opts;
326
+ const supported = [
327
+ PullTypeBatch,
328
+ PullTypeResearch,
329
+ PullTypeReactions,
330
+ PullTypeReferences,
331
+ PullTypeReplies,
332
+ PullTypeProfilePosts,
333
+ PullTypeProfileReplies,
334
+ PullTypeProfileMedias,
335
+ ];
336
+ if (!supported.includes(input.pull_type))
337
+ throw errValidation({
338
+ field: "pull_type",
339
+ msg: "Unsupported Feed session.",
340
+ });
341
+ if (input.pull_type === PullTypeResearch && !input.research.trim())
342
+ throw errValidation({ field: "research", msg: "Research is required." });
343
+ if (![PullTypeBatch, PullTypeResearch].includes(input.pull_type))
344
+ requireFeedID(input.entity_id);
345
+ const context = {
346
+ ...input,
347
+ research: input.research.trim(),
348
+ contact_ids: [...input.contact_ids],
349
+ };
350
+ const signal = this.sessions.signal(parentSignal);
351
+ return await startPullSession({
352
+ signal,
353
+ pull_initial: (signal) => this.feed.pull({
354
+ ...context,
355
+ session_token: newFeedSessionToken,
356
+ is_new_session: true,
357
+ signal,
358
+ }),
123
359
  keep_alive_interval_ms: this.keep_alive_interval_ms,
124
- pull_next: async (token) => {
360
+ pull_next: async (token, signal) => {
125
361
  return await this.feed.pull({
126
362
  ...context,
127
363
  session_token: token,
364
+ signal,
128
365
  is_new_session: false,
129
366
  });
130
367
  },
131
- keep_alive: async (token) => {
368
+ keep_alive: async (token, signal) => {
132
369
  return await this.feed.pull({
133
370
  contact_ids: [],
134
371
  session_token: token,
372
+ signal,
135
373
  is_new_session: false,
136
374
  pull_type: PullTypeKeepAlive,
137
375
  entity_id: "",
@@ -150,85 +388,90 @@ export class FeedFX {
150
388
  }
151
389
  async addReaction(opts) {
152
390
  const post_id = feedPostID(opts.post);
153
- if (this.hasAction(opts.post, FeedAction.Reaction)) {
154
- throw errValidation({
155
- field: "post",
156
- msg: msgFeedReactionAlreadyExists,
157
- });
158
- }
159
- return await this.runAction(post_id, FeedAction.Reaction, async () => {
391
+ return await this.runAction(post_id, FeedAction.Reaction, async (signal) => {
392
+ const fresh = await this.requireFeedPost(post_id, signal);
393
+ if (resolvedID(fresh.self.reaction_id))
394
+ throw errValidation({
395
+ msg: "An action already exists for this post.",
396
+ });
397
+ if (![FeedLike, FeedDislike, FeedReport].includes(opts.reaction_type))
398
+ throw errValidation({
399
+ field: "reaction_type",
400
+ msg: "Unsupported reaction.",
401
+ });
160
402
  const out = await this.feed.insertOneFeedReaction({
403
+ signal,
161
404
  in: {
162
405
  post_id,
163
406
  reaction_type: opts.reaction_type,
164
407
  },
165
408
  });
409
+ signal.throwIfAborted();
166
410
  this.markAction(post_id, FeedAction.Reaction, operationID(out));
167
411
  return out;
168
412
  });
169
413
  }
170
414
  async removeReaction(opts) {
171
415
  const post_id = feedPostID(opts.post);
172
- const id = this.actionID(opts.post, FeedAction.Reaction);
173
- if (id === undefined) {
174
- throw errValidation({
175
- field: "post",
176
- msg: msgFeedReactionMissing,
177
- });
178
- }
179
- return await this.runAction(post_id, FeedAction.Reaction, async () => {
416
+ return await this.runAction(post_id, FeedAction.Reaction, async (signal) => {
417
+ const fresh = await this.requireFeedPost(post_id, signal);
418
+ const id = resolvedID(fresh.self.reaction_id);
419
+ if (!id)
420
+ throw errValidation({ msg: "This action is no longer present." });
180
421
  const out = await this.feed.deleteOneFeedReaction({
422
+ signal,
181
423
  id,
182
424
  });
425
+ signal.throwIfAborted();
183
426
  this.markActionRemoved(post_id, FeedAction.Reaction);
184
427
  return out;
185
428
  });
186
429
  }
187
430
  async addFavorite(opts) {
188
431
  const post_id = feedPostID(opts.post);
189
- if (this.hasAction(opts.post, FeedAction.Favorite)) {
190
- throw errValidation({
191
- field: "post",
192
- msg: msgFeedFavoriteAlreadyExists,
193
- });
194
- }
195
- return await this.runAction(post_id, FeedAction.Favorite, async () => {
432
+ return await this.runAction(post_id, FeedAction.Favorite, async (signal) => {
433
+ const fresh = await this.requireFeedPost(post_id, signal);
434
+ if (resolvedID(fresh.self.favorite_id))
435
+ throw errValidation({
436
+ msg: "An action already exists for this post.",
437
+ });
196
438
  const out = await this.feed.insertOneFeedFavorite({
439
+ signal,
197
440
  in: {
198
441
  post_id,
199
442
  },
200
443
  });
444
+ signal.throwIfAborted();
201
445
  this.markAction(post_id, FeedAction.Favorite, operationID(out));
202
446
  return out;
203
447
  });
204
448
  }
205
449
  async removeFavorite(opts) {
206
450
  const post_id = feedPostID(opts.post);
207
- const id = this.actionID(opts.post, FeedAction.Favorite);
208
- if (id === undefined) {
209
- throw errValidation({
210
- field: "post",
211
- msg: msgFeedFavoriteMissing,
212
- });
213
- }
214
- return await this.runAction(post_id, FeedAction.Favorite, async () => {
451
+ return await this.runAction(post_id, FeedAction.Favorite, async (signal) => {
452
+ const fresh = await this.requireFeedPost(post_id, signal);
453
+ const id = resolvedID(fresh.self.favorite_id);
454
+ if (!id)
455
+ throw errValidation({ msg: "This action is no longer present." });
215
456
  const out = await this.feed.deleteOneFeedFavorite({
457
+ signal,
216
458
  id,
217
459
  });
460
+ signal.throwIfAborted();
218
461
  this.markActionRemoved(post_id, FeedAction.Favorite);
219
462
  return out;
220
463
  });
221
464
  }
222
465
  async createReply(opts) {
223
466
  const post_id = feedPostID(opts.post);
224
- if (this.hasAction(opts.post, FeedAction.Reply)) {
225
- throw errValidation({
226
- field: "post",
227
- msg: msgFeedReplyAlreadyExists,
228
- });
229
- }
230
- return await this.runAction(post_id, FeedAction.Reply, async () => {
467
+ return await this.runAction(post_id, FeedAction.Reply, async (signal) => {
468
+ const fresh = await this.requireFeedPost(post_id, signal);
469
+ if (resolvedID(fresh.self.reply_id))
470
+ throw errValidation({ msg: "An action already exists for this post." });
471
+ if (!opts.in.text.text.trim())
472
+ throw errValidation({ field: "text", msg: "Reply text is required." });
231
473
  const out = await this.feed.insertOneFeedPost({
474
+ signal,
232
475
  in: {
233
476
  objects: [],
234
477
  text: opts.in.text,
@@ -239,37 +482,37 @@ export class FeedFX {
239
482
  reply_to_post_id: post_id,
240
483
  },
241
484
  });
485
+ signal.throwIfAborted();
242
486
  this.markAction(post_id, FeedAction.Reply, operationID(out));
243
487
  return out;
244
488
  });
245
489
  }
246
490
  async removeReply(opts) {
247
491
  const post_id = feedPostID(opts.post);
248
- const id = this.actionID(opts.post, FeedAction.Reply);
249
- if (id === undefined) {
250
- throw errValidation({
251
- field: "post",
252
- msg: msgFeedReplyMissing,
253
- });
254
- }
255
- return await this.runAction(post_id, FeedAction.Reply, async () => {
492
+ return await this.runAction(post_id, FeedAction.Reply, async (signal) => {
493
+ const fresh = await this.requireFeedPost(post_id, signal);
494
+ const id = resolvedID(fresh.self.reply_id);
495
+ if (!id)
496
+ throw errValidation({ msg: "This action is no longer present." });
256
497
  const out = await this.feed.deleteOneFeedPost({
498
+ signal,
257
499
  id,
258
500
  });
501
+ signal.throwIfAborted();
259
502
  this.markActionRemoved(post_id, FeedAction.Reply);
260
503
  return out;
261
504
  });
262
505
  }
263
506
  async createReference(opts) {
264
507
  const post_id = feedPostID(opts.post);
265
- if (this.hasAction(opts.post, FeedAction.Reference)) {
266
- throw errValidation({
267
- field: "post",
268
- msg: msgFeedReferenceAlreadyExists,
269
- });
270
- }
271
- return await this.runAction(post_id, FeedAction.Reference, async () => {
508
+ return await this.runAction(post_id, FeedAction.Reference, async (signal) => {
509
+ const fresh = await this.requireFeedPost(post_id, signal);
510
+ if (resolvedID(fresh.self.reference_id))
511
+ throw errValidation({
512
+ msg: "An action already exists for this post.",
513
+ });
272
514
  const out = await this.feed.insertOneFeedPost({
515
+ signal,
273
516
  in: {
274
517
  objects: [],
275
518
  text: opts.in.text,
@@ -280,208 +523,151 @@ export class FeedFX {
280
523
  reply_to_post_id: ZeroID,
281
524
  },
282
525
  });
526
+ signal.throwIfAborted();
283
527
  this.markAction(post_id, FeedAction.Reference, operationID(out));
284
528
  return out;
285
529
  });
286
530
  }
287
531
  async removeReference(opts) {
288
532
  const post_id = feedPostID(opts.post);
289
- const id = this.actionID(opts.post, FeedAction.Reference);
290
- if (id === undefined) {
291
- throw errValidation({
292
- field: "post",
293
- msg: msgFeedReferenceMissing,
294
- });
295
- }
296
- return await this.runAction(post_id, FeedAction.Reference, async () => {
533
+ return await this.runAction(post_id, FeedAction.Reference, async (signal) => {
534
+ const fresh = await this.requireFeedPost(post_id, signal);
535
+ const id = resolvedID(fresh.self.reference_id);
536
+ if (!id)
537
+ throw errValidation({ msg: "This action is no longer present." });
297
538
  const out = await this.feed.deleteOneFeedPost({
539
+ signal,
298
540
  id,
299
541
  });
542
+ signal.throwIfAborted();
300
543
  this.markActionRemoved(post_id, FeedAction.Reference);
301
544
  return out;
302
545
  });
303
546
  }
547
+ async saveFeedPost(opts) {
548
+ return this.mutate((signal) => saveFeedPost({
549
+ feed: this.feed,
550
+ storage: this.storage_fx,
551
+ read: (id, signal) => this.requireFeedPost(id, signal),
552
+ changed: (id) => this.changed(id),
553
+ }, { ...opts, signal }), opts.signal);
554
+ }
304
555
  async createPostWithMedia(opts) {
305
- return await this.createMediaPost({
556
+ return this.createMediaPost({
557
+ ...opts,
306
558
  in: {
559
+ ...opts.in,
307
560
  objects: [],
308
- text: opts.in.text,
309
- is_uploading: true,
310
561
  has_media: true,
311
- visibility: opts.in.visibility,
312
- referenced_post_id: ZeroID,
562
+ is_uploading: true,
313
563
  reply_to_post_id: ZeroID,
564
+ referenced_post_id: ZeroID,
314
565
  },
315
- user_id: opts.user_id,
316
- uploader_id: opts.uploader_id,
317
- full_quality: opts.full_quality,
318
- files: opts.files,
319
- save_to_personal_box: opts.save_to_personal_box,
320
566
  });
321
567
  }
322
568
  async createReplyWithMedia(opts) {
323
- const post_id = feedPostID(opts.post);
324
- if (this.hasAction(opts.post, FeedAction.Reply)) {
325
- throw errValidation({
326
- field: "post",
327
- msg: msgFeedReplyAlreadyExists,
328
- });
329
- }
330
- return await this.runAction(post_id, FeedAction.Reply, async () => {
331
- const out = await this.createMediaPost({
332
- in: {
333
- objects: [],
334
- text: opts.in.text,
335
- is_uploading: true,
336
- has_media: true,
337
- visibility: All,
338
- referenced_post_id: ZeroID,
339
- reply_to_post_id: post_id,
340
- },
341
- user_id: opts.user_id,
342
- uploader_id: opts.uploader_id,
343
- full_quality: opts.full_quality,
344
- files: opts.files,
345
- save_to_personal_box: opts.save_to_personal_box,
346
- });
347
- const id = completedMediaPostID(out);
348
- if (id !== undefined) {
349
- this.markAction(post_id, FeedAction.Reply, id);
350
- }
351
- return out;
569
+ return this.createMediaPost({
570
+ ...opts,
571
+ in: {
572
+ ...opts.in,
573
+ visibility: All,
574
+ objects: [],
575
+ has_media: true,
576
+ is_uploading: true,
577
+ reply_to_post_id: feedPostID(opts.post),
578
+ referenced_post_id: ZeroID,
579
+ },
352
580
  });
353
581
  }
354
582
  async createReferenceWithMedia(opts) {
355
- const post_id = feedPostID(opts.post);
356
- if (this.hasAction(opts.post, FeedAction.Reference)) {
357
- throw errValidation({
358
- field: "post",
359
- msg: msgFeedReferenceAlreadyExists,
360
- });
361
- }
362
- return await this.runAction(post_id, FeedAction.Reference, async () => {
363
- const out = await this.createMediaPost({
364
- in: {
365
- objects: [],
366
- text: opts.in.text,
367
- is_uploading: true,
368
- has_media: true,
369
- visibility: All,
370
- referenced_post_id: post_id,
371
- reply_to_post_id: ZeroID,
372
- },
373
- user_id: opts.user_id,
374
- uploader_id: opts.uploader_id,
375
- full_quality: opts.full_quality,
376
- files: opts.files,
377
- save_to_personal_box: opts.save_to_personal_box,
378
- });
379
- const id = completedMediaPostID(out);
380
- if (id !== undefined) {
381
- this.markAction(post_id, FeedAction.Reference, id);
382
- }
383
- return out;
583
+ return this.createMediaPost({
584
+ ...opts,
585
+ in: {
586
+ ...opts.in,
587
+ visibility: All,
588
+ objects: [],
589
+ has_media: true,
590
+ is_uploading: true,
591
+ reply_to_post_id: ZeroID,
592
+ referenced_post_id: feedPostID(opts.post),
593
+ },
384
594
  });
385
595
  }
386
596
  async createMediaPost(opts) {
387
- const create = await this.feed.insertOneFeedPost({
388
- in: opts.in,
389
- });
390
- const post_id = operationID(create);
391
- if (post_id === undefined) {
392
- return {
393
- status: FeedMediaPostStatus.CreateUnresolved,
394
- create,
395
- error: errResolveFailed({
396
- resource: resourceFeedPostID,
397
- msg: msgFeedCreatedPostIDMissing,
398
- }),
399
- };
400
- }
401
- let upload;
402
- try {
403
- upload = await this.storage_fx.uploadObjectsWithPersonalBox({
404
- col: ColStorageFeed,
405
- owner_id: post_id,
406
- user_id: opts.user_id,
407
- uploader_id: opts.uploader_id,
408
- full_quality: opts.full_quality,
409
- public_url: true,
410
- files: opts.files,
411
- save_to_personal_box: opts.save_to_personal_box,
412
- });
413
- }
414
- catch (err) {
415
- return {
416
- status: FeedMediaPostStatus.UploadFailed,
417
- post_id,
418
- error: err,
419
- };
420
- }
421
- if (upload.primary.moderation === StorageUploadModeration.AllForbidden) {
422
- return {
423
- status: FeedMediaPostStatus.AllForbidden,
424
- post_id,
425
- upload,
426
- };
427
- }
428
- let stored;
597
+ validateStorageUploadFiles(opts.files);
598
+ const state = opts.state ?? newFeedPostSaveState();
599
+ const progress = { stage: "create" };
429
600
  try {
430
- stored = await this.storage_fx.downloadObjects({
431
- col: ColStorageFeed,
432
- owner_id: post_id,
601
+ const result = await this.saveFeedPost({
602
+ ...opts,
603
+ state,
604
+ owner_id: opts.user_id ?? opts.uploader_id,
605
+ signal: this.sessions.signal(opts.signal),
606
+ idempotency_key: opts.idempotency_key ?? state.create_key,
607
+ on_stage: (value) => {
608
+ progress.stage = value;
609
+ },
610
+ draft: {
611
+ in: opts.in,
612
+ retained_urls: [],
613
+ files: opts.files,
614
+ full_quality: opts.full_quality ?? false,
615
+ save_to_personal_box: opts.save_to_personal_box ?? false,
616
+ },
433
617
  });
434
- }
435
- catch (err) {
436
- return {
437
- status: FeedMediaPostStatus.DownloadFailed,
438
- post_id,
439
- upload,
440
- error: err,
441
- };
442
- }
443
- let objects;
444
- try {
445
- objects = feedObjects(stored.value ?? []);
446
- }
447
- catch (err) {
618
+ if (!state.post_id || !result.upload)
619
+ throw errResolveFailed({
620
+ resource: resourceFeedPost,
621
+ msg: msgFeedPostMissing,
622
+ });
623
+ if (!result.post)
624
+ return {
625
+ status: FeedMediaPostStatus.AllForbidden,
626
+ post_id: state.post_id,
627
+ upload: result.upload,
628
+ };
448
629
  return {
449
- status: FeedMediaPostStatus.ResolveFailed,
450
- post_id,
451
- upload,
452
- error: err,
630
+ status: FeedMediaPostStatus.Complete,
631
+ post_id: result.post.id,
632
+ objects: result.post.objects,
633
+ upload: result.upload,
453
634
  };
454
635
  }
455
- try {
456
- await this.feed.replaceOneFeedPost({
457
- id: post_id,
458
- in: {
459
- ...opts.in,
460
- objects,
461
- is_uploading: false,
462
- },
463
- });
464
- }
465
- catch (err) {
636
+ catch (error) {
637
+ opts.signal?.throwIfAborted();
638
+ if (!state.post_id)
639
+ throw error;
640
+ if (!state.upload)
641
+ return {
642
+ status: FeedMediaPostStatus.UploadFailed,
643
+ post_id: state.post_id,
644
+ error,
645
+ };
646
+ if (state.input)
647
+ return {
648
+ status: FeedMediaPostStatus.PatchFailed,
649
+ post_id: state.post_id,
650
+ upload: state.upload,
651
+ objects: state.input.objects,
652
+ error,
653
+ };
466
654
  return {
467
- status: FeedMediaPostStatus.PatchFailed,
468
- post_id,
469
- upload,
470
- objects,
471
- error: err,
655
+ status: progress.stage === "resolve"
656
+ ? FeedMediaPostStatus.ResolveFailed
657
+ : FeedMediaPostStatus.DownloadFailed,
658
+ post_id: state.post_id,
659
+ upload: state.upload,
660
+ error,
472
661
  };
473
662
  }
474
- return {
475
- status: FeedMediaPostStatus.Complete,
476
- post_id,
477
- upload,
478
- objects,
479
- };
480
663
  }
481
- async findFeedPost(id) {
664
+ async findFeedPost(id, signal) {
665
+ signal?.throwIfAborted();
482
666
  const out = await this.feed.findOneFeedPost({
483
667
  id,
668
+ signal,
484
669
  });
670
+ signal?.throwIfAborted();
485
671
  const post = out.value;
486
672
  if (post === undefined) {
487
673
  throw errResolveFailed({
@@ -518,18 +704,19 @@ export class FeedFX {
518
704
  return favorite;
519
705
  }
520
706
  async runAction(post_id, action, fn) {
521
- if (this.isActionRunning(post_id, action)) {
522
- throw errValidation({
523
- msg: msgFeedActionInProgress,
524
- });
525
- }
526
- this.setActionRunning(post_id, action, true);
527
- try {
528
- return await fn();
529
- }
530
- finally {
531
- this.setActionRunning(post_id, action, false);
532
- }
707
+ return this.mutate(async (signal) => {
708
+ this.setActionRunning(post_id, action, true);
709
+ try {
710
+ const result = await fn(signal);
711
+ signal.throwIfAborted();
712
+ this.changed(post_id);
713
+ return result;
714
+ }
715
+ finally {
716
+ if (!signal.aborted)
717
+ this.setActionRunning(post_id, action, false);
718
+ }
719
+ });
533
720
  }
534
721
  hasAction(post, action) {
535
722
  const post_id = feedPostID(post);
@@ -674,47 +861,12 @@ function resolvedValue(v) {
674
861
  }
675
862
  return out;
676
863
  }
677
- function completedMediaPostID(out) {
678
- if (out.status !== FeedMediaPostStatus.Complete) {
679
- return undefined;
680
- }
681
- return out.post_id;
682
- }
683
- function feedObjects(objects) {
684
- if (objects.length === 0) {
685
- throw errResolveFailed({
686
- resource: resourceFeedObject,
687
- msg: msgFeedObjectsMissing,
688
- });
689
- }
690
- const out = [];
691
- for (const object of objects) {
692
- const public_url = resolvedValue(object.public_url);
693
- if (public_url === undefined) {
694
- throw errResolveFailed({
695
- resource: resourceFeedObject,
696
- msg: msgFeedObjectURLMissing,
697
- });
698
- }
699
- const mime_prefix = resolvedValue(object.mime_prefix);
700
- if (mime_prefix === undefined) {
701
- throw errResolveFailed({
702
- resource: resourceFeedObject,
703
- msg: msgFeedObjectMIMEMissing,
704
- });
705
- }
706
- const embedding = object.caption;
707
- if (embedding === undefined) {
708
- throw errResolveFailed({
709
- resource: resourceFeedObject,
710
- msg: msgFeedObjectCaptionMissing,
711
- });
712
- }
713
- out.push({
714
- public_url,
715
- mime_prefix,
716
- embedding,
864
+ function requireFeedID(value) {
865
+ const id = resolvedID(value);
866
+ if (!id)
867
+ throw errValidation({
868
+ field: "id",
869
+ msg: "A Feed record or owner ID is required.",
717
870
  });
718
- }
719
- return out;
871
+ return id;
720
872
  }