@fabriktor/fx 0.0.110 → 0.0.112
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/billable/billable.d.ts +10 -9
- package/dist/src/billable/billable.js +41 -1
- package/dist/src/chat/send-queue.d.ts +3 -0
- package/dist/src/chat/send-queue.js +73 -45
- package/dist/src/core/multi-create.d.ts +74 -0
- package/dist/src/core/multi-create.js +203 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type BillableOperator } from "@fabriktor/client";
|
|
2
2
|
import { type Bill, BillableFolder, Charge, Equipment, Item, LaborRate, MeterReading, Quantifier, Tax, TmpBillNumber, VendorInvoice, Warehouse } from "@fabriktor/schema";
|
|
3
3
|
import type { ContactsFXOperator, EmploymentOwnerScope } from "../contacts/contacts.js";
|
|
4
|
+
import type { MultiCreateCheckpoint } from "../core/multi-create.js";
|
|
4
5
|
import { type OwnerSearchOptions, type SearchOptions, type SearchPage, type SearchQuery } from "../core/search.js";
|
|
5
6
|
type GenerateBillPayload = Parameters<BillableOperator["generateBill"]>[0];
|
|
6
7
|
export type SalaryEstimateInput = GenerateBillPayload["salary_estimate"];
|
|
@@ -49,31 +50,31 @@ export type CreateBillableFolderOptions = BillableFolderInsertOptions;
|
|
|
49
50
|
export type ReplaceBillableFolderOptions = BillableFolderReplaceOptions;
|
|
50
51
|
export type SearchBillableFoldersOptions = OwnerSearchOptions;
|
|
51
52
|
export type SearchBillableFoldersPage = SearchPage<BillableFolder>;
|
|
52
|
-
export type CreateChargeOptions = ChargeInsertOptions;
|
|
53
|
+
export type CreateChargeOptions = ChargeInsertOptions & MultiCreateCheckpoint;
|
|
53
54
|
export type ReplaceChargeOptions = ChargeReplaceOptions;
|
|
54
55
|
export type SearchChargesOptions = OwnerSearchOptions & ChargeSearchFields;
|
|
55
56
|
export type SearchChargesPage = SearchPage<Charge>;
|
|
56
|
-
export type CreateEquipmentOptions = EquipmentInsertOptions;
|
|
57
|
+
export type CreateEquipmentOptions = EquipmentInsertOptions & MultiCreateCheckpoint;
|
|
57
58
|
export type ReplaceEquipmentOptions = EquipmentReplaceOptions;
|
|
58
59
|
export type SearchEquipmentsOptions = OwnerSearchOptions & EquipmentSearchFields;
|
|
59
60
|
export type SearchEquipmentsPage = SearchPage<Equipment>;
|
|
60
|
-
export type CreateItemOptions = ItemInsertOptions;
|
|
61
|
+
export type CreateItemOptions = ItemInsertOptions & MultiCreateCheckpoint;
|
|
61
62
|
export type ReplaceItemOptions = ItemReplaceOptions;
|
|
62
63
|
export type SearchItemsOptions = OwnerSearchOptions & ItemSearchFields;
|
|
63
64
|
export type SearchItemsPage = SearchPage<Item>;
|
|
64
|
-
export type CreateLaborRateOptions = LaborRateInsertOptions;
|
|
65
|
+
export type CreateLaborRateOptions = LaborRateInsertOptions & MultiCreateCheckpoint;
|
|
65
66
|
export type ReplaceLaborRateOptions = LaborRateReplaceOptions;
|
|
66
67
|
export type SearchLaborRatesOptions = OwnerSearchOptions & Partial<Pick<LaborRate, "is_archived">>;
|
|
67
68
|
export type SearchLaborRatesPage = SearchPage<LaborRate>;
|
|
68
|
-
export type CreateMeterReadingOptions = MeterReadingInsertOptions;
|
|
69
|
+
export type CreateMeterReadingOptions = MeterReadingInsertOptions & MultiCreateCheckpoint;
|
|
69
70
|
export type ReplaceMeterReadingOptions = MeterReadingReplaceOptions;
|
|
70
71
|
export type SearchMeterReadingsOptions = OwnerSearchOptions & MeterReadingSearchFields;
|
|
71
72
|
export type SearchMeterReadingsPage = SearchPage<MeterReading>;
|
|
72
|
-
export type CreateQuantifierOptions = QuantifierInsertOptions;
|
|
73
|
+
export type CreateQuantifierOptions = QuantifierInsertOptions & MultiCreateCheckpoint;
|
|
73
74
|
export type ReplaceQuantifierOptions = QuantifierReplaceOptions;
|
|
74
75
|
export type SearchQuantifiersOptions = OwnerSearchOptions & Partial<Pick<Quantifier, "is_archived">>;
|
|
75
76
|
export type SearchQuantifiersPage = SearchPage<Quantifier>;
|
|
76
|
-
export type CreateTaxOptions = TaxInsertOptions;
|
|
77
|
+
export type CreateTaxOptions = TaxInsertOptions & MultiCreateCheckpoint;
|
|
77
78
|
export type ReplaceTaxOptions = TaxReplaceOptions;
|
|
78
79
|
export type SearchTaxesOptions = OwnerSearchOptions & Partial<Pick<Tax, "is_archived">>;
|
|
79
80
|
export type SearchTaxesPage = SearchPage<Tax>;
|
|
@@ -82,14 +83,14 @@ export type CreateTmpBillNumberOptions = TmpBillNumberInsertOptions;
|
|
|
82
83
|
export type ReplaceTmpBillNumberOptions = TmpBillNumberReplaceOptions;
|
|
83
84
|
export type SearchTmpBillNumbersOptions = OwnerSearchOptions;
|
|
84
85
|
export type SearchTmpBillNumbersPage = SearchPage<TmpBillNumber>;
|
|
85
|
-
export type CreateVendorInvoiceOptions = VendorInvoiceInsertOptions & {
|
|
86
|
+
export type CreateVendorInvoiceOptions = VendorInvoiceInsertOptions & MultiCreateCheckpoint & {
|
|
86
87
|
owner_id: string;
|
|
87
88
|
allow_duplicate?: boolean;
|
|
88
89
|
};
|
|
89
90
|
export type ReplaceVendorInvoiceOptions = VendorInvoiceReplaceOptions;
|
|
90
91
|
export type SearchVendorInvoicesOptions = OwnerSearchOptions & Partial<Pick<VendorInvoice, "is_archived">>;
|
|
91
92
|
export type SearchVendorInvoicesPage = SearchPage<VendorInvoice>;
|
|
92
|
-
export type CreateWarehouseOptions = WarehouseInsertOptions;
|
|
93
|
+
export type CreateWarehouseOptions = WarehouseInsertOptions & MultiCreateCheckpoint;
|
|
93
94
|
export type ReplaceWarehouseOptions = WarehouseReplaceOptions;
|
|
94
95
|
export type SearchWarehousesOptions = OwnerSearchOptions & Partial<Pick<Warehouse, "is_archived">>;
|
|
95
96
|
export type SearchWarehousesPage = SearchPage<Warehouse>;
|
|
@@ -155,8 +155,12 @@ export class BillableFX {
|
|
|
155
155
|
});
|
|
156
156
|
}
|
|
157
157
|
async createCharge(opts) {
|
|
158
|
+
if (opts.created_id)
|
|
159
|
+
return await this.findCharge(opts.created_id);
|
|
160
|
+
opts.before_request?.();
|
|
158
161
|
const out = await this.billable.insertOneCharge(opts);
|
|
159
162
|
const id = requiredOperationID(out, msgCreatedChargeIDMissing);
|
|
163
|
+
opts.on_created?.(id);
|
|
160
164
|
return await this.findCharge(id);
|
|
161
165
|
}
|
|
162
166
|
async replaceCharge(opts) {
|
|
@@ -184,8 +188,12 @@ export class BillableFX {
|
|
|
184
188
|
});
|
|
185
189
|
}
|
|
186
190
|
async createEquipment(opts) {
|
|
191
|
+
if (opts.created_id)
|
|
192
|
+
return await this.findEquipment(opts.created_id);
|
|
193
|
+
opts.before_request?.();
|
|
187
194
|
const out = await this.billable.insertOneEquipment(opts);
|
|
188
195
|
const id = requiredOperationID(out, msgCreatedEquipmentIDMissing);
|
|
196
|
+
opts.on_created?.(id);
|
|
189
197
|
return await this.findEquipment(id);
|
|
190
198
|
}
|
|
191
199
|
async replaceEquipment(opts) {
|
|
@@ -208,8 +216,12 @@ export class BillableFX {
|
|
|
208
216
|
});
|
|
209
217
|
}
|
|
210
218
|
async createItem(opts) {
|
|
219
|
+
if (opts.created_id)
|
|
220
|
+
return await this.findItem(opts.created_id);
|
|
221
|
+
opts.before_request?.();
|
|
211
222
|
const out = await this.billable.insertOneItem(opts);
|
|
212
223
|
const id = requiredOperationID(out, msgCreatedItemIDMissing);
|
|
224
|
+
opts.on_created?.(id);
|
|
213
225
|
return await this.findItem(id);
|
|
214
226
|
}
|
|
215
227
|
async replaceItem(opts) {
|
|
@@ -232,8 +244,12 @@ export class BillableFX {
|
|
|
232
244
|
});
|
|
233
245
|
}
|
|
234
246
|
async createLaborRate(opts) {
|
|
247
|
+
if (opts.created_id)
|
|
248
|
+
return await this.findLaborRate(opts.created_id);
|
|
249
|
+
opts.before_request?.();
|
|
235
250
|
const out = await this.billable.insertOneLaborRate(opts);
|
|
236
251
|
const id = requiredOperationID(out, msgCreatedLaborRateIDMissing);
|
|
252
|
+
opts.on_created?.(id);
|
|
237
253
|
return await this.findLaborRate(id);
|
|
238
254
|
}
|
|
239
255
|
async replaceLaborRate(opts) {
|
|
@@ -248,8 +264,12 @@ export class BillableFX {
|
|
|
248
264
|
});
|
|
249
265
|
}
|
|
250
266
|
async createMeterReading(opts) {
|
|
267
|
+
if (opts.created_id)
|
|
268
|
+
return await this.findMeterReading(opts.created_id);
|
|
269
|
+
opts.before_request?.();
|
|
251
270
|
const out = await this.billable.insertOneMeterReading(opts);
|
|
252
271
|
const id = requiredOperationID(out, msgCreatedMeterReadingIDMissing);
|
|
272
|
+
opts.on_created?.(id);
|
|
253
273
|
return await this.findMeterReading(id);
|
|
254
274
|
}
|
|
255
275
|
async replaceMeterReading(opts) {
|
|
@@ -276,8 +296,12 @@ export class BillableFX {
|
|
|
276
296
|
});
|
|
277
297
|
}
|
|
278
298
|
async createQuantifier(opts) {
|
|
299
|
+
if (opts.created_id)
|
|
300
|
+
return await this.findQuantifier(opts.created_id);
|
|
301
|
+
opts.before_request?.();
|
|
279
302
|
const out = await this.billable.insertOneQuantifier(opts);
|
|
280
303
|
const id = requiredOperationID(out, msgCreatedQuantifierIDMissing);
|
|
304
|
+
opts.on_created?.(id);
|
|
281
305
|
return await this.findQuantifier(id);
|
|
282
306
|
}
|
|
283
307
|
async replaceQuantifier(opts) {
|
|
@@ -302,8 +326,12 @@ export class BillableFX {
|
|
|
302
326
|
});
|
|
303
327
|
}
|
|
304
328
|
async createTax(opts) {
|
|
329
|
+
if (opts.created_id)
|
|
330
|
+
return await this.findTax(opts.created_id);
|
|
331
|
+
opts.before_request?.();
|
|
305
332
|
const out = await this.billable.insertOneTax(opts);
|
|
306
333
|
const id = requiredOperationID(out, msgCreatedTaxIDMissing);
|
|
334
|
+
opts.on_created?.(id);
|
|
307
335
|
return await this.findTax(id);
|
|
308
336
|
}
|
|
309
337
|
async replaceTax(opts) {
|
|
@@ -339,6 +367,8 @@ export class BillableFX {
|
|
|
339
367
|
});
|
|
340
368
|
}
|
|
341
369
|
async createVendorInvoice(opts) {
|
|
370
|
+
if (opts.created_id)
|
|
371
|
+
return await this.findVendorInvoice(opts.created_id);
|
|
342
372
|
const owner_id = opts.owner_id.trim();
|
|
343
373
|
if (!owner_id) {
|
|
344
374
|
throw errRequired({
|
|
@@ -353,8 +383,14 @@ export class BillableFX {
|
|
|
353
383
|
if (!opts.allow_duplicate) {
|
|
354
384
|
await this.checkVendorInvoiceNumber(owner_id, input.number);
|
|
355
385
|
}
|
|
356
|
-
|
|
386
|
+
opts.before_request?.();
|
|
387
|
+
const out = await this.billable.insertOneVendorInvoice({
|
|
388
|
+
in: input,
|
|
389
|
+
signal: opts.signal,
|
|
390
|
+
idempotency_key: opts.idempotency_key,
|
|
391
|
+
});
|
|
357
392
|
const id = requiredOperationID(out, msgCreatedVendorInvoiceIDMissing);
|
|
393
|
+
opts.on_created?.(id);
|
|
358
394
|
return await this.findVendorInvoice(id);
|
|
359
395
|
}
|
|
360
396
|
async replaceVendorInvoice(opts) {
|
|
@@ -369,8 +405,12 @@ export class BillableFX {
|
|
|
369
405
|
});
|
|
370
406
|
}
|
|
371
407
|
async createWarehouse(opts) {
|
|
408
|
+
if (opts.created_id)
|
|
409
|
+
return await this.findWarehouse(opts.created_id);
|
|
410
|
+
opts.before_request?.();
|
|
372
411
|
const out = await this.billable.insertOneWarehouse(opts);
|
|
373
412
|
const id = requiredOperationID(out, msgCreatedWarehouseIDMissing);
|
|
413
|
+
opts.on_created?.(id);
|
|
374
414
|
return await this.findWarehouse(id);
|
|
375
415
|
}
|
|
376
416
|
async replaceWarehouse(opts) {
|
|
@@ -46,6 +46,7 @@ export type ChatSendQueueOptions = {
|
|
|
46
46
|
lock: ChatSendLock;
|
|
47
47
|
upload: (entry: QueuedChat, signal: AbortSignal) => Promise<boolean>;
|
|
48
48
|
changed?: (chat?: Chat) => void;
|
|
49
|
+
online?: () => boolean;
|
|
49
50
|
};
|
|
50
51
|
export declare class ChatSendQueue {
|
|
51
52
|
private readonly opts;
|
|
@@ -97,4 +98,6 @@ export declare class ChatSendQueue {
|
|
|
97
98
|
private publish;
|
|
98
99
|
}
|
|
99
100
|
export declare function chatSendNeedsRetry(entry: QueuedChat): boolean;
|
|
101
|
+
export declare function chatSendCanRetry(entry: QueuedChat): boolean;
|
|
102
|
+
export declare function chatSendCanRestore(entry: QueuedChat): boolean;
|
|
100
103
|
export declare function chatSendCanRemove(entry: QueuedChat): boolean;
|
|
@@ -16,8 +16,9 @@ export const ChatSendStatus = {
|
|
|
16
16
|
Uploading: "uploading",
|
|
17
17
|
UploadInterrupted: "upload_interrupted",
|
|
18
18
|
};
|
|
19
|
-
// Persistent creation FIFO per account and room.
|
|
20
|
-
//
|
|
19
|
+
// Persistent creation FIFO per account and room. A creation is attempted once:
|
|
20
|
+
// the backend rejects reused keys rather than replaying the original response.
|
|
21
|
+
// Unconfirmed entries remain visible but never block later messages.
|
|
21
22
|
// Retrieval, attachments, other rooms and other users are independent.
|
|
22
23
|
export class ChatSendQueue {
|
|
23
24
|
opts;
|
|
@@ -156,6 +157,11 @@ export class ChatSendQueue {
|
|
|
156
157
|
}
|
|
157
158
|
this.remember(chat);
|
|
158
159
|
this.publish();
|
|
160
|
+
for (const entry of this.entries) {
|
|
161
|
+
if (!entry.chat && entry.server_id === chat.id) {
|
|
162
|
+
this.startConfirmation(entry, this.owner, this.controller.signal);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
159
165
|
}
|
|
160
166
|
async refresh() {
|
|
161
167
|
if (!this.enabled) {
|
|
@@ -184,12 +190,13 @@ export class ChatSendQueue {
|
|
|
184
190
|
if (stored.length === 0 && this.pendingSaves.size === 0)
|
|
185
191
|
this.wakeup.cancel();
|
|
186
192
|
for (const room of new Set(stored
|
|
187
|
-
.filter(
|
|
193
|
+
.filter(chatSendCanCreate)
|
|
188
194
|
.map((entry) => entry.input.chat_room_id))) {
|
|
189
195
|
this.work(room, owner, signal);
|
|
190
196
|
}
|
|
191
197
|
for (const entry of stored) {
|
|
192
|
-
if (chatSendCanConfirm(entry)
|
|
198
|
+
if (chatSendCanConfirm(entry) ||
|
|
199
|
+
(!entry.chat && entry.server_id && this.confirmed.has(entry.server_id))) {
|
|
193
200
|
this.startConfirmation(entry, owner, signal);
|
|
194
201
|
}
|
|
195
202
|
if (entry.chat && entry.status === ChatSendStatus.Uploading) {
|
|
@@ -207,7 +214,7 @@ export class ChatSendQueue {
|
|
|
207
214
|
}
|
|
208
215
|
async retry(id) {
|
|
209
216
|
const entry = this.entries.find((value) => value.id === id);
|
|
210
|
-
if (!entry || !this.enabled) {
|
|
217
|
+
if (!entry || !this.enabled || !chatSendCanRetry(entry)) {
|
|
211
218
|
return;
|
|
212
219
|
}
|
|
213
220
|
if (entry.sequence === undefined) {
|
|
@@ -233,7 +240,7 @@ export class ChatSendQueue {
|
|
|
233
240
|
return;
|
|
234
241
|
}
|
|
235
242
|
const current = (await this.opts.store.list(owner)).find((value) => value.id === id);
|
|
236
|
-
if (!current || !
|
|
243
|
+
if (!current || !chatSendCanRetry(current)) {
|
|
237
244
|
return;
|
|
238
245
|
}
|
|
239
246
|
if (current.chat) {
|
|
@@ -308,7 +315,7 @@ export class ChatSendQueue {
|
|
|
308
315
|
owner_id: owner,
|
|
309
316
|
scope: `chat-send:${room}`,
|
|
310
317
|
signal,
|
|
311
|
-
select: (entry) => entry.input.chat_room_id === room &&
|
|
318
|
+
select: (entry) => entry.input.chat_room_id === room && chatSendCanCreate(entry),
|
|
312
319
|
on_error: (err) => {
|
|
313
320
|
if (this.current(owner, signal)) {
|
|
314
321
|
this.error = err;
|
|
@@ -325,13 +332,23 @@ export class ChatSendQueue {
|
|
|
325
332
|
if (!this.current(owner, signal) || this.cancellations.has(entry.id)) {
|
|
326
333
|
return false;
|
|
327
334
|
}
|
|
328
|
-
//
|
|
329
|
-
//
|
|
335
|
+
// The room lock excludes a sender in another tab. A leftover attempt
|
|
336
|
+
// (including legacy automatic retries) has an unknown outcome, not
|
|
337
|
+
// permission to POST again with an already consumed key.
|
|
330
338
|
let current = this.pendingCreates.get(entry.id) ?? entry;
|
|
331
|
-
if (
|
|
339
|
+
if (current.attempted && !current.server_id) {
|
|
340
|
+
await this.save({
|
|
341
|
+
...current,
|
|
342
|
+
status: ChatSendStatus.Failed,
|
|
343
|
+
retry: undefined,
|
|
344
|
+
});
|
|
345
|
+
return true;
|
|
346
|
+
}
|
|
347
|
+
if (this.opts.online?.() === false)
|
|
332
348
|
return false;
|
|
333
349
|
const request = new AbortController();
|
|
334
350
|
const requestSignal = AbortSignal.any([signal, request.signal]);
|
|
351
|
+
let requestStarted = false;
|
|
335
352
|
this.sends.set(entry.id, request);
|
|
336
353
|
try {
|
|
337
354
|
if (!current.server_id) {
|
|
@@ -344,6 +361,7 @@ export class ChatSendQueue {
|
|
|
344
361
|
if (!this.current(owner, signal))
|
|
345
362
|
return false;
|
|
346
363
|
requestSignal.throwIfAborted();
|
|
364
|
+
requestStarted = true;
|
|
347
365
|
const out = await this.opts.chat.insertOneChat({
|
|
348
366
|
in: current.input,
|
|
349
367
|
signal: requestSignal,
|
|
@@ -369,15 +387,12 @@ export class ChatSendQueue {
|
|
|
369
387
|
}
|
|
370
388
|
this.startConfirmation(current, owner, signal);
|
|
371
389
|
}
|
|
372
|
-
catch
|
|
390
|
+
catch {
|
|
373
391
|
current = {
|
|
374
392
|
...current,
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
retry: requestSignal.aborted
|
|
379
|
-
? undefined
|
|
380
|
-
: nextQueueRetry(current.retry),
|
|
393
|
+
attempted: current.attempted && (requestStarted || !!current.server_id),
|
|
394
|
+
status: ChatSendStatus.Failed,
|
|
395
|
+
retry: undefined,
|
|
381
396
|
};
|
|
382
397
|
if (current.server_id)
|
|
383
398
|
this.pendingCreates.set(current.id, current);
|
|
@@ -387,14 +402,13 @@ export class ChatSendQueue {
|
|
|
387
402
|
}
|
|
388
403
|
catch (storageError) {
|
|
389
404
|
this.error = storageError;
|
|
390
|
-
}
|
|
391
|
-
if (this.current(owner, signal) && !request.signal.aborted) {
|
|
392
|
-
this.error = err;
|
|
393
405
|
this.publish();
|
|
394
|
-
|
|
395
|
-
|
|
406
|
+
this.recoverStorage();
|
|
407
|
+
return false;
|
|
396
408
|
}
|
|
397
|
-
|
|
409
|
+
// The failed entry is durable and excluded from the creation lane.
|
|
410
|
+
// Continue with the next message without a timer or another POST.
|
|
411
|
+
return this.current(owner, signal);
|
|
398
412
|
}
|
|
399
413
|
finally {
|
|
400
414
|
this.sends.delete(entry.id);
|
|
@@ -406,7 +420,9 @@ export class ChatSendQueue {
|
|
|
406
420
|
// Retrieval is independent of the room's creation lock. Its per-message
|
|
407
421
|
// lock coordinates retries/recovery across tabs without holding later POSTs.
|
|
408
422
|
startConfirmation(entry, owner, signal) {
|
|
409
|
-
|
|
423
|
+
const known = entry.server_id && this.confirmed.get(entry.server_id);
|
|
424
|
+
if (this.confirmations.has(entry.id) ||
|
|
425
|
+
(!known && this.opts.online?.() === false)) {
|
|
410
426
|
return;
|
|
411
427
|
}
|
|
412
428
|
this.confirmations.add(entry.id);
|
|
@@ -417,17 +433,23 @@ export class ChatSendQueue {
|
|
|
417
433
|
}
|
|
418
434
|
const stored = (await this.opts.store.list(owner)).find((value) => value.id === entry.id);
|
|
419
435
|
if (!stored ||
|
|
420
|
-
!
|
|
421
|
-
|
|
436
|
+
!stored.server_id ||
|
|
437
|
+
stored.chat ||
|
|
438
|
+
(!chatSendCanConfirm(stored) && !this.confirmed.has(stored.server_id))) {
|
|
422
439
|
return;
|
|
423
440
|
}
|
|
424
441
|
let current = stored;
|
|
425
442
|
try {
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
443
|
+
const chat = this.confirmed.get(current.server_id) ??
|
|
444
|
+
(await this.opts.chat
|
|
445
|
+
.findOneChat({ id: current.server_id, signal })
|
|
446
|
+
.catch((err) => {
|
|
447
|
+
// A room event can arrive while this read is in flight.
|
|
448
|
+
const confirmed = this.confirmed.get(current.server_id);
|
|
449
|
+
if (confirmed)
|
|
450
|
+
return { value: confirmed };
|
|
451
|
+
throw err;
|
|
452
|
+
})).value;
|
|
431
453
|
if (!chat ||
|
|
432
454
|
chat.id !== current.server_id ||
|
|
433
455
|
chat.owner_id !== owner ||
|
|
@@ -457,26 +479,16 @@ export class ChatSendQueue {
|
|
|
457
479
|
this.opts.changed?.(chat);
|
|
458
480
|
}
|
|
459
481
|
}
|
|
460
|
-
catch
|
|
461
|
-
const retry = signal.aborted
|
|
462
|
-
? undefined
|
|
463
|
-
: nextQueueRetry(current.retry);
|
|
464
|
-
if (retry && !current.chat && this.current(owner, signal)) {
|
|
465
|
-
this.scheduleRecovery(retry.after);
|
|
466
|
-
}
|
|
482
|
+
catch {
|
|
467
483
|
await this.save({
|
|
468
484
|
...current,
|
|
469
|
-
retry,
|
|
485
|
+
retry: undefined,
|
|
470
486
|
status: current.chat
|
|
471
487
|
? ChatSendStatus.UploadInterrupted
|
|
472
488
|
: signal.aborted
|
|
473
489
|
? ChatSendStatus.Interrupted
|
|
474
490
|
: ChatSendStatus.Failed,
|
|
475
491
|
});
|
|
476
|
-
if (this.current(owner, signal)) {
|
|
477
|
-
this.error = err;
|
|
478
|
-
this.publish();
|
|
479
|
-
}
|
|
480
492
|
}
|
|
481
493
|
})
|
|
482
494
|
.catch((err) => {
|
|
@@ -681,14 +693,30 @@ function chatSendCanConfirm(entry) {
|
|
|
681
693
|
(entry.status === ChatSendStatus.Confirming ||
|
|
682
694
|
entry.status === ChatSendStatus.Sending ||
|
|
683
695
|
entry.status === ChatSendStatus.Queued ||
|
|
684
|
-
entry.status === ChatSendStatus.Interrupted
|
|
685
|
-
entry.status === ChatSendStatus.Failed));
|
|
696
|
+
entry.status === ChatSendStatus.Interrupted));
|
|
686
697
|
}
|
|
687
698
|
export function chatSendNeedsRetry(entry) {
|
|
688
699
|
return (entry.status === ChatSendStatus.Failed ||
|
|
689
700
|
entry.status === ChatSendStatus.Interrupted ||
|
|
690
701
|
entry.status === ChatSendStatus.UploadInterrupted);
|
|
691
702
|
}
|
|
703
|
+
// Local persistence and reads by a known server ID are safe to retry. An
|
|
704
|
+
// attempted creation without an ID cannot be repeated or silently re-keyed.
|
|
705
|
+
export function chatSendCanRetry(entry) {
|
|
706
|
+
return (chatSendNeedsRetry(entry) &&
|
|
707
|
+
(!entry.attempted || !!entry.server_id || !!entry.chat));
|
|
708
|
+
}
|
|
709
|
+
export function chatSendCanRestore(entry) {
|
|
710
|
+
return (chatSendNeedsRetry(entry) &&
|
|
711
|
+
entry.attempted &&
|
|
712
|
+
!entry.server_id &&
|
|
713
|
+
!entry.chat);
|
|
714
|
+
}
|
|
715
|
+
function chatSendCanCreate(entry) {
|
|
716
|
+
return (!entry.chat &&
|
|
717
|
+
!entry.server_id &&
|
|
718
|
+
(entry.status !== ChatSendStatus.Failed || !!entry.retry));
|
|
719
|
+
}
|
|
692
720
|
export function chatSendCanRemove(entry) {
|
|
693
721
|
if (!entry.chat && entry.server_id) {
|
|
694
722
|
return false;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export declare const multiCreateMaximumEntries = 40;
|
|
2
|
+
export declare const multiCreateConcurrency = 5;
|
|
3
|
+
export declare const MultiCreateStatus: {
|
|
4
|
+
readonly Pending: "pending";
|
|
5
|
+
readonly Creating: "creating";
|
|
6
|
+
readonly Created: "created";
|
|
7
|
+
readonly Failed: "failed";
|
|
8
|
+
};
|
|
9
|
+
export type MultiCreateStatus = (typeof MultiCreateStatus)[keyof typeof MultiCreateStatus];
|
|
10
|
+
export type MultiCreateCheckpoint = {
|
|
11
|
+
created_id?: string;
|
|
12
|
+
on_created?: (id: string) => void;
|
|
13
|
+
before_request?: () => void;
|
|
14
|
+
};
|
|
15
|
+
export type MultiCreateAttempt = MultiCreateCheckpoint & {
|
|
16
|
+
idempotency_key: string;
|
|
17
|
+
signal: AbortSignal;
|
|
18
|
+
};
|
|
19
|
+
export type MultiCreateEntry<Input, Result> = {
|
|
20
|
+
readonly id: string;
|
|
21
|
+
readonly input: Input;
|
|
22
|
+
readonly status: MultiCreateStatus;
|
|
23
|
+
readonly idempotency_key: string;
|
|
24
|
+
readonly attempted: boolean;
|
|
25
|
+
readonly created_id?: string;
|
|
26
|
+
readonly result?: Result;
|
|
27
|
+
readonly error?: unknown;
|
|
28
|
+
};
|
|
29
|
+
export type MultiCreateSnapshot<Input, Result> = {
|
|
30
|
+
readonly entries: readonly MultiCreateEntry<Input, Result>[];
|
|
31
|
+
readonly running: boolean;
|
|
32
|
+
};
|
|
33
|
+
export type MultiCreateOutcome<Input, Result> = {
|
|
34
|
+
readonly entries: readonly MultiCreateEntry<Input, Result>[];
|
|
35
|
+
readonly created: number;
|
|
36
|
+
readonly failed: number;
|
|
37
|
+
};
|
|
38
|
+
export declare class MultiCreatePool {
|
|
39
|
+
readonly capacity: number;
|
|
40
|
+
private active;
|
|
41
|
+
private waiting;
|
|
42
|
+
constructor(capacity?: number);
|
|
43
|
+
run(run: () => Promise<void>): Promise<void>;
|
|
44
|
+
}
|
|
45
|
+
export type MultiCreateOptions<Input, Result> = {
|
|
46
|
+
run: (input: Input, attempt: MultiCreateAttempt) => Promise<Result>;
|
|
47
|
+
pool?: MultiCreatePool;
|
|
48
|
+
before_request?: () => void;
|
|
49
|
+
on_failed?: (entry: MultiCreateEntry<Input, Result>) => void;
|
|
50
|
+
on_settled?: (outcome: MultiCreateOutcome<Input, Result>) => void;
|
|
51
|
+
};
|
|
52
|
+
export declare class MultiCreator<Input, Result> {
|
|
53
|
+
private readonly opts;
|
|
54
|
+
private entries;
|
|
55
|
+
private listeners;
|
|
56
|
+
private running;
|
|
57
|
+
private controller;
|
|
58
|
+
private completion;
|
|
59
|
+
constructor(opts: MultiCreateOptions<Input, Result>);
|
|
60
|
+
get snapshot(): MultiCreateSnapshot<Input, Result>;
|
|
61
|
+
subscribe(listener: (snapshot: MultiCreateSnapshot<Input, Result>) => void): () => void;
|
|
62
|
+
add(input: Input): string;
|
|
63
|
+
edit(id: string, input: Input): boolean;
|
|
64
|
+
remove(id: string): boolean;
|
|
65
|
+
start(): Promise<void>;
|
|
66
|
+
private begin;
|
|
67
|
+
retry(id: string, input?: Input): Promise<void>;
|
|
68
|
+
whenIdle(): Promise<void>;
|
|
69
|
+
cancel(): void;
|
|
70
|
+
private run;
|
|
71
|
+
private replace;
|
|
72
|
+
private publish;
|
|
73
|
+
private notify;
|
|
74
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Copyright (C) Fabriktor, Inc. 2025-present.
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License"); you may
|
|
4
|
+
// not use this file except in compliance with the License. You may obtain
|
|
5
|
+
// a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
|
|
6
|
+
export const multiCreateMaximumEntries = 40;
|
|
7
|
+
export const multiCreateConcurrency = 5;
|
|
8
|
+
export const MultiCreateStatus = {
|
|
9
|
+
Pending: "pending",
|
|
10
|
+
Creating: "creating",
|
|
11
|
+
Created: "created",
|
|
12
|
+
Failed: "failed",
|
|
13
|
+
};
|
|
14
|
+
// Share one pool between batches to keep the application's total concurrency bounded.
|
|
15
|
+
export class MultiCreatePool {
|
|
16
|
+
capacity;
|
|
17
|
+
active = 0;
|
|
18
|
+
waiting = [];
|
|
19
|
+
constructor(capacity = multiCreateConcurrency) {
|
|
20
|
+
this.capacity = capacity;
|
|
21
|
+
if (!Number.isInteger(capacity) || capacity < 1)
|
|
22
|
+
throw new RangeError("Invalid creation concurrency.");
|
|
23
|
+
}
|
|
24
|
+
async run(run) {
|
|
25
|
+
await new Promise((resolve) => {
|
|
26
|
+
const enter = () => {
|
|
27
|
+
this.active++;
|
|
28
|
+
resolve();
|
|
29
|
+
};
|
|
30
|
+
if (this.active < this.capacity)
|
|
31
|
+
enter();
|
|
32
|
+
else
|
|
33
|
+
this.waiting.push(enter);
|
|
34
|
+
});
|
|
35
|
+
try {
|
|
36
|
+
await run();
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
this.active--;
|
|
40
|
+
this.waiting.shift()?.();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const sharedMultiCreatePool = new MultiCreatePool();
|
|
45
|
+
// Temporary, app-owned batch. No persistence, automatic retries or transactions.
|
|
46
|
+
// Removing presentation subscribers does not cancel a submitted batch.
|
|
47
|
+
export class MultiCreator {
|
|
48
|
+
opts;
|
|
49
|
+
entries = [];
|
|
50
|
+
listeners = new Set();
|
|
51
|
+
running = false;
|
|
52
|
+
controller = new AbortController();
|
|
53
|
+
completion = Promise.resolve();
|
|
54
|
+
constructor(opts) {
|
|
55
|
+
this.opts = opts;
|
|
56
|
+
}
|
|
57
|
+
get snapshot() {
|
|
58
|
+
return { entries: this.entries, running: this.running };
|
|
59
|
+
}
|
|
60
|
+
subscribe(listener) {
|
|
61
|
+
this.listeners.add(listener);
|
|
62
|
+
listener(this.snapshot);
|
|
63
|
+
return () => this.listeners.delete(listener);
|
|
64
|
+
}
|
|
65
|
+
add(input) {
|
|
66
|
+
if (this.running || this.entries.length >= multiCreateMaximumEntries)
|
|
67
|
+
throw new RangeError("Creation batch is full or running.");
|
|
68
|
+
const id = crypto.randomUUID();
|
|
69
|
+
this.entries = [
|
|
70
|
+
...this.entries,
|
|
71
|
+
{
|
|
72
|
+
id,
|
|
73
|
+
input: structuredClone(input),
|
|
74
|
+
status: MultiCreateStatus.Pending,
|
|
75
|
+
idempotency_key: crypto.randomUUID(),
|
|
76
|
+
attempted: false,
|
|
77
|
+
},
|
|
78
|
+
];
|
|
79
|
+
this.publish();
|
|
80
|
+
return id;
|
|
81
|
+
}
|
|
82
|
+
edit(id, input) {
|
|
83
|
+
const entry = this.entries.find((entry) => entry.id === id);
|
|
84
|
+
if (this.running || entry?.status !== MultiCreateStatus.Pending)
|
|
85
|
+
return false;
|
|
86
|
+
this.replace({ ...entry, input: structuredClone(input) });
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
remove(id) {
|
|
90
|
+
if (this.running ||
|
|
91
|
+
!this.entries.some((entry) => entry.id === id && entry.status === MultiCreateStatus.Pending))
|
|
92
|
+
return false;
|
|
93
|
+
this.entries = this.entries.filter((entry) => entry.id !== id);
|
|
94
|
+
this.publish();
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
start() {
|
|
98
|
+
if (this.running)
|
|
99
|
+
return this.completion;
|
|
100
|
+
const pending = this.entries.filter((entry) => entry.status === MultiCreateStatus.Pending);
|
|
101
|
+
return this.begin(pending);
|
|
102
|
+
}
|
|
103
|
+
begin(pending) {
|
|
104
|
+
if (!pending.length)
|
|
105
|
+
return Promise.resolve();
|
|
106
|
+
this.running = true;
|
|
107
|
+
this.controller = new AbortController();
|
|
108
|
+
this.publish();
|
|
109
|
+
this.completion = this.run(pending);
|
|
110
|
+
return this.completion;
|
|
111
|
+
}
|
|
112
|
+
retry(id, input) {
|
|
113
|
+
const entry = this.entries.find((entry) => entry.id === id);
|
|
114
|
+
if (this.running || entry?.status !== MultiCreateStatus.Failed)
|
|
115
|
+
return this.completion;
|
|
116
|
+
// Explicit user retry is a new creation attempt when no ID was acknowledged.
|
|
117
|
+
// A known ID always resumes retrieval with the original request key.
|
|
118
|
+
const next = {
|
|
119
|
+
...entry,
|
|
120
|
+
input: input === undefined ? entry.input : structuredClone(input),
|
|
121
|
+
idempotency_key: entry.created_id
|
|
122
|
+
? entry.idempotency_key
|
|
123
|
+
: crypto.randomUUID(),
|
|
124
|
+
status: MultiCreateStatus.Pending,
|
|
125
|
+
error: undefined,
|
|
126
|
+
};
|
|
127
|
+
this.replace(next);
|
|
128
|
+
return this.begin([next]);
|
|
129
|
+
}
|
|
130
|
+
whenIdle() {
|
|
131
|
+
return this.completion;
|
|
132
|
+
}
|
|
133
|
+
cancel() {
|
|
134
|
+
this.controller.abort();
|
|
135
|
+
}
|
|
136
|
+
async run(pending) {
|
|
137
|
+
const signal = this.controller.signal;
|
|
138
|
+
const pool = this.opts.pool ?? sharedMultiCreatePool;
|
|
139
|
+
await Promise.all(pending.map((entry) => pool.run(async () => {
|
|
140
|
+
let current = {
|
|
141
|
+
...entry,
|
|
142
|
+
status: MultiCreateStatus.Creating,
|
|
143
|
+
};
|
|
144
|
+
this.replace(current);
|
|
145
|
+
try {
|
|
146
|
+
signal.throwIfAborted();
|
|
147
|
+
this.opts.before_request?.();
|
|
148
|
+
const result = await this.opts.run(structuredClone(current.input), {
|
|
149
|
+
signal,
|
|
150
|
+
idempotency_key: current.idempotency_key,
|
|
151
|
+
created_id: current.created_id,
|
|
152
|
+
before_request: () => {
|
|
153
|
+
signal.throwIfAborted();
|
|
154
|
+
this.opts.before_request?.();
|
|
155
|
+
current = { ...current, attempted: true };
|
|
156
|
+
this.replace(current);
|
|
157
|
+
},
|
|
158
|
+
on_created: (id) => {
|
|
159
|
+
current = { ...current, created_id: id };
|
|
160
|
+
this.replace(current);
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
this.replace({
|
|
164
|
+
...current,
|
|
165
|
+
status: MultiCreateStatus.Created,
|
|
166
|
+
result,
|
|
167
|
+
error: undefined,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
current = { ...current, status: MultiCreateStatus.Failed, error };
|
|
172
|
+
this.replace(current);
|
|
173
|
+
this.notify(() => this.opts.on_failed?.(current));
|
|
174
|
+
}
|
|
175
|
+
})));
|
|
176
|
+
this.running = false;
|
|
177
|
+
this.publish();
|
|
178
|
+
const ids = new Set(pending.map((entry) => entry.id));
|
|
179
|
+
const entries = this.entries.filter((entry) => ids.has(entry.id));
|
|
180
|
+
this.notify(() => this.opts.on_settled?.({
|
|
181
|
+
entries,
|
|
182
|
+
created: entries.filter((entry) => entry.status === MultiCreateStatus.Created).length,
|
|
183
|
+
failed: entries.filter((entry) => entry.status === MultiCreateStatus.Failed).length,
|
|
184
|
+
}));
|
|
185
|
+
}
|
|
186
|
+
replace(entry) {
|
|
187
|
+
this.entries = this.entries.map((value) => value.id === entry.id ? entry : value);
|
|
188
|
+
this.publish();
|
|
189
|
+
}
|
|
190
|
+
publish() {
|
|
191
|
+
for (const listener of this.listeners)
|
|
192
|
+
this.notify(() => listener(this.snapshot));
|
|
193
|
+
}
|
|
194
|
+
notify(run) {
|
|
195
|
+
// Presentation errors cannot turn a successful creation into a failed POST.
|
|
196
|
+
try {
|
|
197
|
+
run();
|
|
198
|
+
}
|
|
199
|
+
catch (err) {
|
|
200
|
+
console.error("Multi-creation observer failed.", err);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
package/dist/src/index.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ export * from "./contacts/contacts.js";
|
|
|
8
8
|
export * from "./core/date.js";
|
|
9
9
|
export * from "./core/err.js";
|
|
10
10
|
export * from "./core/id.js";
|
|
11
|
+
export * from "./core/multi-create.js";
|
|
11
12
|
export * from "./core/pre-action.js";
|
|
12
13
|
export * from "./core/pull.js";
|
|
13
14
|
export * from "./core/queue-store.js";
|
package/dist/src/index.js
CHANGED
|
@@ -8,6 +8,7 @@ export * from "./contacts/contacts.js";
|
|
|
8
8
|
export * from "./core/date.js";
|
|
9
9
|
export * from "./core/err.js";
|
|
10
10
|
export * from "./core/id.js";
|
|
11
|
+
export * from "./core/multi-create.js";
|
|
11
12
|
export * from "./core/pre-action.js";
|
|
12
13
|
export * from "./core/pull.js";
|
|
13
14
|
export * from "./core/queue-store.js";
|