@dereekb/firebase 13.43.0 → 14.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dereekb/firebase",
3
- "version": "13.43.0",
3
+ "version": "14.0.1",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "exports": {
@@ -22,22 +22,22 @@
22
22
  }
23
23
  },
24
24
  "peerDependencies": {
25
- "@dereekb/date": "13.43.0",
26
- "@dereekb/model": "13.43.0",
27
- "@dereekb/rxjs": "13.43.0",
28
- "@dereekb/util": "13.43.0",
29
- "@firebase/rules-unit-testing": "5.0.0",
30
- "@marcbachmann/cel-js": "^7.6.1",
31
- "@typescript-eslint/parser": "8.59.3",
25
+ "@dereekb/date": "14.0.1",
26
+ "@dereekb/model": "14.0.1",
27
+ "@dereekb/rxjs": "14.0.1",
28
+ "@dereekb/util": "14.0.1",
29
+ "@firebase/rules-unit-testing": "5.0.2",
30
+ "@marcbachmann/cel-js": "^8.0.0",
31
+ "@typescript-eslint/parser": "8.69.0",
32
32
  "arktype": "^2.2.0",
33
33
  "date-fns": "^4.1.0",
34
- "firebase": "^12.12.1",
34
+ "firebase": "^12.18.0",
35
35
  "make-error": "^1.3.6",
36
36
  "rxjs": "^7.8.2",
37
37
  "ts-essentials": "^10.2.0"
38
38
  },
39
39
  "devDependencies": {
40
- "eslint": "10.4.0"
40
+ "eslint": "10.9.1"
41
41
  },
42
42
  "module": "./index.esm.js",
43
43
  "main": "./index.esm.js",
@@ -92,7 +92,3 @@ export declare function streamDocumentSnapshotDataPairs<D extends FirestoreDocum
92
92
  * @returns Observable emitting snapshot-data pairs for existing documents only, whenever any document changes.
93
93
  */
94
94
  export declare function streamDocumentSnapshotDataPairsWithData<D extends FirestoreDocument<any>>(documents: D[]): Observable<FirestoreDocumentSnapshotDataPairWithData<D>[]>;
95
- /**
96
- * @deprecated Use {@link streamDocumentSnapshotsData} instead.
97
- */
98
- export declare const latestDataFromDocuments: typeof streamDocumentSnapshotsData;
@@ -1,4 +1,4 @@
1
- import { type ItemAccumulatorNextPageUntilResultsCountFunction, type ItemAccumulatorInstance, type ItemAccumulatorMapFunction, type PageItemIteration } from '@dereekb/rxjs';
1
+ import { type ItemAccumulatorNextPageUntilResultsCountFunction, type ItemAccumulatorInstance, type ItemAccumulatorMapFunction, type PageItemIteration, type PageLoadingState } from '@dereekb/rxjs';
2
2
  import { type MapFunction } from '@dereekb/util';
3
3
  import { type DocumentDataWithIdAndKey, type QueryDocumentSnapshotArray } from '../types';
4
4
  import { type FirestoreItemPageIterationInstance } from './iterator';
@@ -11,7 +11,7 @@ import { type FirestoreItemPageIterationInstance } from './iterator';
11
11
  * @template O - The output type after mapping the query snapshots
12
12
  * @template T - The document data type in the snapshots
13
13
  */
14
- export type MappedFirebaseQuerySnapshotAccumulator<O, T> = ItemAccumulatorInstance<O, QueryDocumentSnapshotArray<T>, PageItemIteration<QueryDocumentSnapshotArray<T>>>;
14
+ export type MappedFirebaseQuerySnapshotAccumulator<O, T> = ItemAccumulatorInstance<O, QueryDocumentSnapshotArray<T>, PageItemIteration<PageLoadingState<QueryDocumentSnapshotArray<T>>>>;
15
15
  /**
16
16
  * An accumulator that collects Firestore query snapshots without custom mapping.
17
17
  *
@@ -180,7 +180,7 @@ export declare const DEFAULT_FIRESTORE_ITEM_PAGE_ITERATOR_ITEMS_PER_PAGE = 50;
180
180
  * @__NO_SIDE_EFFECTS__
181
181
  */
182
182
  export declare function makeFirestoreItemPageIteratorDelegate<T>(): FirestoreItemPageIteratorDelegate<T>;
183
- export interface FirestoreItemPageIteration<T> extends MappedPageItemIterationInstance<QueryDocumentSnapshotArray<T>, FirestoreItemPageQueryResult<T>, PageLoadingState<QueryDocumentSnapshotArray<T>>, PageLoadingState<FirestoreItemPageQueryResult<T>>, InternalFirestoreItemPageIterationInstance<T>> {
183
+ export interface FirestoreItemPageIteration<T> extends MappedPageItemIterationInstance<PageLoadingState<QueryDocumentSnapshotArray<T>>, PageLoadingState<FirestoreItemPageQueryResult<T>>, InternalFirestoreItemPageIterationInstance<T>> {
184
184
  /**
185
185
  * The underlying iteration instance that works with raw query snapshots.
186
186
  *
@@ -208,17 +208,3 @@ export declare function onCallCreateModelResultWithDocs(result: ArrayOrValue<Doc
208
208
  * @returns An {@link OnCallCreateModelResult} containing the keys as an array.
209
209
  */
210
210
  export declare function onCallCreateModelResult(modelKeys: ArrayOrValue<FirestoreModelKey>): OnCallCreateModelResult;
211
- /**
212
- * Creates OnCallTypedModelParams for the input.
213
- *
214
- * Convenience function for calling onCallTypedModelParamsFunction and executing it with the input.
215
- *
216
- * @param modelTypeInput - The model type string or ref.
217
- * @param data - The call payload.
218
- * @param specifier - Optional sub-function specifier.
219
- * @param call - The CRUD call type.
220
- * @returns The constructed {@link OnCallTypedModelParams}
221
- *
222
- * @deprecated Move towards using onCallTypedModelParamsFunction directly with the call type instead of using this function. Will not be removed in the future.
223
- */
224
- export declare function onCallTypedModelParams<T>(modelTypeInput: FirestoreModelType | FirestoreModelTypeRef, data: T, specifier?: string, call?: OnCallFunctionType): OnCallTypedModelParams<T>;
@@ -75,6 +75,26 @@ export interface SubmitFormSpaceResult {
75
75
  */
76
76
  readonly processingTaskCreated: boolean;
77
77
  }
78
+ /**
79
+ * Parameters for reopening a submitted FormSpace back into an editable draft.
80
+ *
81
+ * No options: whether the space may be reopened is the type's policy plus the caller's `reopen` role, and
82
+ * neither is anything a client gets to say. The acting user is taken from the request, never the body,
83
+ * because it is what `rby` records.
84
+ *
85
+ * @dbxModelApiParams
86
+ */
87
+ export interface ReopenFormSpaceParams extends TargetModelParams {
88
+ }
89
+ export declare const reopenFormSpaceParamsType: Type<ReopenFormSpaceParams>;
90
+ /**
91
+ * Parameters for locking a submitted FormSpace's submission immediately.
92
+ *
93
+ * @dbxModelApiParams
94
+ */
95
+ export interface LockFormSpaceParams extends TargetModelParams {
96
+ }
97
+ export declare const lockFormSpaceParamsType: Type<LockFormSpaceParams>;
78
98
  /**
79
99
  * Parameters for removing one uploaded file from a FormSpace slot.
80
100
  *
@@ -175,6 +195,8 @@ export type FormSpaceModelCrudFunctionsConfig = {
175
195
  update: {
176
196
  _: UpdateFormSpaceParams;
177
197
  submit: [SubmitFormSpaceParams, SubmitFormSpaceResult];
198
+ reopen: ReopenFormSpaceParams;
199
+ lock: LockFormSpaceParams;
178
200
  removeFile: RemoveFormSpaceFileParams;
179
201
  };
180
202
  delete: {
@@ -197,6 +219,8 @@ export declare abstract class FormSpaceFunctions implements ModelFirebaseFunctio
197
219
  updateFormSpace: {
198
220
  update: ModelFirebaseCrudFunction<UpdateFormSpaceParams>;
199
221
  submit: ModelFirebaseCrudFunction<SubmitFormSpaceParams, SubmitFormSpaceResult>;
222
+ reopen: ModelFirebaseCrudFunction<ReopenFormSpaceParams>;
223
+ lock: ModelFirebaseCrudFunction<LockFormSpaceParams>;
200
224
  removeFile: ModelFirebaseCrudFunction<RemoveFormSpaceFileParams>;
201
225
  };
202
226
  deleteFormSpace: {
@@ -15,6 +15,31 @@ export declare const FORM_SPACE_TYPE_NOT_REGISTERED_ERROR_CODE = "FORM_SPACE_TYP
15
15
  * Thrown when a FormSpace is edited, uploaded into, or submitted after it stopped being a draft.
16
16
  */
17
17
  export declare const FORM_SPACE_NOT_EDITABLE_ERROR_CODE = "FORM_SPACE_NOT_EDITABLE";
18
+ /**
19
+ * Thrown when a FormSpace's submission is locked before the space was ever submitted.
20
+ *
21
+ * A lock ends a reopen window early, so there has to be a submission for it to be about. A space that was
22
+ * submitted and then reopened DOES qualify — locking a reopened draft is how a reviewer says "this next
23
+ * submission is the last one".
24
+ */
25
+ export declare const FORM_SPACE_NOT_SUBMITTED_ERROR_CODE = "FORM_SPACE_NOT_SUBMITTED";
26
+ /**
27
+ * Thrown when a submitted FormSpace is reopened after it stopped being reopenable.
28
+ *
29
+ * TERMINAL, unlike {@link FORM_SPACE_PROCESSING_IN_PROGRESS_ERROR_CODE}: the type never allowed reopening,
30
+ * the reopen window has closed, `lat` has passed, or `maxReopens` is spent. Retrying cannot help, so the
31
+ * caller should be told the submission is final rather than asked to wait.
32
+ */
33
+ export declare const FORM_SPACE_NOT_REOPENABLE_ERROR_CODE = "FORM_SPACE_NOT_REOPENABLE";
34
+ /**
35
+ * Thrown when a FormSpace is reopened while its submission is actively being processed.
36
+ *
37
+ * Transient by nature — the same reopen succeeds once the processor concludes — which is why it is not
38
+ * folded into {@link FORM_SPACE_NOT_REOPENABLE_ERROR_CODE}. Reopening under a running processor would race
39
+ * it: the task's cleanup writes `ps`/`cpat`/`pn` and would land them on a space already handed back as a
40
+ * draft.
41
+ */
42
+ export declare const FORM_SPACE_PROCESSING_IN_PROGRESS_ERROR_CODE = "FORM_SPACE_PROCESSING_IN_PROGRESS";
18
43
  /**
19
44
  * Thrown when a FormSpace is submitted while one of its type's required slots is still empty.
20
45
  */
@@ -31,9 +31,11 @@ export declare const formSpaceIdentity: import("../..").RootFirestoreModelIdenti
31
31
  /**
32
32
  * Lifecycle state of a {@link FormSpace}.
33
33
  *
34
- * Only DRAFT is editable. The other three are terminal for the owner: SUBMITTED is awaiting or undergoing
35
- * processing, EXPIRED was retired by the sweep before it was ever submitted, and ARCHIVED is a space kept
36
- * for the record after its processing concluded.
34
+ * Only DRAFT is editable. SUBMITTED is awaiting or undergoing processing, and is the one state a space can
35
+ * come BACK from: a type declaring a reopen policy lets a caller holding the `reopen` role return the space
36
+ * to DRAFT until it is fully locked. EXPIRED (retired by the sweep before it was ever submitted) and
37
+ * ARCHIVED (kept for the record after processing concluded) stay terminal — {@link isFormSpaceReopenable}
38
+ * requires SUBMITTED, so neither is reachable by a reopen.
37
39
  */
38
40
  export declare enum FormSpaceState {
39
41
  DRAFT = 0,
@@ -295,11 +297,25 @@ export interface FormSpace<T extends FormSpaceData = FormSpaceData> {
295
297
  */
296
298
  uat: Date;
297
299
  /**
298
- * The date the space was submitted, if it was. Its presence IS the lock.
300
+ * The date the CURRENT submission was made, if the space is submitted. Its presence IS the lock.
301
+ *
302
+ * Cleared by a reopen, which is what hands the space back as an editable draft, so it always describes
303
+ * the submission in force NOW rather than the history. `fsat` is what remembers the first one.
299
304
  *
300
305
  * @dbxModelVariable submittedAt
301
306
  */
302
307
  sat?: Maybe<Date>;
308
+ /**
309
+ * The date the space was FIRST submitted, if it ever was.
310
+ *
311
+ * Never cleared. Since a reopen clears `sat`, without this the fact that the space was submitted at all —
312
+ * and when — would be destroyed by the first reopen. It is also the anchor
313
+ * {@link resolveFormSpaceLocksAt} measures the type's `reopenableUntil` from, which is what stops a
314
+ * reopen/resubmit round from walking the lock deadline forward.
315
+ *
316
+ * @dbxModelVariable firstSubmittedAt
317
+ */
318
+ fsat?: Maybe<Date>;
303
319
  /**
304
320
  * The date processing of the submission concluded, if it has.
305
321
  *
@@ -316,6 +332,54 @@ export interface FormSpace<T extends FormSpaceData = FormSpaceData> {
316
332
  * @dbxModelVariable expiresAt
317
333
  */
318
334
  eat?: Maybe<Date>;
335
+ /**
336
+ * The date reopening stops being possible — the instant the submission becomes FULLY LOCKED.
337
+ *
338
+ * Written once, on the first submit, as `fsat + reopenableUntil`, and never moved afterwards; an explicit
339
+ * lock sets it to that moment instead. Absent means there is no CEILING, not that the space is locked:
340
+ * the type's `reopenableFor` is the master switch, and a type declaring neither is simply never
341
+ * reopenable. The same "an absent field is an absent gate" convention `eat` uses.
342
+ *
343
+ * Stored as an ISO8601 string like every other date here, which means `firestore.rules` CANNOT compare it
344
+ * against `request.time` — rules convert only bool/int/float/null to a string, never a timestamp. A
345
+ * downstream app that needs the lock predicate inside its OWN rules has to denormalize a unix-seconds
346
+ * mirror onto its own model and compare that. Reading this field over a callable, or over the `get` the
347
+ * rules already grant, needs no mirror at all.
348
+ *
349
+ * @dbxModelVariable locksAt
350
+ */
351
+ lat?: Maybe<Date>;
352
+ /**
353
+ * The user who locked the submission early, when a caller did rather than the deadline passing.
354
+ *
355
+ * @dbxModelVariable lockedBy
356
+ */
357
+ lby?: Maybe<FirebaseAuthUserId>;
358
+ /**
359
+ * Monotonic count of times this space has been REOPENED after a submission.
360
+ *
361
+ * Doubles as the submission-attempt generation. The submission task is keyed by it, so a resubmit gets a
362
+ * fresh task instead of colliding with the finished one, and a task still carrying a stale count is
363
+ * fenced off rather than clobbering the attempt in force.
364
+ *
365
+ * Monotonic for the same reason `uc` is: it counts rounds that happened, and a counter something can
366
+ * rewind is not a bound. Capped by the type's `maxReopens`.
367
+ *
368
+ * @dbxModelVariable reopenCount
369
+ */
370
+ rc: number;
371
+ /**
372
+ * The date the space was last reopened, if it ever was.
373
+ *
374
+ * @dbxModelVariable reopenedAt
375
+ */
376
+ rat?: Maybe<Date>;
377
+ /**
378
+ * The user who last reopened the space.
379
+ *
380
+ * @dbxModelVariable reopenedBy
381
+ */
382
+ rby?: Maybe<FirebaseAuthUserId>;
319
383
  }
320
384
  /**
321
385
  * Permission roles for FormSpace operations.
@@ -334,8 +398,15 @@ export interface FormSpace<T extends FormSpaceData = FormSpaceData> {
334
398
  * context to build a role map from — so `uploadFile` is the DECLARATION of who may contribute, and
335
399
  * `FormSpaceUploadAuthorizationDelegate` is where the same app policy is actually applied. Grant them
336
400
  * together, to the same people.
401
+ *
402
+ * `reopen` and `lock` are the two halves of undoing that door, and are separate from each other as much as
403
+ * from `submit`. `reopen` returns a submitted space to DRAFT; WHETHER it may be reopened at all is the
404
+ * type's own policy, re-asserted inside the action's transaction, so the role only says who is allowed to
405
+ * ask. `lock` goes the other way and ends the reopen window early — in a two-party flow that is a
406
+ * privilege the party who submitted should not automatically hold over the party reviewing, which is
407
+ * exactly why it is not folded into `reopen`.
337
408
  */
338
- export type FormSpaceRoles = GrantedReadRole | GrantedUpdateRole | GrantedDeleteRole | 'submit' | 'uploadFile' | 'removeFile';
409
+ export type FormSpaceRoles = GrantedReadRole | GrantedUpdateRole | GrantedDeleteRole | 'submit' | 'uploadFile' | 'removeFile' | 'reopen' | 'lock';
339
410
  /**
340
411
  * Firestore document wrapper for a {@link FormSpace}.
341
412
  *
@@ -1,7 +1,7 @@
1
1
  import { type Maybe } from '@dereekb/util';
2
2
  import { type CreateNotificationTaskTemplate } from '../notification/notification.create.task';
3
3
  import { type NotificationTaskSubtaskCheckpointString, type NotificationTaskSubtaskData, type NotificationTaskSubtaskMetadata } from '../notification/notification.task.subtask';
4
- import { type NotificationTaskType } from '../notification/notification.id';
4
+ import { type NotificationTaskType, type NotificationTaskUniqueId } from '../notification/notification.id';
5
5
  import { type FormSpaceDocument } from './formspace';
6
6
  import { type FormSpaceId, type FormSpaceType } from './formspace.id';
7
7
  /**
@@ -12,6 +12,14 @@ import { type FormSpaceId, type FormSpaceType } from './formspace.id';
12
12
  * ONE task type for every form type. The task's SUBTASK TARGET is the {@link FormSpaceType}, so a new form
13
13
  * type registers a processor rather than a task type — the same specialization
14
14
  * `StorageFileProcessingNotificationTask` already does by purpose.
15
+ *
16
+ * ONE task per SUBMISSION ATTEMPT, not per space. A unique task's document id is derived and permanent, and
17
+ * a completed task is only marked done (`d`) — it lingers until the cleanup sweep collects it. So a space
18
+ * that is reopened and resubmitted would re-derive the id of its own finished task, and
19
+ * `createOrRunUniqueNotificationDocument` would find it already there and do nothing at all: the space
20
+ * would sit in QUEUED_FOR_PROCESSING pointing at a dead document forever. Keying the id by the space's
21
+ * reopen count is what keeps each attempt a document of its own, and the first attempt keeps the exact id
22
+ * it has always had.
15
23
  */
16
24
  /**
17
25
  * NotificationTask type identifier for FormSpace submission processing.
@@ -43,26 +51,79 @@ export interface FormSpaceSubmissionNotificationTaskData<M extends FormSpaceSubm
43
51
  * so subsequent runs do not re-read the document only to learn which processor to dispatch to.
44
52
  */
45
53
  readonly t?: Maybe<FormSpaceType>;
54
+ /**
55
+ * The space's reopen count when this task was created — the submission ATTEMPT this task belongs to.
56
+ *
57
+ * The handler compares it against the space's current count and terminates when they differ. Without
58
+ * that fence a task left over from a superseded attempt would process the reopened space's new content
59
+ * and its cleanup would write `ps`/`cpat`/`pn` over the attempt actually in force. Absent on a task
60
+ * created before attempt-keying existed, which is read as "do not fence".
61
+ */
62
+ readonly rc?: Maybe<number>;
46
63
  }
47
64
  /**
48
65
  * Input for {@link formSpaceSubmissionNotificationTaskTemplate}.
49
66
  */
50
- export interface FormSpaceSubmissionNotificationTaskInput<M extends FormSpaceSubmissionSubtaskMetadata = FormSpaceSubmissionSubtaskMetadata> extends Omit<FormSpaceSubmissionNotificationTaskData<M>, 'formSpace' | 't' | 'sfps'> {
67
+ export interface FormSpaceSubmissionNotificationTaskInput<M extends FormSpaceSubmissionSubtaskMetadata = FormSpaceSubmissionSubtaskMetadata> extends Omit<FormSpaceSubmissionNotificationTaskData<M>, 'formSpace' | 't' | 'sfps' | 'rc'> {
51
68
  readonly formSpaceDocument: FormSpaceDocument;
69
+ /**
70
+ * The space's current reopen count — the attempt this task is for. Defaults to 0, the first submission.
71
+ */
72
+ readonly reopenCount?: Maybe<number>;
52
73
  readonly overrideExistingTask?: Maybe<boolean>;
53
74
  }
75
+ /**
76
+ * Input for {@link formSpaceSubmissionNotificationTaskUniqueId}.
77
+ */
78
+ export interface FormSpaceSubmissionNotificationTaskUniqueIdInput {
79
+ readonly formSpaceId: FormSpaceId;
80
+ /**
81
+ * The attempt's reopen count. Defaults to 0, the first submission.
82
+ */
83
+ readonly reopenCount?: Maybe<number>;
84
+ }
85
+ /**
86
+ * Returns the unique NotificationTask document id processing one SUBMISSION ATTEMPT of a FormSpace.
87
+ *
88
+ * The first attempt is the bare {@link notificationTaskUniqueId}, unsuffixed, so a space submitted before
89
+ * reopening existed keeps the exact document it already has. Every attempt after a reopen appends its
90
+ * count, which is what stops a resubmit from colliding with the finished task of the attempt before it.
91
+ *
92
+ * The `r` between the separators keeps the suffix from ever being able to produce a `__` run, whatever a
93
+ * FormSpace id happens to contain — Firestore reserves ids wrapped in double underscores, and staying well
94
+ * clear of them costs one character.
95
+ *
96
+ * Exported so a caller that has to LOCATE an attempt's task — a test asserting which document a resubmit
97
+ * created, or tooling inspecting a stuck submission — derives the id the same way the template does. The
98
+ * reopen action deliberately does not need it: the superseded attempt's task is left alone, either already
99
+ * done and awaiting the cleanup sweep or still queued and about to fence itself off.
100
+ *
101
+ * @param input - The FormSpace id and the attempt's reopen count.
102
+ * @returns The unique notification task id.
103
+ *
104
+ * @example
105
+ * ```ts
106
+ * formSpaceSubmissionNotificationTaskUniqueId({ formSpaceId: 'abc' }); // 'abc_FSPS'
107
+ * formSpaceSubmissionNotificationTaskUniqueId({ formSpaceId: 'abc', reopenCount: 2 }); // 'abc_FSPS_r2'
108
+ * ```
109
+ *
110
+ * @__NO_SIDE_EFFECTS__
111
+ */
112
+ export declare function formSpaceSubmissionNotificationTaskUniqueId(input: FormSpaceSubmissionNotificationTaskUniqueIdInput): NotificationTaskUniqueId;
54
113
  /**
55
114
  * Creates a {@link CreateNotificationTaskTemplate} for a FormSpace submission task.
56
115
  *
57
- * The task is UNIQUE to the FormSpace: a space submits once, and a second template for the same space must
58
- * resolve to the same document rather than racing a second processor against the first.
116
+ * The task is UNIQUE to one submission ATTEMPT of the FormSpace. Within an attempt that uniqueness is what
117
+ * makes a second template resolve to the same document rather than racing a second processor against the
118
+ * first; across attempts, {@link formSpaceSubmissionNotificationTaskUniqueId} keys them apart so a resubmit
119
+ * after a reopen gets a document — and a checkpoint flow — of its own.
59
120
  *
60
- * @param input - The target FormSpaceDocument and optional subtask data.
121
+ * @param input - The target FormSpaceDocument, its reopen count, and optional subtask data.
61
122
  * @returns A CreateNotificationTaskTemplate for the submission task.
62
123
  *
63
124
  * @example
64
125
  * ```ts
65
- * const template = formSpaceSubmissionNotificationTaskTemplate({ formSpaceDocument: doc });
126
+ * const template = formSpaceSubmissionNotificationTaskTemplate({ formSpaceDocument: doc, reopenCount: formSpace.rc });
66
127
  * ```
67
128
  */
68
129
  export declare function formSpaceSubmissionNotificationTaskTemplate(input: FormSpaceSubmissionNotificationTaskInput): CreateNotificationTaskTemplate;
@@ -153,6 +153,39 @@ export interface FormSpaceTypeConfig {
153
153
  * the sweep's inequality query.
154
154
  */
155
155
  readonly expiresIn?: Maybe<Milliseconds>;
156
+ /**
157
+ * How long after EACH submission a space of this type may be reopened, returning it to an editable draft.
158
+ *
159
+ * THE MASTER SWITCH. When absent the type is never reopenable and submission stays the one-way door it
160
+ * has always been — which is what makes the whole reopen feature opt-in, and why no existing type can
161
+ * acquire resubmit semantics by accident.
162
+ *
163
+ * Distinct from {@link expiresIn}, which bounds the lifetime of the DRAFT. This bounds the lifetime of
164
+ * the SUBMISSION's mutability.
165
+ *
166
+ * Rolling by default: absent {@link reopenableUntil}, every submission grants a fresh window measured
167
+ * from its own `sat`.
168
+ */
169
+ readonly reopenableFor?: Maybe<Milliseconds>;
170
+ /**
171
+ * A hard ceiling on reopening, measured from the FIRST submission rather than the current one.
172
+ *
173
+ * Materialized onto the space as `lat` on its first submit and never moved, so repeated reopen/resubmit
174
+ * rounds cannot walk the deadline forward the way a purely rolling {@link reopenableFor} lets them. Use
175
+ * it when "this submission is final N hours after it was first made" has to be a wall-clock guarantee.
176
+ *
177
+ * Absent leaves {@link reopenableFor} rolling. Meaningless on its own: {@link reopenableFor} is what
178
+ * permits a reopen at all, and this only narrows it.
179
+ */
180
+ readonly reopenableUntil?: Maybe<Milliseconds>;
181
+ /**
182
+ * How many times a space of this type may be reopened. Counted against the space's monotonic `rc`.
183
+ *
184
+ * Absent leaves the count unbounded and the windows above the only limit — which is fine for a
185
+ * {@link reopenableUntil} ceiling, and worth setting deliberately for a purely rolling window, where a
186
+ * resubmit keeps earning a new one.
187
+ */
188
+ readonly maxReopens?: Maybe<number>;
156
189
  }
157
190
  /**
158
191
  * Default for {@link FormSpaceTypeConfig.maxUploads}.
@@ -89,18 +89,61 @@ export interface FormSpaceTemplateInput<T extends FormSpaceData = FormSpaceData>
89
89
  * @__NO_SIDE_EFFECTS__
90
90
  */
91
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
+ }
92
106
  /**
93
107
  * Builds the update template that submits a FormSpace.
94
108
  *
95
109
  * Clearing `eat` is not tidiness: a submitted space that kept its expiration instant would still match the
96
110
  * expiration sweep and be retired out from under the processing task.
97
111
  *
98
- * @param now - The submission instant. Defaults to now.
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.
99
118
  * @returns The update template.
100
119
  *
101
120
  * @__NO_SIDE_EFFECTS__
102
121
  */
103
- export declare function submitFormSpaceTemplate(now?: Maybe<Date>): Partial<FormSpace>;
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>;
104
147
  /**
105
148
  * Builds the update template that expires a FormSpace.
106
149
  *
@@ -132,6 +175,129 @@ export interface IsFormSpaceEditableInput {
132
175
  * @__NO_SIDE_EFFECTS__
133
176
  */
134
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>;
135
301
  /**
136
302
  * Returns the slot config a type declares for the given slot, or null when it declares none.
137
303
  *
@@ -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';