@fabriktor/fx 0.0.111 → 0.0.113

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.
@@ -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
- const out = await this.billable.insertOneVendorInvoice({ in: input });
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) {
@@ -50,7 +50,9 @@ export class ChatSendQueue {
50
50
  }
51
51
  get snapshot() {
52
52
  return {
53
- entries: this.entries,
53
+ entries: this.entries.filter((entry) => !this.cancellations.has(entry.id) ||
54
+ !!entry.server_id ||
55
+ !!entry.chat),
54
56
  confirmed: [...this.confirmed.values()],
55
57
  confirmed_sequences: Object.fromEntries(this.confirmedSequences),
56
58
  error: this.error,
@@ -268,6 +270,7 @@ export class ChatSendQueue {
268
270
  return;
269
271
  }
270
272
  this.cancellations.add(id);
273
+ this.publish();
271
274
  try {
272
275
  // Finish a pending local save before removing its durable record. The
273
276
  // cancellation flag prevents this tab from starting that message meanwhile.
@@ -388,6 +391,11 @@ export class ChatSendQueue {
388
391
  this.startConfirmation(current, owner, signal);
389
392
  }
390
393
  catch {
394
+ // remove() owns deliberate cancellation and removes the durable entry
395
+ // under this same room lock. Do not publish a transient send failure.
396
+ if (this.cancellations.has(entry.id) && !current.server_id) {
397
+ return false;
398
+ }
391
399
  current = {
392
400
  ...current,
393
401
  attempted: current.attempted && (requestStarted || !!current.server_id),
@@ -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
+ }
@@ -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";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabriktor/fx",
3
- "version": "0.0.111",
3
+ "version": "0.0.113",
4
4
  "description": "![Fabriktor Logo](./img/fabriktor-character.gif)",
5
5
  "type": "module",
6
6
  "exports": {