@dereekb/firebase 13.42.0 → 14.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/eslint/index.esm.js +209 -302
  2. package/eslint/package.json +9 -9
  3. package/index.esm.js +5774 -3926
  4. package/package.json +10 -10
  5. package/src/lib/common/firestore/accessor/document.rxjs.d.ts +0 -4
  6. package/src/lib/common/firestore/query/accumulator.d.ts +2 -2
  7. package/src/lib/common/firestore/query/iterator.d.ts +1 -1
  8. package/src/lib/common/model/function.d.ts +0 -14
  9. package/src/lib/model/formspace/formspace.access.d.ts +139 -0
  10. package/src/lib/model/formspace/formspace.action.d.ts +34 -0
  11. package/src/lib/model/formspace/formspace.api.d.ts +240 -0
  12. package/src/lib/model/formspace/formspace.api.error.d.ts +96 -0
  13. package/src/lib/model/formspace/formspace.d.ts +461 -0
  14. package/src/lib/model/formspace/formspace.id.d.ts +60 -0
  15. package/src/lib/model/formspace/formspace.permission.d.ts +47 -0
  16. package/src/lib/model/formspace/formspace.processing.d.ts +86 -0
  17. package/src/lib/model/formspace/formspace.query.d.ts +110 -0
  18. package/src/lib/model/formspace/formspace.task.d.ts +136 -0
  19. package/src/lib/model/formspace/formspace.type.d.ts +300 -0
  20. package/src/lib/model/formspace/formspace.upload.d.ts +204 -0
  21. package/src/lib/model/formspace/formspace.util.d.ts +514 -0
  22. package/src/lib/model/formspace/index.d.ts +13 -0
  23. package/src/lib/model/index.d.ts +1 -0
  24. package/src/lib/model/notification/notification.task.d.ts +3 -1
  25. package/src/lib/model/storagefile/storagefile.api.d.ts +19 -0
  26. package/src/lib/model/storagefile/storagefile.file.d.ts +42 -2
  27. package/src/lib/model/storagefile/storagefile.upload.d.ts +30 -0
  28. package/test/index.esm.js +158 -258
  29. package/test/package.json +8 -8
  30. package/test/src/lib/common/firebase.instance.d.ts +0 -4
  31. package/test/src/lib/common/firestore/firestore.instance.d.ts +0 -4
  32. package/test/src/lib/common/mock/mock.item.collection.fixture.d.ts +0 -7
  33. package/test/src/lib/common/storage/storage.instance.d.ts +0 -4
@@ -0,0 +1,514 @@
1
+ import { type ContentTypeMimeType, type Maybe } from '@dereekb/util';
2
+ import { type FirebaseAuthOwnershipKey, type FirebaseAuthUserId } from '../../common/auth/auth';
3
+ import { type FirestoreModelKey } from '../../common/firestore/collection/collection';
4
+ import { type StorageFileGroupId } from '../storagefile/storagefile.id';
5
+ import { type FormSpace, type FormSpaceData, type FormSpaceFile } from './formspace';
6
+ import { type FormSpaceFileSlot, type FormSpaceKey, type FormSpaceType } from './formspace.id';
7
+ import { type FormSpaceFileSlotConfig, type FormSpaceTypeConfig } from './formspace.type';
8
+ /**
9
+ * @module formspace.util
10
+ *
11
+ * Pure helpers shared by the client and the server: the write templates for each lifecycle transition, and
12
+ * the upload predicate.
13
+ *
14
+ * {@link assertFormSpaceUploadAllowed} in particular is deliberately PURE and lives here rather than in
15
+ * `firebase-server`: the client pre-checks a file with it before asking for a signed URL, and the server's
16
+ * upload initializer enforces the very same function afterwards. One rule, two callers — a client-side copy
17
+ * that drifted would show the user an accept for a file the server then silently discards.
18
+ *
19
+ * A file's NAME is not one of the rules. It used to be: two files of one name in a slot resolved to the
20
+ * same destination object, so the second silently overwrote the first. The destination is now keyed by the
21
+ * space's `fi` index instead, so two files of one name are two objects, and the name is free to be
22
+ * whatever the user uploaded.
23
+ */
24
+ /**
25
+ * Returns the {@link StorageFileGroupId} that owns every file uploaded into a FormSpace.
26
+ *
27
+ * The group is keyed by the FormSpace's own model key, so the existing sync machinery creates it on the
28
+ * first upload and the existing zip / cleanup machinery applies with no FormSpace-specific code.
29
+ *
30
+ * @param formSpaceKey - The FormSpace's model key.
31
+ * @returns The group id.
32
+ *
33
+ * @example
34
+ * ```ts
35
+ * const groupId = formSpaceStorageFileGroupId('fsp/abc123'); // 'fsp_abc123'
36
+ * ```
37
+ */
38
+ export declare function formSpaceStorageFileGroupId(formSpaceKey: FormSpaceKey): StorageFileGroupId;
39
+ /**
40
+ * Input for {@link resolveFormSpaceExpiresAt}.
41
+ */
42
+ export interface ResolveFormSpaceExpiresAtInput {
43
+ readonly config: FormSpaceTypeConfig;
44
+ /**
45
+ * The instant the space is being created at. Defaults to now.
46
+ */
47
+ readonly now?: Maybe<Date>;
48
+ }
49
+ /**
50
+ * Returns the instant a newly created FormSpace of the given type expires at, or null when its type never
51
+ * expires.
52
+ *
53
+ * Null is meaningful rather than merely absent: it is what leaves `eat` unwritten, and an unwritten `eat`
54
+ * is what excludes the space from the sweep's inequality query.
55
+ *
56
+ * @param input - The type config and creation instant.
57
+ * @returns The expiration instant, or null when the type does not expire.
58
+ *
59
+ * @__NO_SIDE_EFFECTS__
60
+ */
61
+ export declare function resolveFormSpaceExpiresAt(input: ResolveFormSpaceExpiresAtInput): Maybe<Date>;
62
+ /**
63
+ * Input for {@link formSpaceTemplate}.
64
+ */
65
+ export interface FormSpaceTemplateInput<T extends FormSpaceData = FormSpaceData> {
66
+ readonly formSpaceType: FormSpaceType;
67
+ readonly uid: FirebaseAuthUserId;
68
+ readonly ownerKey?: Maybe<FirebaseAuthOwnershipKey>;
69
+ readonly targetModelKey?: Maybe<FirestoreModelKey>;
70
+ readonly displayName?: Maybe<string>;
71
+ readonly data?: Maybe<T>;
72
+ readonly expiresAt?: Maybe<Date>;
73
+ /**
74
+ * The creation instant. Defaults to now.
75
+ */
76
+ readonly now?: Maybe<Date>;
77
+ }
78
+ /**
79
+ * Builds the complete document template for a newly created FormSpace.
80
+ *
81
+ * @param input - The type, owner, and initial content of the space.
82
+ * @returns The FormSpace template.
83
+ *
84
+ * @example
85
+ * ```ts
86
+ * const template = formSpaceTemplate({ formSpaceType: 'demo_example', uid: 'user123' });
87
+ * ```
88
+ *
89
+ * @__NO_SIDE_EFFECTS__
90
+ */
91
+ export declare function formSpaceTemplate<T extends FormSpaceData = FormSpaceData>(input: FormSpaceTemplateInput<T>): FormSpace<T>;
92
+ /**
93
+ * Input for {@link submitFormSpaceTemplate}.
94
+ */
95
+ export interface SubmitFormSpaceTemplateInput {
96
+ /**
97
+ * The space being submitted. Read to tell a FIRST submission from a resubmission after a reopen.
98
+ */
99
+ readonly formSpace: Pick<FormSpace, 'fsat'>;
100
+ readonly config: FormSpaceTypeConfig;
101
+ /**
102
+ * The submission instant. Defaults to now.
103
+ */
104
+ readonly now?: Maybe<Date>;
105
+ }
106
+ /**
107
+ * Builds the update template that submits a FormSpace.
108
+ *
109
+ * Clearing `eat` is not tidiness: a submitted space that kept its expiration instant would still match the
110
+ * expiration sweep and be retired out from under the processing task.
111
+ *
112
+ * `fsat` and the lock deadline `lat` are written ONLY on the first submission and are left untouched by a
113
+ * resubmission. That asymmetry is the whole first-submit anchor: recomputing `lat` here would let a
114
+ * reopen/resubmit round walk the deadline forward indefinitely, which is precisely what
115
+ * {@link FormSpaceTypeConfig.reopenableUntil} exists to prevent.
116
+ *
117
+ * @param input - The space, its type config, and the submission instant.
118
+ * @returns The update template.
119
+ *
120
+ * @__NO_SIDE_EFFECTS__
121
+ */
122
+ export declare function submitFormSpaceTemplate(input: SubmitFormSpaceTemplateInput): Partial<FormSpace>;
123
+ /**
124
+ * Input for {@link resolveFormSpaceLocksAt}.
125
+ */
126
+ export interface ResolveFormSpaceLocksAtInput {
127
+ readonly config: FormSpaceTypeConfig;
128
+ /**
129
+ * The instant the space was first submitted — the anchor the ceiling is measured from.
130
+ */
131
+ readonly firstSubmittedAt: Date;
132
+ }
133
+ /**
134
+ * Returns the instant a submitted FormSpace of the given type becomes permanently locked, or null when its
135
+ * type declares no ceiling.
136
+ *
137
+ * The mirror of {@link resolveFormSpaceExpiresAt}, and null is meaningful in the same way: it is what
138
+ * leaves `lat` unwritten, and an unwritten `lat` is what leaves the type's `reopenableFor` rolling from
139
+ * each submission rather than capped.
140
+ *
141
+ * @param input - The type config and the first-submission instant.
142
+ * @returns The lock instant, or null when the type declares no ceiling.
143
+ *
144
+ * @__NO_SIDE_EFFECTS__
145
+ */
146
+ export declare function resolveFormSpaceLocksAt(input: ResolveFormSpaceLocksAtInput): Maybe<Date>;
147
+ /**
148
+ * Builds the update template that expires a FormSpace.
149
+ *
150
+ * @param now - The expiration instant. Defaults to now.
151
+ * @returns The update template.
152
+ *
153
+ * @__NO_SIDE_EFFECTS__
154
+ */
155
+ export declare function expireFormSpaceTemplate(now?: Maybe<Date>): Partial<FormSpace>;
156
+ /**
157
+ * Input for {@link isFormSpaceEditable}.
158
+ */
159
+ export interface IsFormSpaceEditableInput {
160
+ readonly formSpace: Pick<FormSpace, 's' | 'sat' | 'eat'>;
161
+ /**
162
+ * The instant to judge against. Defaults to now.
163
+ */
164
+ readonly now?: Maybe<Date>;
165
+ }
166
+ /**
167
+ * Returns true when a FormSpace may still be edited or uploaded into.
168
+ *
169
+ * Checks the expiration instant as well as the state, so a space whose sweep has not run yet is already
170
+ * closed. The sweep is what RETIRES the document; it is not what makes it un-editable.
171
+ *
172
+ * @param input - The space and the instant to judge against.
173
+ * @returns True when the space is editable.
174
+ *
175
+ * @__NO_SIDE_EFFECTS__
176
+ */
177
+ export declare function isFormSpaceEditable(input: IsFormSpaceEditableInput): boolean;
178
+ /**
179
+ * Input for {@link isFormSpaceReopenable}.
180
+ */
181
+ export interface IsFormSpaceReopenableInput {
182
+ readonly formSpace: Pick<FormSpace, 's' | 'sat' | 'lat' | 'rc'>;
183
+ readonly config: FormSpaceTypeConfig;
184
+ /**
185
+ * The instant to judge against. Defaults to now.
186
+ */
187
+ readonly now?: Maybe<Date>;
188
+ }
189
+ /**
190
+ * Returns true when a submitted FormSpace may still be reopened into an editable draft.
191
+ *
192
+ * POLICY ONLY. It answers "does the type still allow this", not "is right now a safe moment" — a space
193
+ * whose processor is mid-run is reopenable by this predicate and refused by the action, because
194
+ * `ps === PROCESSING` is transient and telling a user their space is permanently locked while a task
195
+ * finishes would be a lie. The action owns that check; this owns the window.
196
+ *
197
+ * Requires SUBMITTED, which is what keeps EXPIRED and ARCHIVED terminal for free.
198
+ *
199
+ * @param input - The space, its type config, and the instant to judge against.
200
+ * @returns True when the space may be reopened.
201
+ *
202
+ * @example
203
+ * ```ts
204
+ * const canReopen = isFormSpaceReopenable({ formSpace, config });
205
+ * ```
206
+ *
207
+ * @__NO_SIDE_EFFECTS__
208
+ */
209
+ export declare function isFormSpaceReopenable(input: IsFormSpaceReopenableInput): boolean;
210
+ /**
211
+ * Input for {@link isFormSpaceFullyLocked}.
212
+ */
213
+ export interface IsFormSpaceFullyLockedInput {
214
+ readonly formSpace: Pick<FormSpace, 's' | 'sat' | 'eat' | 'lat' | 'rc'>;
215
+ readonly config: FormSpaceTypeConfig;
216
+ /**
217
+ * The instant to judge against. Defaults to now.
218
+ */
219
+ readonly now?: Maybe<Date>;
220
+ }
221
+ /**
222
+ * Returns true when nothing further can be done to a FormSpace: it is neither editable nor reopenable.
223
+ *
224
+ * Derived from the other two predicates rather than testing the fields itself, so the three answers can
225
+ * never disagree about one space. There is deliberately no FULLY_LOCKED {@link FormSpaceState} — a new
226
+ * enum member would fork every `s === SUBMITTED` check in the framework and downstream, to express
227
+ * something both existing predicates already know.
228
+ *
229
+ * @param input - The space, its type config, and the instant to judge against.
230
+ * @returns True when the space is fully locked.
231
+ *
232
+ * @__NO_SIDE_EFFECTS__
233
+ */
234
+ export declare function isFormSpaceFullyLocked(input: IsFormSpaceFullyLockedInput): boolean;
235
+ /**
236
+ * Input for {@link reopenFormSpaceTemplate}.
237
+ */
238
+ export interface ReopenFormSpaceTemplateInput {
239
+ readonly formSpace: Pick<FormSpace, 'rc' | 'lat'>;
240
+ readonly config: FormSpaceTypeConfig;
241
+ /**
242
+ * The user reopening the space, recorded on `rby`.
243
+ */
244
+ readonly uid?: Maybe<FirebaseAuthUserId>;
245
+ /**
246
+ * The reopen instant. Defaults to now.
247
+ */
248
+ readonly now?: Maybe<Date>;
249
+ }
250
+ /**
251
+ * Builds the update template that reopens a submitted FormSpace into an editable draft.
252
+ *
253
+ * It has to undo all THREE of {@link isFormSpaceEditable}'s conditions rather than just the state: a
254
+ * template that moved `s` back to DRAFT while leaving `sat` set, or leaving `eat` at the null submit wrote,
255
+ * produces a "draft" that either nothing can edit or nothing can ever retire.
256
+ *
257
+ * `eat` is re-armed to the EARLIER of a fresh `expiresIn` window and the space's own lock deadline, so the
258
+ * reopened draft can never outlive the window it was reopened inside. When the type declares neither, `eat`
259
+ * stays absent and the draft does not expire — the same bargain a type with no `expiresIn` already makes
260
+ * for a freshly created space.
261
+ *
262
+ * `uc` and `fi` are deliberately NOT rewound. `uc` bounds uploads ACCEPTED over the space's lifetime, so
263
+ * refunding it here would turn `maxUploads` into a bound on files retained that a reopen loop could evade;
264
+ * `fi` must never hand out an index twice. A type that expects replacement uploads has to budget
265
+ * `maxUploads` for them. `fsat` is likewise preserved — it is the record a reopen exists to not destroy.
266
+ *
267
+ * @param input - The space, its type config, the acting user, and the reopen instant.
268
+ * @returns The update template.
269
+ *
270
+ * @__NO_SIDE_EFFECTS__
271
+ */
272
+ export declare function reopenFormSpaceTemplate(input: ReopenFormSpaceTemplateInput): Partial<FormSpace>;
273
+ /**
274
+ * Input for {@link lockFormSpaceTemplate}.
275
+ */
276
+ export interface LockFormSpaceTemplateInput {
277
+ /**
278
+ * The user locking the space, recorded on `lby`.
279
+ */
280
+ readonly uid?: Maybe<FirebaseAuthUserId>;
281
+ /**
282
+ * The lock instant. Defaults to now.
283
+ */
284
+ readonly now?: Maybe<Date>;
285
+ }
286
+ /**
287
+ * Builds the update template that locks a submitted FormSpace's submission immediately.
288
+ *
289
+ * Only `lat` moves. The lock is not a state transition — the space stays SUBMITTED and its processing is
290
+ * untouched — it is the end of the reopen window, brought forward from whatever the type's
291
+ * `reopenableUntil` would have made it. Writing `lat` in the past is what makes every reopen predicate
292
+ * answer false from this instant on, including for a type whose window was purely rolling and so never
293
+ * had a `lat` at all.
294
+ *
295
+ * @param input - The acting user and the lock instant.
296
+ * @returns The update template.
297
+ *
298
+ * @__NO_SIDE_EFFECTS__
299
+ */
300
+ export declare function lockFormSpaceTemplate(input: LockFormSpaceTemplateInput): Partial<FormSpace>;
301
+ /**
302
+ * Returns the slot config a type declares for the given slot, or null when it declares none.
303
+ *
304
+ * @param config - The type config.
305
+ * @param slot - The slot to look up.
306
+ * @returns The slot config, or null.
307
+ *
308
+ * @__NO_SIDE_EFFECTS__
309
+ */
310
+ export declare function formSpaceFileSlotConfig(config: FormSpaceTypeConfig, slot: FormSpaceFileSlot): Maybe<FormSpaceFileSlotConfig>;
311
+ /**
312
+ * Returns the human-readable name of a slot, falling back to the slot key itself.
313
+ *
314
+ * The key is a reasonable fallback rather than a placeholder: a slot is named `resume` or `cover` precisely
315
+ * because that is what it holds, so a type that declared no `name` still reads as something.
316
+ *
317
+ * @param config - The type config.
318
+ * @param slot - The slot to name.
319
+ * @returns The slot's name.
320
+ *
321
+ * @__NO_SIDE_EFFECTS__
322
+ */
323
+ export declare function formSpaceFileSlotName(config: FormSpaceTypeConfig, slot: FormSpaceFileSlot): string;
324
+ /**
325
+ * Returns how many files a slot may hold at once.
326
+ *
327
+ * @param slotConfig - The slot config, or null for an undeclared slot.
328
+ * @returns The slot's file capacity.
329
+ *
330
+ * @__NO_SIDE_EFFECTS__
331
+ */
332
+ export declare function formSpaceSlotMaxFiles(slotConfig: Maybe<FormSpaceFileSlotConfig>): number;
333
+ /**
334
+ * Returns how many files a slot must hold before the space may be submitted.
335
+ *
336
+ * `required` is the older, coarser spelling of the same idea, so it resolves to 1 when `minFiles` is absent.
337
+ *
338
+ * @param slotConfig - The slot config, or null for an undeclared slot.
339
+ * @returns The slot's minimum file count.
340
+ *
341
+ * @__NO_SIDE_EFFECTS__
342
+ */
343
+ export declare function formSpaceSlotMinFiles(slotConfig: Maybe<FormSpaceFileSlotConfig>): number;
344
+ /**
345
+ * Returns the files a FormSpace currently holds in one slot.
346
+ *
347
+ * @param formSpace - The space to read.
348
+ * @param slot - The slot to filter by.
349
+ * @returns The slot's files, in the order the space stores them.
350
+ *
351
+ * @__NO_SIDE_EFFECTS__
352
+ */
353
+ export declare function formSpaceFilesInSlot(formSpace: Pick<FormSpace, 'f'>, slot: FormSpaceFileSlot): FormSpaceFile[];
354
+ /**
355
+ * Every slot a type requires be filled before its spaces may be submitted.
356
+ *
357
+ * A CLIENT-side convenience for labelling a form's required slots. The submit gate itself uses
358
+ * {@link formSpaceSubmitBlockers}, which also understands `minFiles` and validation state.
359
+ *
360
+ * @param config - The type config.
361
+ * @returns The required slots.
362
+ *
363
+ * @__NO_SIDE_EFFECTS__
364
+ */
365
+ export declare function requiredFormSpaceFileSlots(config: FormSpaceTypeConfig): FormSpaceFileSlot[];
366
+ /**
367
+ * Why {@link assertFormSpaceUploadAllowed} rejected an upload.
368
+ *
369
+ * A discriminated reason rather than a bare false: the caller turns it into an error code, and a client
370
+ * pre-check turns it into a message the user can act on.
371
+ */
372
+ export type FormSpaceUploadRejectionReason = 'not_editable' | 'unknown_slot' | 'max_uploads_reached' | 'slot_full' | 'invalid_mime_type' | 'file_too_large';
373
+ /**
374
+ * Result of {@link assertFormSpaceUploadAllowed}.
375
+ */
376
+ export interface FormSpaceUploadAllowedResult {
377
+ readonly allowed: boolean;
378
+ readonly reason?: Maybe<FormSpaceUploadRejectionReason>;
379
+ }
380
+ /**
381
+ * Input for {@link assertFormSpaceUploadAllowed}.
382
+ */
383
+ export interface AssertFormSpaceUploadAllowedInput {
384
+ readonly formSpace: Pick<FormSpace, 's' | 'sat' | 'eat' | 'uc' | 'f'>;
385
+ readonly config: FormSpaceTypeConfig;
386
+ readonly slot: FormSpaceFileSlot;
387
+ readonly mimeType: ContentTypeMimeType;
388
+ readonly sizeBytes: number;
389
+ /**
390
+ * The instant to judge editability against. Defaults to now.
391
+ */
392
+ readonly now?: Maybe<Date>;
393
+ }
394
+ /**
395
+ * Decides whether one file may be uploaded into one slot of one FormSpace.
396
+ *
397
+ * THE single upload rule. The client calls it to pre-check before requesting a signed URL, and the server's
398
+ * upload initializer calls it again — authoritatively, after loading the space — before creating any
399
+ * StorageFile. The client call is a courtesy; only the server call is a control.
400
+ *
401
+ * @param input - The space, its type config, and the candidate file.
402
+ * @returns Whether the upload is allowed, and why not when it is not.
403
+ *
404
+ * @example
405
+ * ```ts
406
+ * const result = assertFormSpaceUploadAllowed({ formSpace, config, slot: 'resume', mimeType: 'application/pdf', sizeBytes: 4096 });
407
+ * ```
408
+ *
409
+ * @__NO_SIDE_EFFECTS__
410
+ */
411
+ export declare function assertFormSpaceUploadAllowed(input: AssertFormSpaceUploadAllowedInput): FormSpaceUploadAllowedResult;
412
+ /**
413
+ * Why a FormSpace cannot be submitted yet.
414
+ *
415
+ * Per-slot rather than a bare list of slot names, because "you have not uploaded a second document" and "the
416
+ * document you uploaded was rejected" want different words in front of the user.
417
+ */
418
+ export interface FormSpaceSubmitBlocker {
419
+ readonly slot: FormSpaceFileSlot;
420
+ /**
421
+ * `missing_files` — the slot holds fewer than its `minFiles`.
422
+ * `invalid_file` — the slot holds a file validation judged INVALID.
423
+ * `pending_validation` — the slot holds a file whose validation has not concluded.
424
+ */
425
+ readonly reason: 'missing_files' | 'invalid_file' | 'pending_validation';
426
+ /**
427
+ * The offending files, for `invalid_file` and `pending_validation`.
428
+ */
429
+ readonly files?: Maybe<FormSpaceFile[]>;
430
+ }
431
+ /**
432
+ * Returns every reason a FormSpace may not be submitted yet, or an empty array when it may.
433
+ *
434
+ * Reads the space's own `f` array rather than querying its StorageFiles. That array is written in the
435
+ * accept transaction, so unlike a query it is correct immediately after an upload — and unlike a query it
436
+ * can be read inside the transaction that takes the submit lock.
437
+ *
438
+ * @param formSpace - The space to check.
439
+ * @param config - Its type config.
440
+ * @returns The blockers, empty when the space may be submitted.
441
+ *
442
+ * @example
443
+ * ```ts
444
+ * const blockers = formSpaceSubmitBlockers(formSpace, config);
445
+ *
446
+ * if (blockers.length > 0) {
447
+ * throw formSpaceRequiredSlotMissingError(blockers.map((x) => x.slot));
448
+ * }
449
+ * ```
450
+ *
451
+ * @__NO_SIDE_EFFECTS__
452
+ */
453
+ export declare function formSpaceSubmitBlockers(formSpace: Pick<FormSpace, 'f'>, config: FormSpaceTypeConfig): FormSpaceSubmitBlocker[];
454
+ /**
455
+ * What one slot of a FormSpace currently holds, and whether that satisfies the slot's own requirement.
456
+ *
457
+ * The per-slot view of {@link formSpaceSubmitBlockers}, for a UI that labels each slot individually rather
458
+ * than reporting one verdict for the whole space.
459
+ */
460
+ export interface FormSpaceSlotStatus {
461
+ readonly slot: FormSpaceFileSlot;
462
+ /**
463
+ * The files the slot currently holds.
464
+ */
465
+ readonly files: FormSpaceFile[];
466
+ readonly minFiles: number;
467
+ readonly maxFiles: number;
468
+ /**
469
+ * Whether the space cannot be submitted while this slot is empty, i.e. {@link minFiles} is above zero.
470
+ */
471
+ readonly required: boolean;
472
+ /**
473
+ * Every reason this slot blocks submission. Empty when it does not.
474
+ */
475
+ readonly blockers: FormSpaceSubmitBlocker[];
476
+ /**
477
+ * Whether this slot blocks submission. An OPTIONAL EMPTY slot is satisfied — it is holding up nothing.
478
+ */
479
+ readonly satisfied: boolean;
480
+ /**
481
+ * Whether the slot is satisfied AND holds something.
482
+ *
483
+ * The distinction from {@link satisfied} is what an optional slot needs: an empty one blocks nothing, but
484
+ * marking it DONE claims the user dealt with it when they have not touched it. So this is the narrower
485
+ * predicate — "there is something here and it is fine" — and it is what a checkmark belongs next to.
486
+ */
487
+ readonly complete: boolean;
488
+ }
489
+ /**
490
+ * Input for {@link formSpaceSlotStatus}.
491
+ */
492
+ export interface FormSpaceSlotStatusInput {
493
+ readonly formSpace: Pick<FormSpace, 'f'>;
494
+ readonly config: FormSpaceTypeConfig;
495
+ readonly slot: FormSpaceFileSlot;
496
+ }
497
+ /**
498
+ * Returns what one slot holds and whether that satisfies the slot's requirement.
499
+ *
500
+ * Derived from {@link formSpaceSubmitBlockers} rather than re-deriving the rule, so a slot a UI marks done is
501
+ * exactly a slot the server's submit gate would not object to.
502
+ *
503
+ * @param input - The space, its type config, and the slot to report on.
504
+ * @returns The slot's status.
505
+ *
506
+ * @example
507
+ * ```ts
508
+ * const status = formSpaceSlotStatus({ formSpace, config, slot: 'resume' });
509
+ * const showCheck = status.complete;
510
+ * ```
511
+ *
512
+ * @__NO_SIDE_EFFECTS__
513
+ */
514
+ export declare function formSpaceSlotStatus(input: FormSpaceSlotStatusInput): FormSpaceSlotStatus;
@@ -0,0 +1,13 @@
1
+ export * from './formspace.access';
2
+ export * from './formspace.action';
3
+ export * from './formspace.api.error';
4
+ export * from './formspace.api';
5
+ export * from './formspace.id';
6
+ export * from './formspace.permission';
7
+ export * from './formspace.processing';
8
+ export * from './formspace.query';
9
+ export * from './formspace.task';
10
+ export * from './formspace.type';
11
+ export * from './formspace.upload';
12
+ export * from './formspace.util';
13
+ export * from './formspace';
@@ -1,5 +1,6 @@
1
1
  export * from './external';
2
2
  export * from './calendar';
3
+ export * from './formspace';
3
4
  export * from './user';
4
5
  export * from './notification';
5
6
  export * from './oidcmodel';
@@ -12,7 +12,9 @@
12
12
  * 2. Server picks it up via the send queue and routes to the registered handler
13
13
  * 3. Handler returns a result indicating completion, partial progress, delay, or failure
14
14
  * 4. Server updates the notification document accordingly and re-queues if not done
15
- * 5. On completion (`true`), the notification document is deleted
15
+ * 5. On completion (`true`), the notification document is marked done (`d`) — NOT deleted. It stops
16
+ * matching the send queue immediately, but the document itself lingers until the cleanup sweep collects
17
+ * it, so a caller re-deriving a unique task's id can still find the finished one sitting there.
16
18
  */
17
19
  import { type NotificationItem, type NotificationItemMetadata } from './notification.item';
18
20
  import { type NotificationTaskType } from './notification.id';
@@ -2,6 +2,7 @@ import { type Type } from 'arktype';
2
2
  import { type TargetModelParams, type OnCallCreateModelResult, type FirestoreModelKey } from '../../common';
3
3
  import { type ModelFirebaseCrudFunction, type FirebaseFunctionTypeConfigMap, type ModelFirebaseCrudFunctionConfigMap, type ModelFirebaseFunctionMap, type ModelFirebaseCreateFunction } from '../../client';
4
4
  import { type StorageFileSignedDownloadUrl, type StorageFileTypes } from './storagefile';
5
+ import { type StorageFileUploadScope } from './storagefile.upload';
5
6
  import { type StorageFileKey, type StorageFileId, type StorageFilePurpose } from './storagefile.id';
6
7
  import { type StorageBucketId, type StorageMetadata, type StoragePath, type StorageSlashPath } from '../../common/storage';
7
8
  import { type ContentDispositionString, type ContentTypeMimeType, type Maybe, type Milliseconds, type SlashPath, type SlashPathFile, type UnixDateTimeMillisecondsNumber, type UnixDateTimeSecondsNumber } from '@dereekb/util';
@@ -26,6 +27,14 @@ export interface InitializeAllStorageFilesFromUploadsParams {
26
27
  readonly maxFilesToInitialize?: Maybe<number>;
27
28
  readonly folderPath?: Maybe<StorageSlashPath>;
28
29
  readonly overrideUploadsFolderPath?: Maybe<StorageSlashPath>;
30
+ /**
31
+ * Whether to expedite processing of each initialized file that ends up queued for it.
32
+ *
33
+ * The same option {@link InitializeStorageFileFromUploadParams} already offers for a single file. Without
34
+ * it a file initialized by this sweep waits for the next `processAllQueuedStorageFiles` pass, which is a
35
+ * whole scheduling tick of latency for a purpose whose processing the uploader is waiting on.
36
+ */
37
+ readonly expediteProcessing?: Maybe<boolean>;
29
38
  }
30
39
  export declare const initializeAllStorageFilesFromUploadsParamsType: Type<InitializeAllStorageFilesFromUploadsParams>;
31
40
  /**
@@ -343,7 +352,17 @@ export interface CreateStorageFileSignedUploadUrlParams {
343
352
  * when omitted.
344
353
  */
345
354
  readonly expiresInMs?: Maybe<Milliseconds>;
355
+ /**
356
+ * The model, and optionally the slot within it, this upload belongs to. Required when the resolved
357
+ * policy sets `requiresScopeInput: true` (e.g. a FormSpace upload, which is keyed by space and slot
358
+ * rather than by the uid alone).
359
+ */
360
+ readonly scope?: Maybe<StorageFileUploadScope>;
346
361
  }
362
+ /**
363
+ * Arktype for a {@link StorageFileUploadScope}.
364
+ */
365
+ export declare const storageFileUploadScopeType: Type<StorageFileUploadScope>;
347
366
  export declare const createStorageFileSignedUploadUrlParamsType: Type<CreateStorageFileSignedUploadUrlParams>;
348
367
  /**
349
368
  * Result of creating a signed upload URL.
@@ -1,11 +1,51 @@
1
- import { type Factory, type FactoryWithRequiredInput, type Maybe, type SlashPathDetails } from '@dereekb/util';
2
- import { type StoragePath } from '../../common/storage/storage';
1
+ import { type ContentTypeMimeType, type Factory, type FactoryWithRequiredInput, type Maybe, type SlashPathDetails, type SlashPathFile } from '@dereekb/util';
2
+ import { type StoragePath, type StorageSlashPath } from '../../common/storage/storage';
3
+ import { type StorageFileDisplayName } from './storagefile.id';
3
4
  import { type StorageCustomMetadata } from '../../common/storage/types';
4
5
  import { type FirebaseStorageAccessorFile } from '../../common/storage/driver/accessor';
5
6
  /**
6
7
  * Input for a {@link StoredFileReader}, carrying the storage bucket and path of the file.
7
8
  */
8
9
  export type StoredFileReaderInput = StoragePath;
10
+ /**
11
+ * Input for {@link storageFileDisplayFileName}.
12
+ */
13
+ export interface StorageFileDisplayFileNameInput {
14
+ /**
15
+ * The StorageFile's own `n`, which is UNTYPED by contract (see {@link StorageFileDisplayName}).
16
+ */
17
+ readonly displayName?: Maybe<StorageFileDisplayName>;
18
+ /**
19
+ * The object's path — a StorageFile's `pathString`, or the `name` off its storage metadata.
20
+ */
21
+ readonly pathString?: Maybe<StorageSlashPath>;
22
+ /**
23
+ * The object's content type, used when the path names no extension.
24
+ */
25
+ readonly contentType?: Maybe<ContentTypeMimeType>;
26
+ }
27
+ /**
28
+ * Composes the name a stored file should be presented to a user under.
29
+ *
30
+ * THE one place that answers "what is this file called". A StorageFile's `n` is untyped by contract, so
31
+ * the extension always comes from the object's own path — which is why a purpose whose destination is not
32
+ * name-keyed (a FormSpace file lives at `.../{index}.{ext}`) still downloads and zips under the name its
33
+ * uploader gave it.
34
+ *
35
+ * Falls back to the path's own leaf when there is no display name, so a StorageFile that never had one
36
+ * behaves exactly as it did before.
37
+ *
38
+ * @param input - The display name, the object path, and the content type.
39
+ * @returns The composed file name, or null when the input names neither.
40
+ *
41
+ * @example
42
+ * ```ts
43
+ * storageFileDisplayFileName({ displayName: 'resume', pathString: '/fsp/f1/resume/0.pdf' }); // 'resume.pdf'
44
+ * ```
45
+ *
46
+ * @__NO_SIDE_EFFECTS__
47
+ */
48
+ export declare function storageFileDisplayFileName(input: StorageFileDisplayFileNameInput): Maybe<SlashPathFile>;
9
49
  /**
10
50
  * Factory that creates a {@link StoredFileReader} from a {@link FirebaseStorageAccessorFile}.
11
51
  *
@@ -105,6 +105,28 @@ export type StorageFileInitializeFromUploadResultType = 'success' | 'no_determin
105
105
  export interface StorageFilePurposeUploadPolicyBuildPathInput {
106
106
  readonly uid: FirebaseAuthUserId;
107
107
  readonly filename?: Maybe<SlashPathFile>;
108
+ /**
109
+ * The model the upload is scoped to, for a purpose whose destination is not derivable from the uid alone.
110
+ *
111
+ * Required when the policy sets {@link StorageFilePurposeUploadPolicy.requiresScopeInput}.
112
+ */
113
+ readonly scope?: Maybe<StorageFileUploadScope>;
114
+ }
115
+ /**
116
+ * Names the specific model, and optionally the slot within it, that an upload belongs to.
117
+ *
118
+ * This is what lets ONE purpose serve many destinations: a FormSpace upload is `{ id: formSpaceId,
119
+ * subgroup: slot }` under a single `form_space` purpose, rather than a purpose per form type.
120
+ */
121
+ export interface StorageFileUploadScope {
122
+ /**
123
+ * Id of the model the upload is scoped to.
124
+ */
125
+ readonly id: string;
126
+ /**
127
+ * Slot/subgroup within the scoped model, when the model has more than one.
128
+ */
129
+ readonly subgroup?: Maybe<string>;
108
130
  }
109
131
  /**
110
132
  * Per-purpose constraints for generating short-lived signed upload URLs.
@@ -128,4 +150,12 @@ export interface StorageFilePurposeUploadPolicy {
128
150
  * When false (e.g. avatar), the path is derived solely from the uid.
129
151
  */
130
152
  readonly requiresFilenameInput: boolean;
153
+ /**
154
+ * When true, the caller MUST provide a {@link StorageFileUploadScope} for `buildUploadPath`.
155
+ *
156
+ * Set by a purpose whose destination folder is keyed by a model rather than by the uid alone. The scope
157
+ * is only a PATH input — it is not itself authorization; the purpose's initializer is what loads the
158
+ * scoped model and decides whether this uploader may write into it.
159
+ */
160
+ readonly requiresScopeInput?: boolean;
131
161
  }