@fabriktor/fx 0.0.104 → 0.0.106

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.
@@ -26,7 +26,24 @@ type VendorInvoiceInsertOptions = Parameters<BillableOperator["insertOneVendorIn
26
26
  type VendorInvoiceReplaceOptions = Parameters<BillableOperator["replaceOneVendorInvoice"]>[0];
27
27
  type WarehouseInsertOptions = Parameters<BillableOperator["insertOneWarehouse"]>[0];
28
28
  type WarehouseReplaceOptions = Parameters<BillableOperator["replaceOneWarehouse"]>[0];
29
- export type SearchBillsOptions = OwnerSearchOptions & Partial<Pick<Bill, "is_estimate" | "is_canceled" | "type">>;
29
+ export declare const BillFilter: {
30
+ readonly All: "all";
31
+ readonly NotCancelled: "not_cancelled";
32
+ readonly Fixed: "fixed";
33
+ readonly Dynamic: "dynamic";
34
+ readonly Cancelled: "cancelled";
35
+ };
36
+ export type BillFilter = (typeof BillFilter)[keyof typeof BillFilter];
37
+ export declare function billFilterFields(value: BillFilter): Partial<Pick<Bill, "is_canceled" | "type">>;
38
+ type ItemSearchFields = Partial<Pick<Item, "folder_id" | "is_archived" | "quantifier_id">> & {
39
+ warehouse_id?: Item["per_warehouse_quantity"][number]["warehouse_id"];
40
+ };
41
+ type EquipmentSearchFields = Partial<Pick<Equipment, "folder_id" | "is_archived" | "warehouse_id" | "rental_type">>;
42
+ type ChargeSearchFields = Partial<Pick<Charge, "location_id" | "entity_id" | "warehouse_id" | "type" | "is_estimate" | "is_billed">>;
43
+ type MeterReadingSearchFields = Partial<Pick<MeterReading, "location_id" | "equipment_id">>;
44
+ export type SearchBillsOptions = OwnerSearchOptions & Partial<Pick<Bill, "is_estimate" | "is_canceled" | "type">> & {
45
+ filter?: BillFilter;
46
+ };
30
47
  export type SearchBillsPage = SearchPage<Bill>;
31
48
  export type CreateBillableFolderOptions = BillableFolderInsertOptions;
32
49
  export type ReplaceBillableFolderOptions = BillableFolderReplaceOptions;
@@ -34,31 +51,31 @@ export type SearchBillableFoldersOptions = OwnerSearchOptions;
34
51
  export type SearchBillableFoldersPage = SearchPage<BillableFolder>;
35
52
  export type CreateChargeOptions = ChargeInsertOptions;
36
53
  export type ReplaceChargeOptions = ChargeReplaceOptions;
37
- export type SearchChargesOptions = OwnerSearchOptions;
54
+ export type SearchChargesOptions = OwnerSearchOptions & ChargeSearchFields;
38
55
  export type SearchChargesPage = SearchPage<Charge>;
39
56
  export type CreateEquipmentOptions = EquipmentInsertOptions;
40
57
  export type ReplaceEquipmentOptions = EquipmentReplaceOptions;
41
- export type SearchEquipmentsOptions = OwnerSearchOptions;
58
+ export type SearchEquipmentsOptions = OwnerSearchOptions & EquipmentSearchFields;
42
59
  export type SearchEquipmentsPage = SearchPage<Equipment>;
43
60
  export type CreateItemOptions = ItemInsertOptions;
44
61
  export type ReplaceItemOptions = ItemReplaceOptions;
45
- export type SearchItemsOptions = OwnerSearchOptions;
62
+ export type SearchItemsOptions = OwnerSearchOptions & ItemSearchFields;
46
63
  export type SearchItemsPage = SearchPage<Item>;
47
64
  export type CreateLaborRateOptions = LaborRateInsertOptions;
48
65
  export type ReplaceLaborRateOptions = LaborRateReplaceOptions;
49
- export type SearchLaborRatesOptions = OwnerSearchOptions;
66
+ export type SearchLaborRatesOptions = OwnerSearchOptions & Partial<Pick<LaborRate, "is_archived">>;
50
67
  export type SearchLaborRatesPage = SearchPage<LaborRate>;
51
68
  export type CreateMeterReadingOptions = MeterReadingInsertOptions;
52
69
  export type ReplaceMeterReadingOptions = MeterReadingReplaceOptions;
53
- export type SearchMeterReadingsOptions = OwnerSearchOptions;
70
+ export type SearchMeterReadingsOptions = OwnerSearchOptions & MeterReadingSearchFields;
54
71
  export type SearchMeterReadingsPage = SearchPage<MeterReading>;
55
72
  export type CreateQuantifierOptions = QuantifierInsertOptions;
56
73
  export type ReplaceQuantifierOptions = QuantifierReplaceOptions;
57
- export type SearchQuantifiersOptions = OwnerSearchOptions;
74
+ export type SearchQuantifiersOptions = OwnerSearchOptions & Partial<Pick<Quantifier, "is_archived">>;
58
75
  export type SearchQuantifiersPage = SearchPage<Quantifier>;
59
76
  export type CreateTaxOptions = TaxInsertOptions;
60
77
  export type ReplaceTaxOptions = TaxReplaceOptions;
61
- export type SearchTaxesOptions = OwnerSearchOptions;
78
+ export type SearchTaxesOptions = OwnerSearchOptions & Partial<Pick<Tax, "is_archived">>;
62
79
  export type SearchTaxesPage = SearchPage<Tax>;
63
80
  export type SearchAllTaxesOptions = Omit<SearchTaxesOptions, "page">;
64
81
  export type CreateTmpBillNumberOptions = TmpBillNumberInsertOptions;
@@ -70,11 +87,11 @@ export type CreateVendorInvoiceOptions = VendorInvoiceInsertOptions & {
70
87
  allow_duplicate?: boolean;
71
88
  };
72
89
  export type ReplaceVendorInvoiceOptions = VendorInvoiceReplaceOptions;
73
- export type SearchVendorInvoicesOptions = OwnerSearchOptions;
90
+ export type SearchVendorInvoicesOptions = OwnerSearchOptions & Partial<Pick<VendorInvoice, "is_archived">>;
74
91
  export type SearchVendorInvoicesPage = SearchPage<VendorInvoice>;
75
92
  export type CreateWarehouseOptions = WarehouseInsertOptions;
76
93
  export type ReplaceWarehouseOptions = WarehouseReplaceOptions;
77
- export type SearchWarehousesOptions = OwnerSearchOptions;
94
+ export type SearchWarehousesOptions = OwnerSearchOptions & Partial<Pick<Warehouse, "is_archived">>;
78
95
  export type SearchWarehousesPage = SearchPage<Warehouse>;
79
96
  export type BillableFXOptions = {
80
97
  billable: BillableOperator;
@@ -111,14 +128,12 @@ export type BillableReadSearchOptions = SearchOptions & {
111
128
  employer_scope_ids?: Iterable<string>;
112
129
  query?: SearchQuery;
113
130
  };
114
- export type BillableItemReadSearchOptions = BillableReadSearchOptions & {
115
- warehouse_id?: string;
116
- quantifier_id?: string;
117
- };
118
- export type BillableEquipmentReadSearchOptions = BillableReadSearchOptions & {
119
- warehouse_id?: string;
120
- rental_type?: string;
121
- };
131
+ export type BillableItemReadSearchOptions = BillableReadSearchOptions & ItemSearchFields;
132
+ export type BillableEquipmentReadSearchOptions = BillableReadSearchOptions & EquipmentSearchFields;
133
+ export type BillableChargeReadSearchOptions = BillableReadSearchOptions & ChargeSearchFields;
134
+ export type BillableMeterReadingReadSearchOptions = BillableReadSearchOptions & MeterReadingSearchFields;
135
+ export type BillableQuantifierReadSearchOptions = BillableReadSearchOptions & Partial<Pick<Quantifier, "is_archived">>;
136
+ export type BillableWarehouseReadSearchOptions = BillableReadSearchOptions & Partial<Pick<Warehouse, "is_archived">>;
122
137
  export type GenerateFixedEstimateOptions = {
123
138
  in: GenerateBillPayload;
124
139
  };
@@ -150,7 +165,7 @@ export interface BillableFXOperator {
150
165
  createCharge(opts: CreateChargeOptions): Promise<Charge>;
151
166
  replaceCharge(opts: ReplaceChargeOptions): Promise<Charge>;
152
167
  searchCharges(opts: SearchChargesOptions): Promise<SearchChargesPage>;
153
- searchReadableCharges(opts: BillableReadSearchOptions): Promise<SearchChargesPage>;
168
+ searchReadableCharges(opts: BillableChargeReadSearchOptions): Promise<SearchChargesPage>;
154
169
  createEquipment(opts: CreateEquipmentOptions): Promise<Equipment>;
155
170
  replaceEquipment(opts: ReplaceEquipmentOptions): Promise<Equipment>;
156
171
  searchEquipments(opts: SearchEquipmentsOptions): Promise<SearchEquipmentsPage>;
@@ -165,11 +180,11 @@ export interface BillableFXOperator {
165
180
  createMeterReading(opts: CreateMeterReadingOptions): Promise<MeterReading>;
166
181
  replaceMeterReading(opts: ReplaceMeterReadingOptions): Promise<MeterReading>;
167
182
  searchMeterReadings(opts: SearchMeterReadingsOptions): Promise<SearchMeterReadingsPage>;
168
- searchReadableMeterReadings(opts: BillableReadSearchOptions): Promise<SearchMeterReadingsPage>;
183
+ searchReadableMeterReadings(opts: BillableMeterReadingReadSearchOptions): Promise<SearchMeterReadingsPage>;
169
184
  createQuantifier(opts: CreateQuantifierOptions): Promise<Quantifier>;
170
185
  replaceQuantifier(opts: ReplaceQuantifierOptions): Promise<Quantifier>;
171
186
  searchQuantifiers(opts: SearchQuantifiersOptions): Promise<SearchQuantifiersPage>;
172
- searchReadableQuantifiers(opts: BillableReadSearchOptions): Promise<SearchQuantifiersPage>;
187
+ searchReadableQuantifiers(opts: BillableQuantifierReadSearchOptions): Promise<SearchQuantifiersPage>;
173
188
  createTax(opts: CreateTaxOptions): Promise<Tax>;
174
189
  replaceTax(opts: ReplaceTaxOptions): Promise<Tax>;
175
190
  searchTaxes(opts: SearchTaxesOptions): Promise<SearchTaxesPage>;
@@ -183,7 +198,7 @@ export interface BillableFXOperator {
183
198
  createWarehouse(opts: CreateWarehouseOptions): Promise<Warehouse>;
184
199
  replaceWarehouse(opts: ReplaceWarehouseOptions): Promise<Warehouse>;
185
200
  searchWarehouses(opts: SearchWarehousesOptions): Promise<SearchWarehousesPage>;
186
- searchReadableWarehouses(opts: BillableReadSearchOptions): Promise<SearchWarehousesPage>;
201
+ searchReadableWarehouses(opts: BillableWarehouseReadSearchOptions): Promise<SearchWarehousesPage>;
187
202
  generateFixedEstimate(opts: GenerateFixedEstimateOptions): Promise<Bill>;
188
203
  acceptFixedEstimate(opts: AcceptFixedEstimateOptions): Promise<Bill>;
189
204
  generateDynamicEstimate(opts: GenerateDynamicEstimateOptions): Promise<Bill>;
@@ -205,7 +220,7 @@ export declare class BillableFX implements BillableFXOperator {
205
220
  createCharge(opts: CreateChargeOptions): Promise<Charge>;
206
221
  replaceCharge(opts: ReplaceChargeOptions): Promise<Charge>;
207
222
  searchCharges(opts: SearchChargesOptions): Promise<SearchChargesPage>;
208
- searchReadableCharges(opts: BillableReadSearchOptions): Promise<SearchChargesPage>;
223
+ searchReadableCharges(opts: BillableChargeReadSearchOptions): Promise<SearchChargesPage>;
209
224
  createEquipment(opts: CreateEquipmentOptions): Promise<Equipment>;
210
225
  replaceEquipment(opts: ReplaceEquipmentOptions): Promise<Equipment>;
211
226
  searchEquipments(opts: SearchEquipmentsOptions): Promise<SearchEquipmentsPage>;
@@ -220,11 +235,11 @@ export declare class BillableFX implements BillableFXOperator {
220
235
  createMeterReading(opts: CreateMeterReadingOptions): Promise<MeterReading>;
221
236
  replaceMeterReading(opts: ReplaceMeterReadingOptions): Promise<MeterReading>;
222
237
  searchMeterReadings(opts: SearchMeterReadingsOptions): Promise<SearchMeterReadingsPage>;
223
- searchReadableMeterReadings(opts: BillableReadSearchOptions): Promise<SearchMeterReadingsPage>;
238
+ searchReadableMeterReadings(opts: BillableMeterReadingReadSearchOptions): Promise<SearchMeterReadingsPage>;
224
239
  createQuantifier(opts: CreateQuantifierOptions): Promise<Quantifier>;
225
240
  replaceQuantifier(opts: ReplaceQuantifierOptions): Promise<Quantifier>;
226
241
  searchQuantifiers(opts: SearchQuantifiersOptions): Promise<SearchQuantifiersPage>;
227
- searchReadableQuantifiers(opts: BillableReadSearchOptions): Promise<SearchQuantifiersPage>;
242
+ searchReadableQuantifiers(opts: BillableQuantifierReadSearchOptions): Promise<SearchQuantifiersPage>;
228
243
  createTax(opts: CreateTaxOptions): Promise<Tax>;
229
244
  replaceTax(opts: ReplaceTaxOptions): Promise<Tax>;
230
245
  searchTaxes(opts: SearchTaxesOptions): Promise<SearchTaxesPage>;
@@ -238,7 +253,7 @@ export declare class BillableFX implements BillableFXOperator {
238
253
  createWarehouse(opts: CreateWarehouseOptions): Promise<Warehouse>;
239
254
  replaceWarehouse(opts: ReplaceWarehouseOptions): Promise<Warehouse>;
240
255
  searchWarehouses(opts: SearchWarehousesOptions): Promise<SearchWarehousesPage>;
241
- searchReadableWarehouses(opts: BillableReadSearchOptions): Promise<SearchWarehousesPage>;
256
+ searchReadableWarehouses(opts: BillableWarehouseReadSearchOptions): Promise<SearchWarehousesPage>;
242
257
  generateFixedEstimate(opts: GenerateFixedEstimateOptions): Promise<Bill>;
243
258
  acceptFixedEstimate(opts: AcceptFixedEstimateOptions): Promise<Bill>;
244
259
  generateDynamicEstimate(opts: GenerateDynamicEstimateOptions): Promise<Bill>;
@@ -249,7 +264,6 @@ export declare class BillableFX implements BillableFXOperator {
249
264
  vendorInvoiceFromBill(opts: VendorInvoiceFromBillOptions): Promise<VendorInvoice>;
250
265
  private generateBill;
251
266
  private checkVendorInvoiceNumber;
252
- private searchReadableActivity;
253
267
  private searchReadable;
254
268
  private findBill;
255
269
  private findBillableFolder;
@@ -3,13 +3,13 @@
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 { queryParams } from "@fabriktor/client";
6
+ import { mergeQueryOptions, queryNestedParam, queryParams, } from "@fabriktor/client";
7
7
  import { BillTypeDynamic, BillTypeFixed, UserTypeEmployee, UserTypeEmployer, } from "@fabriktor/schema";
8
8
  import { isZeroDateTime } from "../core/date.js";
9
9
  import { errRequired, errResolveFailed, errValidation } from "../core/err.js";
10
10
  import { requiredOperationID, resolvedID } from "../core/id.js";
11
11
  import { runPreActionCheck } from "../core/pre-action.js";
12
- import { collectSearchResults, runOwnerScopeFilteredSearch, runOwnerScopeSearch, runOwnerSearch, withOwnerQuery, } from "../core/search.js";
12
+ import { collectSearchResults, runOwnerScopeSearch, runOwnerSearch, withOwnerQuery, } from "../core/search.js";
13
13
  const resourceBill = "bill";
14
14
  const msgCreatedBillIDMissing = "Created bill response did not contain an ID.";
15
15
  const msgBillMissing = "Bill could not be resolved after the mutation.";
@@ -56,6 +56,26 @@ const msgBillIDRequired = "Bill ID is required.";
56
56
  const msgBillIDMissing = "Bill response did not contain an ID.";
57
57
  const msgCompletableBillRequired = "Only an uncanceled invoice can be completed.";
58
58
  const msgAcceptedEstimateCancellation = "Cancel the accepted invoice instead of its estimate.";
59
+ // Composite sidebar states belong with their backend query mapping.
60
+ export const BillFilter = {
61
+ All: "all",
62
+ NotCancelled: "not_cancelled",
63
+ Fixed: BillTypeFixed,
64
+ Dynamic: BillTypeDynamic,
65
+ Cancelled: "cancelled",
66
+ };
67
+ export function billFilterFields(value) {
68
+ switch (value) {
69
+ case BillFilter.All:
70
+ return {};
71
+ case BillFilter.NotCancelled:
72
+ return { is_canceled: false };
73
+ case BillFilter.Cancelled:
74
+ return { is_canceled: true };
75
+ default:
76
+ return { is_canceled: false, type: value };
77
+ }
78
+ }
59
79
  export const BillableReadResource = {
60
80
  Bill: "bill",
61
81
  BillableFolder: "billable_folder",
@@ -114,6 +134,7 @@ export class BillableFX {
114
134
  is_estimate: opts.is_estimate,
115
135
  is_canceled: opts.is_canceled,
116
136
  type: opts.type,
137
+ ...(opts.filter === undefined ? {} : billFilterFields(opts.filter)),
117
138
  }),
118
139
  search: (searchOpts) => this.billable.searchManyBills(searchOpts),
119
140
  });
@@ -145,12 +166,20 @@ export class BillableFX {
145
166
  async searchCharges(opts) {
146
167
  return await runOwnerSearch({
147
168
  ...opts,
169
+ query: chargeSearchQuery(opts),
148
170
  search: (searchOpts) => this.billable.searchManyCharges(searchOpts),
149
171
  });
150
172
  }
151
173
  async searchReadableCharges(opts) {
152
- return await this.searchReadableActivity({
174
+ const employer_ids = activityEmployerScopeIDs(opts);
175
+ return await this.searchReadable({
153
176
  ...opts,
177
+ // owner_id identifies the creator; employer_id identifies their employer.
178
+ // An explicitly empty authorized employer set must not become All.
179
+ allowed_owner_ids: employer_ids?.size === 0 ? [] : activityOwnerIDs(opts.context),
180
+ query: mergeQueryOptions(opts.query, chargeSearchQuery(opts), employer_ids === undefined ? undefined : queryParams({
181
+ employer_id: [...employer_ids],
182
+ })),
154
183
  search: (searchOpts) => this.billable.searchManyCharges(searchOpts),
155
184
  });
156
185
  }
@@ -166,18 +195,15 @@ export class BillableFX {
166
195
  async searchEquipments(opts) {
167
196
  return await runOwnerSearch({
168
197
  ...opts,
198
+ query: equipmentSearchQuery(opts),
169
199
  search: (searchOpts) => this.billable.searchManyEquipments(searchOpts),
170
200
  });
171
201
  }
172
202
  async searchReadableEquipments(opts) {
173
- const { warehouse_id, rental_type, ...search_opts } = opts;
174
203
  return await this.searchReadable({
175
- ...search_opts,
204
+ ...opts,
205
+ query: mergeQueryOptions(opts.query, equipmentSearchQuery(opts)),
176
206
  allowed_owner_ids: catalogOwnerIDs(opts.context, opts.employer_scope_ids),
177
- filter: warehouse_id === undefined && rental_type === undefined
178
- ? undefined
179
- : (equipment) => (warehouse_id === undefined || equipment.warehouse_id === warehouse_id) &&
180
- (rental_type === undefined || equipment.rental_type === rental_type),
181
207
  search: (searchOpts) => this.billable.searchManyEquipments(searchOpts),
182
208
  });
183
209
  }
@@ -193,19 +219,15 @@ export class BillableFX {
193
219
  async searchItems(opts) {
194
220
  return await runOwnerSearch({
195
221
  ...opts,
222
+ query: itemSearchQuery(opts),
196
223
  search: (searchOpts) => this.billable.searchManyItems(searchOpts),
197
224
  });
198
225
  }
199
226
  async searchReadableItems(opts) {
200
- const { warehouse_id, quantifier_id, ...search_opts } = opts;
201
227
  return await this.searchReadable({
202
- ...search_opts,
228
+ ...opts,
229
+ query: mergeQueryOptions(opts.query, itemSearchQuery(opts)),
203
230
  allowed_owner_ids: catalogOwnerIDs(opts.context, opts.employer_scope_ids),
204
- filter: warehouse_id === undefined && quantifier_id === undefined
205
- ? undefined
206
- : (item) => (warehouse_id === undefined ||
207
- item.per_warehouse_quantity.some((quantity) => quantity.warehouse_id === warehouse_id)) &&
208
- (quantifier_id === undefined || item.quantifier_id === quantifier_id),
209
231
  search: (searchOpts) => this.billable.searchManyItems(searchOpts),
210
232
  });
211
233
  }
@@ -221,6 +243,7 @@ export class BillableFX {
221
243
  async searchLaborRates(opts) {
222
244
  return await runOwnerSearch({
223
245
  ...opts,
246
+ query: queryParams({ is_archived: opts.is_archived }),
224
247
  search: (searchOpts) => this.billable.searchManyLaborRates(searchOpts),
225
248
  });
226
249
  }
@@ -236,12 +259,19 @@ export class BillableFX {
236
259
  async searchMeterReadings(opts) {
237
260
  return await runOwnerSearch({
238
261
  ...opts,
262
+ query: meterReadingSearchQuery(opts),
239
263
  search: (searchOpts) => this.billable.searchManyMeterReadings(searchOpts),
240
264
  });
241
265
  }
242
266
  async searchReadableMeterReadings(opts) {
243
- return await this.searchReadableActivity({
267
+ const employer_ids = activityEmployerScopeIDs(opts);
268
+ return await this.searchReadable({
244
269
  ...opts,
270
+ // An explicitly empty authorized scope must stay empty, not become All.
271
+ allowed_owner_ids: employer_ids?.size === 0 ? [] : activityOwnerIDs(opts.context),
272
+ query: mergeQueryOptions(opts.query, meterReadingSearchQuery(opts), employer_ids === undefined ? undefined : queryParams({
273
+ employer_id: [...employer_ids],
274
+ })),
245
275
  search: (searchOpts) => this.billable.searchManyMeterReadings(searchOpts),
246
276
  });
247
277
  }
@@ -257,12 +287,16 @@ export class BillableFX {
257
287
  async searchQuantifiers(opts) {
258
288
  return await runOwnerSearch({
259
289
  ...opts,
290
+ query: queryParams({ is_archived: opts.is_archived }),
260
291
  search: (searchOpts) => this.billable.searchManyQuantifiers(searchOpts),
261
292
  });
262
293
  }
263
294
  async searchReadableQuantifiers(opts) {
264
295
  return await this.searchReadable({
265
296
  ...opts,
297
+ query: mergeQueryOptions(opts.query, queryParams({
298
+ ...(opts.is_archived === undefined ? {} : { is_archived: opts.is_archived }),
299
+ })),
266
300
  allowed_owner_ids: catalogOwnerIDs(opts.context, opts.employer_scope_ids),
267
301
  search: (searchOpts) => this.billable.searchManyQuantifiers(searchOpts),
268
302
  });
@@ -279,6 +313,7 @@ export class BillableFX {
279
313
  async searchTaxes(opts) {
280
314
  return await runOwnerSearch({
281
315
  ...opts,
316
+ query: queryParams({ is_archived: opts.is_archived }),
282
317
  search: (searchOpts) => this.billable.searchManyTaxes(searchOpts),
283
318
  });
284
319
  }
@@ -329,6 +364,7 @@ export class BillableFX {
329
364
  async searchVendorInvoices(opts) {
330
365
  return await runOwnerSearch({
331
366
  ...opts,
367
+ query: queryParams({ is_archived: opts.is_archived }),
332
368
  search: (searchOpts) => this.billable.searchManyVendorInvoices(searchOpts),
333
369
  });
334
370
  }
@@ -344,12 +380,16 @@ export class BillableFX {
344
380
  async searchWarehouses(opts) {
345
381
  return await runOwnerSearch({
346
382
  ...opts,
383
+ query: queryParams({ is_archived: opts.is_archived }),
347
384
  search: (searchOpts) => this.billable.searchManyWarehouses(searchOpts),
348
385
  });
349
386
  }
350
387
  async searchReadableWarehouses(opts) {
351
388
  return await this.searchReadable({
352
389
  ...opts,
390
+ query: mergeQueryOptions(opts.query, queryParams({
391
+ ...(opts.is_archived === undefined ? {} : { is_archived: opts.is_archived }),
392
+ })),
353
393
  allowed_owner_ids: catalogOwnerIDs(opts.context, opts.employer_scope_ids),
354
394
  search: (searchOpts) => this.billable.searchManyWarehouses(searchOpts),
355
395
  });
@@ -424,38 +464,12 @@ export class BillableFX {
424
464
  },
425
465
  });
426
466
  }
427
- async searchReadableActivity(opts) {
428
- const employer_scope_ids = activityEmployerScopeIDs(opts);
429
- return await this.searchReadable({
430
- ...opts,
431
- allowed_owner_ids: activityOwnerIDs(opts.context),
432
- filter: employer_scope_ids === undefined
433
- ? undefined
434
- : (value) => employer_scope_ids.has(value.employer_id),
435
- });
436
- }
437
467
  async searchReadable(opts) {
438
- const { context: _context, owner_ids: requested_owner_ids, employer_scope_ids: _employer_scope_ids, allowed_owner_ids, search, filter, query, ...searchOpts } = opts;
468
+ const { context: _context, owner_ids: requested_owner_ids, employer_scope_ids: _employer_scope_ids, allowed_owner_ids, search, query, ...searchOpts } = opts;
439
469
  const owner_ids = restrictOwnerIDs(allowed_owner_ids, requested_owner_ids);
440
- const page = filter === undefined
441
- ? await runOwnerScopeSearch({
442
- ...searchOpts,
443
- owner_ids,
444
- query,
445
- search,
446
- })
447
- : await runOwnerScopeFilteredSearch({
448
- ...searchOpts,
449
- owner_ids,
450
- query,
451
- search,
452
- filter,
453
- compare: compareCreatedAtDescending,
454
- });
455
- return {
456
- ...page,
457
- results: [...page.results].sort(compareCreatedAtDescending),
458
- };
470
+ // Keep each backend page in its returned order. The owner contract requires
471
+ // separate requests; do not manufacture a cross-owner sort in the frontend.
472
+ return await runOwnerScopeSearch({ ...searchOpts, owner_ids, query, search });
459
473
  }
460
474
  async findBill(id) {
461
475
  const out = await this.billable.findOneBill({
@@ -712,6 +726,55 @@ function catalogOwnerIDs(context, employer_scope_ids) {
712
726
  }
713
727
  return uniqueIDs([context.owner_id]);
714
728
  }
729
+ function itemSearchQuery(opts) {
730
+ return mergeQueryOptions(queryParams({
731
+ ...(opts.folder_id === undefined
732
+ ? {}
733
+ : { folder_id: resolvedID(opts.folder_id) }),
734
+ ...(opts.is_archived === undefined ? {} : { is_archived: opts.is_archived }),
735
+ ...(opts.quantifier_id === undefined
736
+ ? {}
737
+ : { quantifier_id: resolvedID(opts.quantifier_id) }),
738
+ }), opts.warehouse_id === undefined ? undefined : queryNestedParam(["per_warehouse_quantity", "warehouse_id"], resolvedID(opts.warehouse_id)));
739
+ }
740
+ function equipmentSearchQuery(opts) {
741
+ return queryParams({
742
+ ...(opts.folder_id === undefined
743
+ ? {}
744
+ : { folder_id: resolvedID(opts.folder_id) }),
745
+ ...(opts.is_archived === undefined ? {} : { is_archived: opts.is_archived }),
746
+ ...(opts.warehouse_id === undefined
747
+ ? {}
748
+ : { warehouse_id: resolvedID(opts.warehouse_id) }),
749
+ ...(opts.rental_type === undefined ? {} : { rental_type: opts.rental_type }),
750
+ });
751
+ }
752
+ function chargeSearchQuery(opts) {
753
+ return queryParams({
754
+ ...(opts.location_id === undefined
755
+ ? {}
756
+ : { location_id: resolvedID(opts.location_id) }),
757
+ ...(opts.entity_id === undefined
758
+ ? {}
759
+ : { entity_id: resolvedID(opts.entity_id) }),
760
+ ...(opts.warehouse_id === undefined
761
+ ? {}
762
+ : { warehouse_id: resolvedID(opts.warehouse_id) }),
763
+ ...(opts.type === undefined ? {} : { type: opts.type }),
764
+ ...(opts.is_estimate === undefined ? {} : { is_estimate: opts.is_estimate }),
765
+ ...(opts.is_billed === undefined ? {} : { is_billed: opts.is_billed }),
766
+ });
767
+ }
768
+ function meterReadingSearchQuery(opts) {
769
+ return queryParams({
770
+ ...(opts.location_id === undefined
771
+ ? {}
772
+ : { location_id: resolvedID(opts.location_id) }),
773
+ ...(opts.equipment_id === undefined
774
+ ? {}
775
+ : { equipment_id: resolvedID(opts.equipment_id) }),
776
+ });
777
+ }
715
778
  function activityEmployerScopeIDs(opts) {
716
779
  if (opts.context.active_mode === UserTypeEmployer) {
717
780
  return new Set([opts.context.owner_id]);
@@ -739,13 +802,6 @@ function restrictOwnerIDs(allowed, requested) {
739
802
  function uniqueIDs(ids) {
740
803
  return [...new Set([...ids].map((id) => id.trim()).filter((id) => id !== ""))];
741
804
  }
742
- function compareCreatedAtDescending(left, right) {
743
- return timestamp(right.created_at) - timestamp(left.created_at);
744
- }
745
- function timestamp(value) {
746
- const out = globalThis.Date.parse(value);
747
- return Number.isFinite(out) ? out : 0;
748
- }
749
805
  export function newBillableFX(opts) {
750
806
  return new BillableFX(opts);
751
807
  }
@@ -1,6 +1,6 @@
1
1
  import type { ChatOperator } from "@fabriktor/client";
2
2
  import type { Chat, InChat } from "@fabriktor/schema";
3
- import { type QueueStore, type QueueLock } from "../core/queue.js";
3
+ import { type QueueRetry, type QueueStore, type QueueLock } from "../core/queue.js";
4
4
  export declare const ChatSendStatus: {
5
5
  readonly Saving: "saving";
6
6
  readonly Queued: "queued";
@@ -28,6 +28,7 @@ export type QueuedChat = {
28
28
  chat?: Chat;
29
29
  attachments: ChatSendAttachments;
30
30
  attempted: boolean;
31
+ retry?: QueueRetry;
31
32
  upload_key: string;
32
33
  personal_box_key: string;
33
34
  };
@@ -64,6 +65,9 @@ export declare class ChatSendQueue {
64
65
  private error?;
65
66
  private enabled;
66
67
  private revision;
68
+ private readonly wakeup;
69
+ private storageRetry?;
70
+ private pendingCreates;
67
71
  constructor(opts: ChatSendQueueOptions);
68
72
  get hasUnsavedMessages(): boolean;
69
73
  get snapshot(): ChatSendSnapshot;
@@ -80,6 +84,9 @@ export declare class ChatSendQueue {
80
84
  private startConfirmation;
81
85
  private startUpload;
82
86
  private recoverUpload;
87
+ private waitForRetry;
88
+ private recoverStorage;
89
+ private scheduleRecovery;
83
90
  private uploadLock;
84
91
  private confirmationLock;
85
92
  private roomLock;
@@ -4,7 +4,7 @@
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
6
  import { requiredOperationID } from "../core/id.js";
7
- import { PersistentQueue, } from "../core/queue.js";
7
+ import { PersistentQueue, QueueWakeup, nextQueueRetry, } from "../core/queue.js";
8
8
  import { newOptimisticChat } from "./optimistic.js";
9
9
  export const ChatSendStatus = {
10
10
  Saving: "saving",
@@ -17,7 +17,8 @@ export const ChatSendStatus = {
17
17
  UploadInterrupted: "upload_interrupted",
18
18
  };
19
19
  // Persistent creation FIFO per account and room. Creation never advances beyond
20
- // a failed/uncertain head. Retrieval, other rooms and other users are independent.
20
+ // an uncertain head: it recovers automatically with its original request key.
21
+ // Retrieval, attachments, other rooms and other users are independent.
21
22
  export class ChatSendQueue {
22
23
  opts;
23
24
  owner = "";
@@ -36,6 +37,9 @@ export class ChatSendQueue {
36
37
  error;
37
38
  enabled = false;
38
39
  revision = 0;
40
+ wakeup = new QueueWakeup();
41
+ storageRetry;
42
+ pendingCreates = new Map();
39
43
  constructor(opts) {
40
44
  this.opts = opts;
41
45
  this.queue = new PersistentQueue(opts.store, opts.lock);
@@ -58,7 +62,8 @@ export class ChatSendQueue {
58
62
  }
59
63
  async start(ownerID) {
60
64
  if (this.enabled && this.owner === ownerID) {
61
- await this.refresh();
65
+ this.persist();
66
+ await this.writes;
62
67
  return;
63
68
  }
64
69
  this.pause();
@@ -72,11 +77,13 @@ export class ChatSendQueue {
72
77
  this.enabled = ownerID !== "";
73
78
  this.controller = new AbortController();
74
79
  this.publish();
75
- await this.refresh();
80
+ this.persist();
81
+ await this.writes;
76
82
  }
77
83
  pause() {
78
84
  this.enabled = false;
79
85
  this.controller.abort();
86
+ this.wakeup.cancel();
80
87
  }
81
88
  enqueue(input, attachments) {
82
89
  if (!this.enabled || !this.owner) {
@@ -106,23 +113,28 @@ export class ChatSendQueue {
106
113
  this.writes = this.writes.then(async () => {
107
114
  const blocked = new Set();
108
115
  for (const entry of this.pendingSaves.values()) {
109
- if (entry.status !== ChatSendStatus.Saving) {
116
+ if (!this.current(entry.owner_id, this.controller.signal))
117
+ continue;
118
+ if (this.waitForRetry(entry) || blocked.has(entry.owner_id)) {
110
119
  blocked.add(entry.owner_id);
111
- }
112
- if (blocked.has(entry.owner_id)) {
113
120
  continue;
114
121
  }
115
122
  try {
116
123
  const saved = await this.queue.enqueue({
117
124
  ...entry,
118
125
  status: ChatSendStatus.Queued,
126
+ retry: undefined,
119
127
  });
120
128
  this.pendingSaves.delete(entry.id);
121
129
  this.replace(saved);
122
130
  this.opts.changed?.();
123
131
  }
124
132
  catch (err) {
125
- const failed = { ...entry, status: ChatSendStatus.Failed };
133
+ const failed = {
134
+ ...entry,
135
+ status: ChatSendStatus.Failed,
136
+ retry: nextQueueRetry(entry.retry),
137
+ };
126
138
  this.pendingSaves.set(entry.id, failed);
127
139
  this.replace(failed);
128
140
  if (entry.owner_id === this.owner) {
@@ -130,6 +142,7 @@ export class ChatSendQueue {
130
142
  this.publish();
131
143
  }
132
144
  blocked.add(entry.owner_id);
145
+ this.waitForRetry(failed);
133
146
  }
134
147
  }
135
148
  await this.refresh();
@@ -166,7 +179,10 @@ export class ChatSendQueue {
166
179
  }
167
180
  }
168
181
  this.error = undefined;
182
+ this.storageRetry = undefined;
169
183
  this.publish();
184
+ if (stored.length === 0 && this.pendingSaves.size === 0)
185
+ this.wakeup.cancel();
170
186
  for (const room of new Set(stored
171
187
  .filter((entry) => !entry.chat && !entry.server_id)
172
188
  .map((entry) => entry.input.chat_room_id))) {
@@ -185,6 +201,7 @@ export class ChatSendQueue {
185
201
  if (this.current(owner, signal)) {
186
202
  this.error = err;
187
203
  this.publish();
204
+ this.recoverStorage();
188
205
  }
189
206
  }
190
207
  }
@@ -197,7 +214,11 @@ export class ChatSendQueue {
197
214
  if (entry.status !== ChatSendStatus.Failed) {
198
215
  return;
199
216
  }
200
- const saving = { ...entry, status: ChatSendStatus.Saving };
217
+ const saving = {
218
+ ...entry,
219
+ status: ChatSendStatus.Saving,
220
+ retry: undefined,
221
+ };
201
222
  this.pendingSaves.set(id, saving);
202
223
  this.replace(saving);
203
224
  this.persist();
@@ -221,6 +242,7 @@ export class ChatSendQueue {
221
242
  else {
222
243
  await this.save({
223
244
  ...current,
245
+ retry: undefined,
224
246
  status: current.server_id
225
247
  ? ChatSendStatus.Confirming
226
248
  : ChatSendStatus.Queued,
@@ -266,6 +288,7 @@ export class ChatSendQueue {
266
288
  if (stored && !chatSendCanRemove(stored))
267
289
  return;
268
290
  await this.opts.store.remove(id);
291
+ this.pendingCreates.delete(id);
269
292
  this.entries = this.entries.filter((value) => value.id !== id);
270
293
  this.publish();
271
294
  this.opts.changed?.();
@@ -290,6 +313,7 @@ export class ChatSendQueue {
290
313
  if (this.current(owner, signal)) {
291
314
  this.error = err;
292
315
  this.publish();
316
+ this.recoverStorage();
293
317
  }
294
318
  },
295
319
  on_idle: () => {
@@ -301,54 +325,65 @@ export class ChatSendQueue {
301
325
  if (!this.current(owner, signal) || this.cancellations.has(entry.id)) {
302
326
  return false;
303
327
  }
304
- if (entry.status === ChatSendStatus.Sending) {
305
- await this.save({ ...entry, status: ChatSendStatus.Interrupted });
328
+ // A persisted Sending marker is an interrupted request once we own
329
+ // the room lock. Reuse its key; never skip an unknown creation outcome.
330
+ let current = this.pendingCreates.get(entry.id) ?? entry;
331
+ if (this.waitForRetry(current))
306
332
  return false;
307
- }
308
- else if (entry.status !== ChatSendStatus.Queued) {
309
- return false;
310
- }
311
- let current = entry;
312
333
  const request = new AbortController();
313
334
  const requestSignal = AbortSignal.any([signal, request.signal]);
314
335
  this.sends.set(entry.id, request);
315
336
  try {
316
- current = {
317
- ...entry,
318
- status: ChatSendStatus.Sending,
319
- attempted: true,
320
- };
321
- await this.save(current);
322
- if (!this.current(owner, signal)) {
323
- return false;
337
+ if (!current.server_id) {
338
+ current = {
339
+ ...current,
340
+ status: ChatSendStatus.Sending,
341
+ attempted: true,
342
+ };
343
+ await this.save(current);
344
+ if (!this.current(owner, signal))
345
+ return false;
346
+ requestSignal.throwIfAborted();
347
+ const out = await this.opts.chat.insertOneChat({
348
+ in: current.input,
349
+ signal: requestSignal,
350
+ idempotency_key: current.id,
351
+ });
352
+ current = {
353
+ ...current,
354
+ server_id: requiredOperationID(out, "Created chat response did not contain an ID."),
355
+ };
356
+ this.pendingCreates.set(current.id, current);
324
357
  }
325
- requestSignal.throwIfAborted();
326
- const out = await this.opts.chat.insertOneChat({
327
- in: current.input,
328
- signal: requestSignal,
329
- idempotency_key: current.id,
330
- });
331
358
  current = {
332
359
  ...current,
333
360
  status: ChatSendStatus.Confirming,
334
- server_id: requiredOperationID(out, "Created chat response did not contain an ID."),
361
+ retry: undefined,
335
362
  };
336
363
  // Persist successful creation before advancing the room's FIFO. A
337
364
  // later retrieval failure must never cause this POST to be repeated.
338
365
  await this.save(current);
366
+ this.pendingCreates.delete(current.id);
339
367
  if (!this.current(owner, signal)) {
340
368
  return false;
341
369
  }
342
370
  this.startConfirmation(current, owner, signal);
343
371
  }
344
372
  catch (err) {
373
+ current = {
374
+ ...current,
375
+ status: requestSignal.aborted
376
+ ? ChatSendStatus.Interrupted
377
+ : ChatSendStatus.Failed,
378
+ retry: requestSignal.aborted
379
+ ? undefined
380
+ : nextQueueRetry(current.retry),
381
+ };
382
+ if (current.server_id)
383
+ this.pendingCreates.set(current.id, current);
345
384
  try {
346
- await this.save({
347
- ...current,
348
- status: requestSignal.aborted
349
- ? ChatSendStatus.Interrupted
350
- : ChatSendStatus.Failed,
351
- });
385
+ await this.save(current);
386
+ this.pendingCreates.delete(current.id);
352
387
  }
353
388
  catch (storageError) {
354
389
  this.error = storageError;
@@ -356,6 +391,8 @@ export class ChatSendQueue {
356
391
  if (this.current(owner, signal) && !request.signal.aborted) {
357
392
  this.error = err;
358
393
  this.publish();
394
+ if (current.retry)
395
+ this.scheduleRecovery(current.retry.after);
359
396
  }
360
397
  return false;
361
398
  }
@@ -369,7 +406,7 @@ export class ChatSendQueue {
369
406
  // Retrieval is independent of the room's creation lock. Its per-message
370
407
  // lock coordinates retries/recovery across tabs without holding later POSTs.
371
408
  startConfirmation(entry, owner, signal) {
372
- if (this.confirmations.has(entry.id)) {
409
+ if (this.confirmations.has(entry.id) || this.waitForRetry(entry)) {
373
410
  return;
374
411
  }
375
412
  this.confirmations.add(entry.id);
@@ -379,7 +416,9 @@ export class ChatSendQueue {
379
416
  return;
380
417
  }
381
418
  const stored = (await this.opts.store.list(owner)).find((value) => value.id === entry.id);
382
- if (!stored || !chatSendCanConfirm(stored)) {
419
+ if (!stored ||
420
+ !chatSendCanConfirm(stored) ||
421
+ this.waitForRetry(stored)) {
383
422
  return;
384
423
  }
385
424
  let current = stored;
@@ -406,6 +445,7 @@ export class ChatSendQueue {
406
445
  ...current,
407
446
  chat,
408
447
  status: ChatSendStatus.UploadInterrupted,
448
+ retry: undefined,
409
449
  };
410
450
  await this.save(current);
411
451
  this.startUpload(current, owner, signal);
@@ -418,8 +458,15 @@ export class ChatSendQueue {
418
458
  }
419
459
  }
420
460
  catch (err) {
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
+ }
421
467
  await this.save({
422
468
  ...current,
469
+ retry,
423
470
  status: current.chat
424
471
  ? ChatSendStatus.UploadInterrupted
425
472
  : signal.aborted
@@ -436,6 +483,7 @@ export class ChatSendQueue {
436
483
  if (this.current(owner, signal)) {
437
484
  this.error = err;
438
485
  this.publish();
486
+ this.recoverStorage();
439
487
  }
440
488
  })
441
489
  .finally(() => {
@@ -563,6 +611,25 @@ export class ChatSendQueue {
563
611
  })
564
612
  .finally(() => this.uploads.delete(entry.id));
565
613
  }
614
+ waitForRetry(entry) {
615
+ if (!entry.retry || entry.retry.after <= Date.now())
616
+ return false;
617
+ this.scheduleRecovery(entry.retry.after);
618
+ return true;
619
+ }
620
+ recoverStorage() {
621
+ this.storageRetry = nextQueueRetry(this.storageRetry);
622
+ this.scheduleRecovery(this.storageRetry.after);
623
+ }
624
+ scheduleRecovery(after) {
625
+ if (!this.enabled)
626
+ return;
627
+ this.wakeup.schedule(after, this.controller.signal, () => {
628
+ // Persist also refreshes the durable queue. It is app-owned, never
629
+ // dependent on a mounted chat tool or a presentation subscription.
630
+ this.persist();
631
+ });
632
+ }
566
633
  uploadLock(entry) {
567
634
  return `chat-upload:${entry.owner_id}:${entry.id}`;
568
635
  }
@@ -614,7 +681,8 @@ function chatSendCanConfirm(entry) {
614
681
  (entry.status === ChatSendStatus.Confirming ||
615
682
  entry.status === ChatSendStatus.Sending ||
616
683
  entry.status === ChatSendStatus.Queued ||
617
- entry.status === ChatSendStatus.Interrupted));
684
+ entry.status === ChatSendStatus.Interrupted ||
685
+ entry.status === ChatSendStatus.Failed));
618
686
  }
619
687
  export function chatSendNeedsRetry(entry) {
620
688
  return (entry.status === ChatSendStatus.Failed ||
@@ -1,4 +1,5 @@
1
1
  import type { Date as SchemaDate, InDate } from "@fabriktor/schema";
2
+ export declare const utcTimezone = "UTC";
2
3
  export type LocalDateParts = {
3
4
  y: number;
4
5
  m: number;
@@ -12,3 +13,6 @@ export declare function zeroDate(): SchemaDate;
12
13
  export declare function isZeroDate(v: LocalDateParts): boolean;
13
14
  export declare function isZeroDateTime(value: string | null | undefined): boolean;
14
15
  export declare function isZeroSchemaDate(value: SchemaDate | null | undefined): boolean;
16
+ export declare function dateTimezone(timezone: string): string;
17
+ export declare function inDateAtTimestamp(timestamp: string, timezone?: string): InDate | null;
18
+ export declare function timestampDate(timestamp: string): globalThis.Date | null;
@@ -16,7 +16,7 @@ const zeroDatePrefix = [
16
16
  String(zeroDateParts.d).padStart(2, "0"),
17
17
  ].join("-");
18
18
  const zeroUTC = `${zeroDatePrefix}T00:00:00Z`;
19
- const utcTimezone = "UTC";
19
+ export const utcTimezone = "UTC";
20
20
  export function normalizeInputDate(v) {
21
21
  if (isZeroDate(v)) {
22
22
  return zeroInputDate();
@@ -53,3 +53,48 @@ export function isZeroSchemaDate(value) {
53
53
  value === undefined ||
54
54
  (isZeroDate(value) && isZeroDateTime(value.utc)));
55
55
  }
56
+ // Use the account/location timezone explicitly; never consult the browser's
57
+ // timezone when interpreting a persisted UTC timestamp.
58
+ export function dateTimezone(timezone) {
59
+ const value = timezone.trim();
60
+ if (!value)
61
+ return utcTimezone;
62
+ try {
63
+ new Intl.DateTimeFormat("en", { timeZone: value });
64
+ return value;
65
+ }
66
+ catch {
67
+ return utcTimezone;
68
+ }
69
+ }
70
+ export function inDateAtTimestamp(timestamp, timezone = utcTimezone) {
71
+ const instant = timestampDate(timestamp);
72
+ if (!instant)
73
+ return null;
74
+ const tz = dateTimezone(timezone);
75
+ const parts = new Intl.DateTimeFormat("en-CA", {
76
+ timeZone: tz,
77
+ year: "numeric",
78
+ month: "2-digit",
79
+ day: "2-digit",
80
+ hour: "2-digit",
81
+ minute: "2-digit",
82
+ hourCycle: "h23",
83
+ }).formatToParts(instant);
84
+ const part = (type) => Number(parts.find((value) => value.type === type)?.value);
85
+ return {
86
+ y: part("year"),
87
+ m: part("month"),
88
+ d: part("day"),
89
+ h: part("hour"),
90
+ min: part("minute"),
91
+ tz,
92
+ };
93
+ }
94
+ export function timestampDate(timestamp) {
95
+ if (isZeroDateTime(timestamp) || !/(?:Z|[+-]\d{2}:\d{2})$/i.test(timestamp)) {
96
+ return null;
97
+ }
98
+ const instant = new globalThis.Date(timestamp);
99
+ return Number.isFinite(instant.getTime()) ? instant : null;
100
+ }
@@ -3,6 +3,16 @@ export type QueueRecord = {
3
3
  owner_id: string;
4
4
  sequence?: number;
5
5
  };
6
+ export type QueueRetry = {
7
+ attempts: number;
8
+ after: number;
9
+ };
10
+ export declare function nextQueueRetry(previous?: QueueRetry): QueueRetry;
11
+ export declare class QueueWakeup {
12
+ private pending?;
13
+ schedule(after: number, signal: AbortSignal, run: () => void): void;
14
+ cancel(): void;
15
+ }
6
16
  export interface QueueStore<T extends QueueRecord> {
7
17
  add(entry: T): Promise<T>;
8
18
  put(entry: T): Promise<void>;
@@ -3,6 +3,39 @@
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
+ const queueRetryInitialMilliseconds = 1_000;
7
+ const queueRetryMaximumMilliseconds = 30_000;
8
+ // Durable deadlines prevent refreshes and other tabs from bypassing backoff.
9
+ export function nextQueueRetry(previous) {
10
+ const attempts = (previous?.attempts ?? 0) + 1;
11
+ const delay = Math.min(queueRetryMaximumMilliseconds, queueRetryInitialMilliseconds * 2 ** (attempts - 1));
12
+ return { attempts, after: Date.now() + delay };
13
+ }
14
+ // One wakeup for the earliest pending recovery. A paused account must never
15
+ // be restarted by an old timer. The caller re-reads durable state on wakeup.
16
+ export class QueueWakeup {
17
+ pending;
18
+ schedule(after, signal, run) {
19
+ if (signal.aborted || (this.pending && this.pending.after <= after))
20
+ return;
21
+ this.cancel();
22
+ const cancel = () => {
23
+ clearTimeout(timer);
24
+ signal.removeEventListener("abort", cancel);
25
+ this.pending = undefined;
26
+ };
27
+ const timer = setTimeout(() => {
28
+ cancel();
29
+ if (!signal.aborted)
30
+ run();
31
+ }, Math.max(0, after - Date.now()));
32
+ this.pending = { after, cancel };
33
+ signal.addEventListener("abort", cancel, { once: true });
34
+ }
35
+ cancel() {
36
+ this.pending?.cancel();
37
+ }
38
+ }
6
39
  // Store order is authoritative across views/tabs. Each scope has one worker;
7
40
  // run must durably retire the head before returning true. False pauses the
8
41
  // lane without blocking other scopes. Storage/transport failures never skip it.
@@ -0,0 +1,30 @@
1
+ import { type SearchOptions, type SearchPage, type SearchResultWithID } from "./search.js";
2
+ export type SearchState<T> = {
3
+ readonly results: readonly T[];
4
+ readonly ready: boolean;
5
+ readonly loading: boolean;
6
+ readonly loading_more: boolean;
7
+ readonly error: unknown;
8
+ readonly has_more: boolean;
9
+ readonly total_count?: number;
10
+ };
11
+ export type SearchControllerOptions<T, Options extends SearchOptions> = {
12
+ search: (opts: Options) => Promise<SearchPage<T>>;
13
+ on_change: (state: SearchState<T>) => void;
14
+ };
15
+ export declare class SearchController<T extends SearchResultWithID, Options extends SearchOptions> {
16
+ private readonly options;
17
+ private selection?;
18
+ private request?;
19
+ private next_page;
20
+ private current;
21
+ constructor(options: SearchControllerOptions<T, Options>);
22
+ get state(): SearchState<T>;
23
+ clear(): void;
24
+ replace(selection: Options): Promise<boolean>;
25
+ loadMore(): Promise<boolean>;
26
+ retry(): Promise<boolean>;
27
+ private load;
28
+ private emptyState;
29
+ private publish;
30
+ }
@@ -0,0 +1,111 @@
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
+ import { appendUniqueSearchResults, normalizeSearchPage, } from "./search.js";
7
+ import { errUnknown } from "./err.js";
8
+ // Holds only the current selection and its displayed pages. Previous queries
9
+ // are discarded, and revisiting a selection always makes a backend request.
10
+ export class SearchController {
11
+ options;
12
+ selection;
13
+ request;
14
+ next_page = normalizeSearchPage();
15
+ current = this.emptyState();
16
+ constructor(options) {
17
+ this.options = options;
18
+ }
19
+ get state() {
20
+ return this.current;
21
+ }
22
+ // Invalidate immediately, including during text-search debounce or teardown.
23
+ clear() {
24
+ this.request?.abort();
25
+ this.request = undefined;
26
+ this.selection = undefined;
27
+ this.next_page = normalizeSearchPage();
28
+ this.publish(this.emptyState());
29
+ }
30
+ async replace(selection) {
31
+ this.clear();
32
+ this.selection = { ...selection };
33
+ return await this.load(false);
34
+ }
35
+ async loadMore() {
36
+ if (!this.current.has_more || this.current.error !== undefined) {
37
+ return false;
38
+ }
39
+ return await this.load(true);
40
+ }
41
+ async retry() {
42
+ return await this.load(this.next_page > normalizeSearchPage());
43
+ }
44
+ async load(append) {
45
+ if (!this.selection || this.current.loading || this.current.loading_more) {
46
+ return false;
47
+ }
48
+ const request = new AbortController();
49
+ this.request = request;
50
+ this.publish({
51
+ ...this.current,
52
+ loading: !append,
53
+ loading_more: append,
54
+ error: undefined,
55
+ });
56
+ try {
57
+ const page = await this.options.search({
58
+ ...this.selection,
59
+ page: this.next_page,
60
+ signal: request.signal,
61
+ });
62
+ if (this.request !== request || request.signal.aborted) {
63
+ return false;
64
+ }
65
+ this.next_page = page.next_page;
66
+ this.publish({
67
+ results: append
68
+ ? appendUniqueSearchResults(this.current.results, page.results)
69
+ : page.results,
70
+ ready: true,
71
+ loading: false,
72
+ loading_more: false,
73
+ error: undefined,
74
+ has_more: page.has_more,
75
+ total_count: page.total_count,
76
+ });
77
+ return true;
78
+ }
79
+ catch (error) {
80
+ if (this.request !== request || request.signal.aborted) {
81
+ return false;
82
+ }
83
+ this.publish({
84
+ ...this.current,
85
+ loading: false,
86
+ loading_more: false,
87
+ error: errUnknown(error),
88
+ });
89
+ return false;
90
+ }
91
+ finally {
92
+ if (this.request === request) {
93
+ this.request = undefined;
94
+ }
95
+ }
96
+ }
97
+ emptyState() {
98
+ return {
99
+ results: [],
100
+ ready: false,
101
+ loading: false,
102
+ loading_more: false,
103
+ error: undefined,
104
+ has_more: false,
105
+ };
106
+ }
107
+ publish(state) {
108
+ this.current = state;
109
+ this.options.on_change(state);
110
+ }
111
+ }
@@ -4,7 +4,7 @@ export type SearchPaginationOptions = {
4
4
  page?: number;
5
5
  per_page?: number;
6
6
  };
7
- export type SearchOptions = SearchPaginationOptions & {
7
+ export type SearchOptions = SearchPaginationOptions & Pick<SearchManyOptionsCRUD, "signal"> & {
8
8
  q?: string;
9
9
  };
10
10
  export type RequiredSearchOptions = SearchPaginationOptions & {
@@ -54,3 +54,7 @@ export declare function runSearch<T>(opts: RunSearchOptions<T>): Promise<SearchP
54
54
  export declare function normalizeSearchPage(value?: number): number;
55
55
  export declare function normalizeSearchPerPage(value?: number): number;
56
56
  export declare function normalizeSearchQuery(value?: string): string | undefined;
57
+ export type SearchResultWithID = {
58
+ readonly id: string;
59
+ };
60
+ export declare function appendUniqueSearchResults<T extends SearchResultWithID>(current: readonly T[], next: readonly T[]): T[];
@@ -48,6 +48,7 @@ export async function runOwnerScopeSearch(opts) {
48
48
  per_page,
49
49
  query: opts.query,
50
50
  search: opts.search,
51
+ signal: opts.signal,
51
52
  })));
52
53
  const results = new Map();
53
54
  for (const ownerPage of pages) {
@@ -94,6 +95,7 @@ export async function runOwnerScopeFilteredSearch(opts) {
94
95
  per_page,
95
96
  query: opts.query,
96
97
  search: opts.search,
98
+ signal: opts.signal,
97
99
  }),
98
100
  })));
99
101
  for (const owner_page of pages) {
@@ -147,6 +149,7 @@ export async function runSearch(opts) {
147
149
  };
148
150
  const raw = await opts.search({
149
151
  query,
152
+ signal: opts.signal,
150
153
  });
151
154
  return resolveSearchPage({
152
155
  raw,
@@ -236,3 +239,17 @@ function maxDefined(values) {
236
239
  }
237
240
  return Math.max(...values.map((value) => value ?? 0));
238
241
  }
242
+ export function appendUniqueSearchResults(current, next) {
243
+ const out = [...current];
244
+ const indexes = new Map(out.map((item, index) => [item.id, index]));
245
+ for (const item of next) {
246
+ const index = indexes.get(item.id);
247
+ if (index === undefined) {
248
+ indexes.set(item.id, out.length);
249
+ out.push(item);
250
+ continue;
251
+ }
252
+ out[index] = item;
253
+ }
254
+ return out;
255
+ }
@@ -12,6 +12,7 @@ export * from "./core/pre-action.js";
12
12
  export * from "./core/pull.js";
13
13
  export * from "./core/queue-store.js";
14
14
  export * from "./core/queue.js";
15
+ export * from "./core/search-controller.js";
15
16
  export * from "./core/search.js";
16
17
  export * from "./feed/feed.js";
17
18
  export * from "./fulfillments/fulfillments.js";
package/dist/src/index.js CHANGED
@@ -12,6 +12,7 @@ export * from "./core/pre-action.js";
12
12
  export * from "./core/pull.js";
13
13
  export * from "./core/queue-store.js";
14
14
  export * from "./core/queue.js";
15
+ export * from "./core/search-controller.js";
15
16
  export * from "./core/search.js";
16
17
  export * from "./feed/feed.js";
17
18
  export * from "./fulfillments/fulfillments.js";
@@ -1,5 +1,5 @@
1
- import type { MemosOperator } from "@fabriktor/client";
2
- import type { Memo, MemoFolder } from "@fabriktor/schema";
1
+ import { type MemosOperator } from "@fabriktor/client";
2
+ import { type Memo, type MemoFolder } from "@fabriktor/schema";
3
3
  import { type OwnerSearchOptions, type SearchPage } from "../core/search.js";
4
4
  type MemoInsertOptions = Parameters<MemosOperator["insertOneMemo"]>[0];
5
5
  type MemoReplaceOptions = Parameters<MemosOperator["replaceOneMemo"]>[0];
@@ -9,7 +9,7 @@ export type CreateMemoOptions = MemoInsertOptions;
9
9
  export type ReplaceMemoOptions = MemoReplaceOptions;
10
10
  export type CreateMemoFolderOptions = MemoFolderInsertOptions;
11
11
  export type ReplaceMemoFolderOptions = MemoFolderReplaceOptions;
12
- export type SearchMemosOptions = OwnerSearchOptions;
12
+ export type SearchMemosOptions = OwnerSearchOptions & Partial<Pick<Memo, "is_archived" | "folder_id">>;
13
13
  export type SearchMemoFoldersOptions = OwnerSearchOptions;
14
14
  export type SearchMemosPage = SearchPage<Memo>;
15
15
  export type SearchMemoFoldersPage = SearchPage<MemoFolder>;
@@ -3,13 +3,16 @@
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 { errResolveFailed } from "../core/err.js";
7
- import { requiredOperationID } from "../core/id.js";
6
+ import { queryParams } from "@fabriktor/client";
7
+ import { OwnerIDKey } from "@fabriktor/schema";
8
+ import { errRequired, errResolveFailed } from "../core/err.js";
9
+ import { requiredOperationID, resolvedID } from "../core/id.js";
8
10
  import { runOwnerSearch, } from "../core/search.js";
9
11
  const resourceMemo = "memo";
10
12
  const resourceMemoFolder = "memo_folder";
11
13
  const msgCreatedMemoIDMissing = "Created memo response did not contain an ID.";
12
14
  const msgMemoMissing = "Memo could not be resolved after the mutation.";
15
+ const msgMemoOwnerRequired = "Memo owner is required.";
13
16
  const msgCreatedMemoFolderIDMissing = "Created memo folder response did not contain an ID.";
14
17
  const msgMemoFolderMissing = "Memo folder could not be resolved after the mutation.";
15
18
  export class MemosFX {
@@ -36,8 +39,18 @@ export class MemosFX {
36
39
  return await this.findMemoFolder(opts.id);
37
40
  }
38
41
  async searchMemos(opts) {
42
+ const { is_archived, folder_id, ...searchOpts } = opts;
43
+ const owner_id = resolvedID(opts.owner_id);
44
+ if (owner_id === undefined) {
45
+ throw errRequired({ field: OwnerIDKey, msg: msgMemoOwnerRequired });
46
+ }
39
47
  return await runOwnerSearch({
40
- ...opts,
48
+ ...searchOpts,
49
+ owner_id,
50
+ query: queryParams({
51
+ is_archived,
52
+ folder_id: resolvedID(folder_id),
53
+ }),
41
54
  search: (searchOpts) => this.memos.searchManyMemos(searchOpts),
42
55
  });
43
56
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabriktor/fx",
3
- "version": "0.0.104",
3
+ "version": "0.0.106",
4
4
  "description": "![Fabriktor Logo](./img/fabriktor-character.gif)",
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,7 +17,7 @@
17
17
  "typescript": "^6.0.3"
18
18
  },
19
19
  "dependencies": {
20
- "@fabriktor/client": "0.0.82",
20
+ "@fabriktor/client": "0.0.83",
21
21
  "@fabriktor/fx": "0.0.94",
22
22
  "@fabriktor/schema": "0.0.63"
23
23
  }