@visiolab/effect-firebase 0.0.0-stage → 0.3.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 (140) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +81 -2
  3. package/dist/_virtual/_rolldown/runtime.mjs +12 -0
  4. package/dist/app/App.d.mts +27 -0
  5. package/dist/app/App.mjs +27 -0
  6. package/dist/app/index.d.mts +2 -0
  7. package/dist/app/index.mjs +1 -0
  8. package/dist/firestore/Collection.d.mts +423 -0
  9. package/dist/firestore/Collection.mjs +133 -0
  10. package/dist/firestore/CollectionGroup.d.mts +164 -0
  11. package/dist/firestore/CollectionGroup.mjs +84 -0
  12. package/dist/firestore/DocumentPath.d.mts +95 -0
  13. package/dist/firestore/DocumentPath.mjs +71 -0
  14. package/dist/firestore/DocumentSnapshot.d.mts +239 -0
  15. package/dist/firestore/DocumentSnapshot.mjs +134 -0
  16. package/dist/firestore/FieldValue.d.mts +332 -0
  17. package/dist/firestore/FieldValue.mjs +256 -0
  18. package/dist/firestore/Firestore.d.mts +1165 -0
  19. package/dist/firestore/Firestore.mjs +274 -0
  20. package/dist/firestore/FirestoreModel.d.mts +1232 -0
  21. package/dist/firestore/FirestoreModel.mjs +586 -0
  22. package/dist/firestore/FirestoreSchema.d.mts +567 -0
  23. package/dist/firestore/FirestoreSchema.mjs +419 -0
  24. package/dist/firestore/FirestoreValue.d.mts +145 -0
  25. package/dist/firestore/FirestoreValue.mjs +87 -0
  26. package/dist/firestore/GeoPoint.d.mts +90 -0
  27. package/dist/firestore/GeoPoint.mjs +75 -0
  28. package/dist/firestore/Patch.d.mts +73 -0
  29. package/dist/firestore/Patch.mjs +57 -0
  30. package/dist/firestore/QuerySnapshot.d.mts +107 -0
  31. package/dist/firestore/QuerySnapshot.mjs +43 -0
  32. package/dist/firestore/Timestamp.d.mts +273 -0
  33. package/dist/firestore/Timestamp.mjs +255 -0
  34. package/dist/firestore/index.d.mts +14 -0
  35. package/dist/firestore/index.mjs +14 -0
  36. package/dist/firestore/internal/collection.mjs +156 -0
  37. package/dist/firestore/internal/collectionGroup.mjs +64 -0
  38. package/dist/firestore/internal/documentPath.d.mts +4 -0
  39. package/dist/firestore/internal/documentPath.mjs +42 -0
  40. package/dist/firestore/internal/documentSnapshot.mjs +63 -0
  41. package/dist/firestore/internal/fieldValue.d.mts +11 -0
  42. package/dist/firestore/internal/fieldValue.mjs +88 -0
  43. package/dist/firestore/internal/firestore.mjs +145 -0
  44. package/dist/firestore/internal/firestoreError.d.mts +29 -0
  45. package/dist/firestore/internal/firestoreError.mjs +30 -0
  46. package/dist/firestore/internal/firestoreModel.mjs +719 -0
  47. package/dist/firestore/internal/firestoreSchema.mjs +123 -0
  48. package/dist/firestore/internal/firestoreValue.mjs +108 -0
  49. package/dist/firestore/internal/firestoreValueWalk.d.mts +58 -0
  50. package/dist/firestore/internal/firestoreValueWalk.mjs +223 -0
  51. package/dist/firestore/internal/geoPoint.d.mts +4 -0
  52. package/dist/firestore/internal/geoPoint.mjs +39 -0
  53. package/dist/firestore/internal/patch.mjs +19 -0
  54. package/dist/firestore/internal/querySnapshot.mjs +24 -0
  55. package/dist/firestore/internal/schemaAST.mjs +35 -0
  56. package/dist/firestore/internal/snapshotExtra.mjs +35 -0
  57. package/dist/firestore/internal/streamFromListener.mjs +12 -0
  58. package/dist/firestore/internal/timestamp.d.mts +4 -0
  59. package/dist/firestore/internal/timestamp.mjs +66 -0
  60. package/dist/firestore/internal/timestampSchema.mjs +31 -0
  61. package/dist/firestore/internal/transactionFromCallback.mjs +66 -0
  62. package/dist/functions/Callable.d.mts +654 -0
  63. package/dist/functions/Callable.mjs +450 -0
  64. package/dist/functions/CallableError.d.mts +297 -0
  65. package/dist/functions/CallableError.mjs +256 -0
  66. package/dist/functions/CallableMiddleware.d.mts +213 -0
  67. package/dist/functions/CallableMiddleware.mjs +52 -0
  68. package/dist/functions/CallableRequest.d.mts +166 -0
  69. package/dist/functions/CallableRequest.mjs +18 -0
  70. package/dist/functions/Caller.d.mts +103 -0
  71. package/dist/functions/Caller.mjs +115 -0
  72. package/dist/functions/DocumentTrigger.d.mts +795 -0
  73. package/dist/functions/DocumentTrigger.mjs +566 -0
  74. package/dist/functions/FirebaseFunction.d.mts +79 -0
  75. package/dist/functions/FirebaseFunction.mjs +4 -0
  76. package/dist/functions/FunctionGroup.d.mts +175 -0
  77. package/dist/functions/FunctionGroup.mjs +64 -0
  78. package/dist/functions/index.d.mts +9 -0
  79. package/dist/functions/index.mjs +9 -0
  80. package/dist/functions/internal/callable.mjs +167 -0
  81. package/dist/functions/internal/callableMiddleware.d.mts +19 -0
  82. package/dist/functions/internal/callableMiddleware.mjs +15 -0
  83. package/dist/functions/internal/callableRequest.mjs +6 -0
  84. package/dist/functions/internal/documentTrigger.d.mts +10 -0
  85. package/dist/functions/internal/documentTrigger.mjs +73 -0
  86. package/dist/functions/internal/firebaseFunction.d.mts +5 -0
  87. package/dist/functions/internal/firebaseFunction.mjs +24 -0
  88. package/dist/functions/internal/functionGroup.mjs +36 -0
  89. package/package.json +66 -3
  90. package/src/app/App.ts +24 -0
  91. package/src/app/index.ts +7 -0
  92. package/src/firestore/Collection.ts +520 -0
  93. package/src/firestore/CollectionGroup.ts +245 -0
  94. package/src/firestore/DocumentPath.ts +114 -0
  95. package/src/firestore/DocumentSnapshot.ts +308 -0
  96. package/src/firestore/FieldValue.ts +375 -0
  97. package/src/firestore/Firestore.ts +1432 -0
  98. package/src/firestore/FirestoreModel.ts +1884 -0
  99. package/src/firestore/FirestoreSchema.ts +643 -0
  100. package/src/firestore/FirestoreValue.ts +207 -0
  101. package/src/firestore/GeoPoint.ts +105 -0
  102. package/src/firestore/Patch.ts +117 -0
  103. package/src/firestore/QuerySnapshot.ts +150 -0
  104. package/src/firestore/Timestamp.ts +302 -0
  105. package/src/firestore/index.ts +67 -0
  106. package/src/firestore/internal/collection.ts +246 -0
  107. package/src/firestore/internal/collectionGroup.ts +81 -0
  108. package/src/firestore/internal/documentPath.ts +53 -0
  109. package/src/firestore/internal/documentSnapshot.ts +117 -0
  110. package/src/firestore/internal/fieldValue.ts +119 -0
  111. package/src/firestore/internal/firestore.ts +322 -0
  112. package/src/firestore/internal/firestoreError.ts +36 -0
  113. package/src/firestore/internal/firestoreModel.ts +1242 -0
  114. package/src/firestore/internal/firestoreSchema.ts +218 -0
  115. package/src/firestore/internal/firestoreValue.ts +202 -0
  116. package/src/firestore/internal/firestoreValueWalk.ts +349 -0
  117. package/src/firestore/internal/geoPoint.ts +59 -0
  118. package/src/firestore/internal/patch.ts +21 -0
  119. package/src/firestore/internal/querySnapshot.ts +39 -0
  120. package/src/firestore/internal/schemaAST.ts +83 -0
  121. package/src/firestore/internal/snapshotExtra.ts +47 -0
  122. package/src/firestore/internal/streamFromListener.ts +27 -0
  123. package/src/firestore/internal/timestamp.ts +119 -0
  124. package/src/firestore/internal/timestampSchema.ts +42 -0
  125. package/src/firestore/internal/transactionFromCallback.ts +106 -0
  126. package/src/functions/Callable.ts +855 -0
  127. package/src/functions/CallableError.ts +348 -0
  128. package/src/functions/CallableMiddleware.ts +279 -0
  129. package/src/functions/CallableRequest.ts +207 -0
  130. package/src/functions/Caller.ts +141 -0
  131. package/src/functions/DocumentTrigger.ts +927 -0
  132. package/src/functions/FirebaseFunction.ts +96 -0
  133. package/src/functions/FunctionGroup.ts +225 -0
  134. package/src/functions/index.ts +62 -0
  135. package/src/functions/internal/callable.ts +272 -0
  136. package/src/functions/internal/callableMiddleware.ts +54 -0
  137. package/src/functions/internal/callableRequest.ts +8 -0
  138. package/src/functions/internal/documentTrigger.ts +143 -0
  139. package/src/functions/internal/firebaseFunction.ts +58 -0
  140. package/src/functions/internal/functionGroup.ts +47 -0
@@ -0,0 +1,1242 @@
1
+ import {
2
+ Effect,
3
+ Predicate,
4
+ Schema,
5
+ SchemaAST,
6
+ SchemaGetter,
7
+ SchemaIssue,
8
+ Result,
9
+ SchemaParser,
10
+ } from "effect";
11
+ import { VariantSchema } from "effect/schema";
12
+ import type * as FirestoreModel from "../FirestoreModel.ts";
13
+ import type * as FirestoreSchema from "../FirestoreSchema.ts";
14
+ import * as fieldValue from "./fieldValue.ts";
15
+ import * as firestoreSchema from "./firestoreSchema.ts";
16
+ import * as firestoreValue from "./firestoreValue.ts";
17
+ import * as firestoreValueWalk from "./firestoreValueWalk.ts";
18
+ import * as patch from "./patch.ts";
19
+
20
+ const Variants = VariantSchema.make({
21
+ variants: ["read", "create", "update"],
22
+ defaultVariant: "read",
23
+ });
24
+
25
+ /** @internal */
26
+ export const Field = Variants.Field;
27
+
28
+ /** @internal */
29
+ export const FieldOnly = Variants.FieldOnly;
30
+
31
+ /** @internal */
32
+ export const FieldExcept = Variants.FieldExcept;
33
+
34
+ /** @internal */
35
+ export const Struct = Variants.Struct;
36
+
37
+ /** @internal */
38
+ export const fieldEvolve = Variants.fieldEvolve;
39
+
40
+ /** @internal */
41
+ export const extract = Variants.extract;
42
+
43
+ /** @internal */
44
+ export const UnionTypeId = "~@visiolab/effect-firebase/firestore/FirestoreModel/Union";
45
+
46
+ // What `VariantSchema` tells a field by, which a union carries to be extracted
47
+ // variant by variant inside a struct, as a field is. Effect does not export
48
+ // it, so it is written out here: one more reliance on Effect internals of the
49
+ // kind ADR 0002 accepts, held safe by `effect` being pinned to an exact version.
50
+ const FieldTypeId = "~effect/schema/VariantSchema/Field";
51
+
52
+ type Members = ReadonlyArray<VariantSchema.Struct<any>>;
53
+
54
+ /** @internal */
55
+ export const fields = <A extends VariantSchema.Struct<any>>(
56
+ self: A,
57
+ ): A[typeof VariantSchema.TypeId] => VariantSchema.fields(self);
58
+
59
+ /** @internal */
60
+ export const RecordTypeId = "~@visiolab/effect-firebase/firestore/FirestoreModel/Record";
61
+
62
+ /**
63
+ * Where an update goes one level down when flattening: at the keys of a struct,
64
+ * or at every key of a Record, whatever it is. A key missing from both is a
65
+ * value, written whole.
66
+ */
67
+ interface Shape {
68
+ readonly fields: { readonly [key: string]: Shape };
69
+ readonly rest: { readonly value: Shape | undefined } | undefined;
70
+ }
71
+
72
+ /** The keys of a Record that only the data names, and what each of them holds. */
73
+ interface Rest<A> {
74
+ readonly key: Schema.Top;
75
+ readonly value: A;
76
+ }
77
+
78
+ /**
79
+ * A path an update writes at, one segment per key: the name of a field, or the
80
+ * schema of the key of a Record, which encodes whatever key sits there.
81
+ */
82
+ type Path = ReadonlyArray<string | Schema.Top>;
83
+
84
+ /**
85
+ * The schemas an update writes at: by path where it only goes through fields,
86
+ * and by the pattern of the paths where it goes through a Record.
87
+ */
88
+ interface Paths {
89
+ readonly known: Record<string, Schema.Top>;
90
+ readonly patterns: Map<string, Schema.Top>;
91
+ }
92
+
93
+ /** No path yet, to gather an update's into. */
94
+ const emptyPaths = (): Paths => ({ known: {}, patterns: new Map() });
95
+
96
+ /**
97
+ * Throws on a field name holding a dot, which a path would read as two.
98
+ */
99
+ const assertFieldName = (key: string): void => {
100
+ if (key.includes(".")) {
101
+ throw new Error(`Invalid Firestore field name "${key}": it must not contain "."`);
102
+ }
103
+ };
104
+
105
+ /** The reason a key is not one a path can name on its own, if it is not. */
106
+ const unaddressable = (key: string): string | undefined =>
107
+ key === "" ? "must not be empty" : key.includes(".") ? 'must not contain "."' : undefined;
108
+
109
+ /** A key a path names on its own, which is a non-empty one holding no dot. */
110
+ const Segment = Schema.String.check(Schema.isPattern(/^[^.]+$/));
111
+
112
+ /** Where a path sits, with `*` for each key only the data names. */
113
+ const where = (path: Path): string =>
114
+ path.map((segment) => (Schema.isSchema(segment) ? "*" : segment)).join(".");
115
+
116
+ /**
117
+ * The parts of the template literal a path matches: the names of the fields
118
+ * along it, joined by dots, and `key` for each key of a Record.
119
+ */
120
+ const template = <A>(path: Path, key: (schema: Schema.Top, i: number) => A): Array<string | A> => {
121
+ const parts: Array<string | A> = [];
122
+ let literal = "";
123
+ path.forEach((segment, i) => {
124
+ if (i > 0) {
125
+ literal += ".";
126
+ }
127
+ if (Schema.isSchema(segment)) {
128
+ if (literal !== "") {
129
+ parts.push(literal);
130
+ }
131
+ parts.push(key(segment, i));
132
+ literal = "";
133
+ } else {
134
+ literal += segment;
135
+ }
136
+ });
137
+ if (literal !== "") {
138
+ parts.push(literal);
139
+ }
140
+ return parts;
141
+ };
142
+
143
+ /**
144
+ * The key of a pattern of paths, as an update is written with it and as
145
+ * Firestore holds it: each key of a Record is encoded by the Record's key
146
+ * schema, and refused where its encoded form is one no path can name.
147
+ */
148
+ const PathKey = (path: Path): Schema.Top => {
149
+ const encoded = template(path, (key, i) =>
150
+ Schema.String.check(
151
+ Schema.makeFilter((segment: string) => {
152
+ const reason = unaddressable(segment);
153
+ return reason === undefined
154
+ ? undefined
155
+ : `Invalid Firestore field name "${segment}" in "${where(path.slice(0, i))}": it ${reason}`;
156
+ }),
157
+ ).pipe(Schema.decodeTo(key as Schema.Codec<unknown, string>)),
158
+ );
159
+ const decoded = template(path, (key) => (recordKeys(key).rest ? Segment : Schema.toType(key)));
160
+ const parser = Schema.TemplateLiteralParser(encoded as any) as unknown as Schema.Codec<
161
+ ReadonlyArray<unknown>,
162
+ string
163
+ >;
164
+ return parser.pipe(
165
+ Schema.decodeTo(Schema.TemplateLiteral(decoded as any) as Schema.Codec<string, string>, {
166
+ decode: SchemaGetter.transform((parts) => parts.join("")),
167
+ // A key a path names holds no dot, so splitting the path at each one
168
+ // gives back its keys.
169
+ encode: SchemaGetter.transform((key) => {
170
+ const segments = key.split(".");
171
+ return template(path, (_, i) => segments[i]);
172
+ }),
173
+ }),
174
+ );
175
+ };
176
+
177
+ // Every key of a Record known up front sits at the same pattern, which is only
178
+ // added once. A pattern holds no optional key, since any path it matches may be
179
+ // left out anyway.
180
+ const addPath = (paths: Paths, path: Path, schema: Schema.Top): void => {
181
+ if (path.every((segment) => typeof segment === "string")) {
182
+ paths.known[path.join(".")] = Schema.optionalKey(schema);
183
+ } else if (!paths.patterns.has(where(path))) {
184
+ paths.patterns.set(
185
+ where(path),
186
+ Schema.Record(PathKey(path) as Schema.Record.Key, withoutOptionalKey(schema)),
187
+ );
188
+ }
189
+ };
190
+
191
+ const variantSchema = (
192
+ field: unknown,
193
+ variant: "read" | "create" | "update",
194
+ ): Schema.Top | undefined =>
195
+ VariantSchema.isStruct(field)
196
+ ? (extract(field, variant) as Schema.Top)
197
+ : VariantSchema.isField(field)
198
+ ? (field.schemas as any)[variant]
199
+ : (field as Schema.Top);
200
+
201
+ const updateSchema = (field: unknown): Schema.Top | undefined =>
202
+ VariantSchema.isStruct(field) ? undefined : variantSchema(field, "update");
203
+
204
+ // What Effect itself strips a default with, though it is left out of the
205
+ // typings.
206
+ const replaceContext = (SchemaAST as any).replaceContext as <A extends SchemaAST.AST>(
207
+ ast: A,
208
+ context: SchemaAST.Context,
209
+ ) => A;
210
+
211
+ /**
212
+ * Drops the default a field is constructed with. A patch leaves a key out to
213
+ * keep what the document holds, so filling one in would overwrite it.
214
+ */
215
+ const withoutConstructorDefault = (schema: Schema.Top): Schema.Top => {
216
+ const context = schema.ast.context;
217
+ if (context?.constructorDefault === undefined) {
218
+ return schema;
219
+ }
220
+ return Schema.make(
221
+ replaceContext(
222
+ schema.ast,
223
+ new SchemaAST.Context(context.isOptional, context.isMutable, undefined, context.annotations),
224
+ ),
225
+ );
226
+ };
227
+
228
+ /**
229
+ * Drops the optional key a schema is made, for a value at a key only the data
230
+ * names, which may be missing whatever it holds.
231
+ */
232
+ const withoutOptionalKey = (schema: Schema.Top): Schema.Top => {
233
+ const context = schema.ast.context;
234
+ if (context?.isOptional !== true) {
235
+ return schema;
236
+ }
237
+ return Schema.make(
238
+ replaceContext(
239
+ schema.ast,
240
+ new SchemaAST.Context(
241
+ false,
242
+ context.isMutable,
243
+ context.constructorDefault,
244
+ context.annotations,
245
+ ),
246
+ ),
247
+ );
248
+ };
249
+
250
+ /** The keys a Record key schema names up front, and whether the data names more. */
251
+ const recordKeys = (key: Schema.Top): { readonly known: Array<string>; readonly rest: boolean } => {
252
+ const ast = Schema.Record(key as Schema.Record.Key, Schema.Unknown).ast;
253
+ const known = ast.propertySignatures.map(({ name }) => {
254
+ if (typeof name === "symbol") {
255
+ throw new Error(`Invalid Firestore field name ${String(name)}: it must be a string`);
256
+ }
257
+ const segment = String(name);
258
+ const reason = unaddressable(segment);
259
+ if (reason !== undefined) {
260
+ throw new Error(`Invalid Firestore field name "${segment}": it ${reason}`);
261
+ }
262
+ return segment;
263
+ });
264
+ const rest = ast.indexSignatures.length > 0;
265
+ if (rest && known.length > 0) {
266
+ throw new Error(
267
+ `Invalid Record key: it names ${known.map((k) => `"${k}"`).join(", ")} besides keys only the data names, which an update cannot tell apart`,
268
+ );
269
+ }
270
+ return { known, rest };
271
+ };
272
+
273
+ /**
274
+ * What an update reaches into: a struct, or a Record. Either holds fields at
275
+ * keys known up front, and a Record may hold more at keys only the data names.
276
+ */
277
+ interface Node {
278
+ readonly fields: VariantSchema.Struct.Fields;
279
+ /**
280
+ * The members of a union, which has no fields of its own to reach into: it
281
+ * is written whole.
282
+ */
283
+ readonly members?: Members | undefined;
284
+ /**
285
+ * The key schema of a Record keyed by literals, which a path names each of
286
+ * its fields by, to encode it as part of the update.
287
+ */
288
+ readonly key: Schema.Top | undefined;
289
+ readonly rest: Rest<unknown> | undefined;
290
+ /** The whole node, as `set` writes it. */
291
+ readonly create: () => Schema.Top;
292
+ /** Whether a document may go without it. */
293
+ readonly optional: boolean;
294
+ /** Whether an update writes anything at all into it. */
295
+ readonly writable: boolean;
296
+ }
297
+
298
+ const recordNode = (
299
+ key: Schema.Top,
300
+ value: unknown,
301
+ create: () => Schema.Top,
302
+ optional: boolean,
303
+ ): Node => {
304
+ const keys = recordKeys(key);
305
+ return {
306
+ fields: Object.fromEntries(keys.known.map((k) => [k, value])) as VariantSchema.Struct.Fields,
307
+ key: keys.rest ? undefined : key,
308
+ rest: keys.rest ? { key, value } : undefined,
309
+ create,
310
+ optional,
311
+ // A value no update writes leaves nothing of the Record to write either.
312
+ writable: nodeOf(value)?.writable ?? updateSchema(value) !== undefined,
313
+ };
314
+ };
315
+
316
+ /** Whether a field was made {@link FirestoreModel.Optional}. */
317
+ const isOptional = (field: unknown): field is { readonly field: unknown } =>
318
+ Predicate.hasProperty(field, "~optionality") && field["~optionality"] === "optional";
319
+
320
+ /** What a field reaches into, if it is a struct or a Record. */
321
+ const nodeOf = (field: unknown, optional = false): Node | undefined => {
322
+ if (isOptional(field)) {
323
+ return nodeOf(field.field, true);
324
+ }
325
+ if (Predicate.hasProperty(field, UnionTypeId)) {
326
+ return unionNode(field[UnionTypeId] as Members, optional);
327
+ }
328
+ if (Predicate.hasProperty(field, RecordTypeId)) {
329
+ const { key, value } = field[RecordTypeId] as Rest<unknown>;
330
+ return recordNode(key, value, () => variantSchema(field, "create")!, optional);
331
+ }
332
+ if (VariantSchema.isStruct(field)) {
333
+ return {
334
+ fields: VariantSchema.fields(field),
335
+ key: undefined,
336
+ rest: undefined,
337
+ create: () => extract(field, "create") as Schema.Top,
338
+ optional,
339
+ writable: true,
340
+ };
341
+ }
342
+ return undefined;
343
+ };
344
+
345
+ /**
346
+ * Whether a set of fields may be written whole by an update: only where every
347
+ * field it holds, at any depth, is written both by `set`, whose shape a
348
+ * replacement takes, and by an update. Replacing one holding a field written
349
+ * once would change it, and one holding a field `set` leaves out would drop it.
350
+ */
351
+ const isReplaceable = (fields: VariantSchema.Struct.Fields): boolean =>
352
+ Object.values(fields).every((field) => {
353
+ const node = nodeOf(field);
354
+ return node === undefined
355
+ ? variantSchema(field, "create") !== undefined && updateSchema(field) !== undefined
356
+ : isNodeReplaceable(node);
357
+ });
358
+
359
+ const isNodeReplaceable = (node: Node): boolean =>
360
+ node.members !== undefined
361
+ ? node.members.every(isMemberReplaceable)
362
+ : isReplaceable(node.fields) &&
363
+ (node.rest === undefined ||
364
+ isReplaceable({ _: node.rest.value } as VariantSchema.Struct.Fields));
365
+
366
+ const isMemberReplaceable = (member: VariantSchema.Struct<any>): boolean =>
367
+ isReplaceable(fields(member));
368
+
369
+ /** The union of what `set` writes each of `members` as. */
370
+ const unionCreate = (members: Members): Schema.Top =>
371
+ Schema.Union(members.map((member) => extract(member, "create") as Schema.Top));
372
+
373
+ const unionNode = (members: Members, optional: boolean): Node => ({
374
+ fields: {},
375
+ members,
376
+ key: undefined,
377
+ rest: undefined,
378
+ create: () => unionCreate(members),
379
+ optional,
380
+ writable: optional || members.some(isMemberReplaceable) || hasSharedField(members.map(fields)),
381
+ });
382
+
383
+ /**
384
+ * The replacement of a struct, holding the struct as `create` makes it - with
385
+ * its constructor defaults filled in, since a default left out of a struct
386
+ * written whole would be a field dropped.
387
+ *
388
+ * Given several, as the unions the members of a union hold at one key are, it
389
+ * is made by every one of them, each of which must take it, and holds it as
390
+ * the first makes it. That they also store it the same way is for the schema
391
+ * of its path to tell - see {@link SharedValueSchema}.
392
+ */
393
+ const ReplaceSchema = (creates: ReadonlyArray<Schema.Top>): Schema.Top =>
394
+ Schema.declareConstructor<any, any>()(
395
+ creates,
396
+ (creates) => (input, ast, options) => {
397
+ if (!patch.isReplace(input)) {
398
+ return Effect.fail(new SchemaIssue.InvalidType(ast, input, options));
399
+ }
400
+ return Effect.forEach(creates, (create) => create.makeEffect(input.value, options)).pipe(
401
+ Effect.mapBothEager({
402
+ onSuccess: ([made]) => patch.replace(made),
403
+ onFailure: (issue) => new SchemaIssue.Pointer(["value"], issue),
404
+ }),
405
+ );
406
+ },
407
+ { expected: "a struct written whole" },
408
+ );
409
+
410
+ /**
411
+ * The patch of a struct, refusing the markers a `Schema.Struct` would
412
+ * otherwise take for a patch of nothing, since it keeps only the keys it knows.
413
+ */
414
+ const StructPatchSchema = (fields: Schema.Top): Schema.Top =>
415
+ Schema.declareConstructor<any, any>()(
416
+ [fields],
417
+ ([fields]) =>
418
+ (input, ast, options) =>
419
+ patch.isReplace(input) || fieldValue.isDeleteField(input)
420
+ ? Effect.fail(new SchemaIssue.InvalidType(ast, input, options))
421
+ : SchemaParser.decodeUnknownEffect(fields)(input, options),
422
+ { expected: "a patch of a struct" },
423
+ );
424
+
425
+ /**
426
+ * Builds the patch a set of fields is updated through in `mode`, along with
427
+ * where its structs and Records sit, and gathers the schema of each path it can
428
+ * write at into `paths`.
429
+ *
430
+ * A struct takes a patch, written at its paths, unless it may be missing and
431
+ * the patch is checked, since the patch would create it holding only what it
432
+ * carried. It takes a replacement wherever it is replaceable, and a removal
433
+ * where it may be missing. A Record is a struct whose keys may come from the
434
+ * data - see {@link buildNode}.
435
+ *
436
+ * Where `recordKey` is given, the fields are the keys of a Record keyed by
437
+ * literals, and each sits on its path as that key schema encodes it.
438
+ *
439
+ * Throws on a field name holding a dot, which a path would read as two.
440
+ */
441
+ const build = (
442
+ fields: VariantSchema.Struct.Fields,
443
+ path: Path,
444
+ paths: Paths,
445
+ mode: FirestoreModel.UpdateMode,
446
+ recordKey?: Schema.Top,
447
+ ): { readonly patch: Schema.Top; readonly shape: Shape } => {
448
+ const schemas: Record<string, Schema.Top> = {};
449
+ const shapes: Record<string, Shape> = {};
450
+ for (const key of Object.keys(fields)) {
451
+ assertFieldName(key);
452
+ const field = fields[key];
453
+ const at = [...path, recordKey ?? key];
454
+ const node = nodeOf(field);
455
+ if (node !== undefined) {
456
+ if (node.writable) {
457
+ const built = buildNode(node, at, paths, mode);
458
+ schemas[key] = Schema.optionalKey(built.schema);
459
+ shapes[key] = built.shape;
460
+ }
461
+ } else {
462
+ const update = updateSchema(field);
463
+ if (update !== undefined) {
464
+ const schema = withoutConstructorDefault(update);
465
+ schemas[key] = Schema.optionalKey(schema);
466
+ addPath(paths, at, schema);
467
+ }
468
+ }
469
+ }
470
+ return { patch: Schema.Struct(schemas), shape: { fields: shapes, rest: undefined } };
471
+ };
472
+
473
+ /**
474
+ * Builds what a struct or a Record at `path` takes in `mode`, gathering the
475
+ * paths it writes at into `paths`, the way {@link build} does for a set of
476
+ * fields.
477
+ *
478
+ * A Record whose keys come from the data is patched key by key, each key
479
+ * written at its own path. Any such key may be missing, so each takes the
480
+ * removal, and a value that is itself a struct or a Record is treated as one a
481
+ * document may go without. That also makes a patch safe where the Record
482
+ * itself may be missing: every key it writes is one the Record may hold or not.
483
+ */
484
+ const buildNode = (
485
+ node: Node,
486
+ path: Path,
487
+ paths: Paths,
488
+ mode: FirestoreModel.UpdateMode,
489
+ ): { readonly schema: Schema.Top; readonly shape: Shape } => {
490
+ if (node.members !== undefined) {
491
+ return buildUnion(node, node.members, path, paths, mode);
492
+ }
493
+ const inner =
494
+ node.rest === undefined
495
+ ? build(node.fields, path, paths, mode, node.key)
496
+ : buildRest(node.rest, path, paths, mode);
497
+ const create = isNodeReplaceable(node) ? node.create() : undefined;
498
+ const takes: Array<Schema.Top> = [];
499
+ if (!node.optional || mode === "unchecked" || node.rest !== undefined) {
500
+ takes.push(StructPatchSchema(inner.patch));
501
+ }
502
+ if (create !== undefined) {
503
+ takes.push(ReplaceSchema([create]));
504
+ }
505
+ if (node.optional) {
506
+ takes.push(firestoreSchema.DeleteField);
507
+ }
508
+ const written = node.optional
509
+ ? create === undefined
510
+ ? firestoreSchema.DeleteField
511
+ : firestoreSchema.DeleteFieldOr(create)
512
+ : create;
513
+ if (written !== undefined) {
514
+ addPath(paths, path, written);
515
+ }
516
+ return {
517
+ schema: takes.length === 1 ? takes[0] : Schema.Union(takes),
518
+ shape: inner.shape,
519
+ };
520
+ };
521
+
522
+ /**
523
+ * Builds what a union at `path` takes in `mode`, gathering the paths it writes
524
+ * at into `paths`. A union is replaced with any member it may be replaced
525
+ * with, removed where it may be missing, and patched at its Shared Fields -
526
+ * and, unchecked, at a key only some members hold - see {@link buildShared}.
527
+ * The patch is refused where it may be missing and the patch is checked, as it
528
+ * is for a struct. One that takes none of these in `mode` takes nothing.
529
+ */
530
+ const buildUnion = (
531
+ node: Node,
532
+ members: Members,
533
+ path: Path,
534
+ paths: Paths,
535
+ mode: FirestoreModel.UpdateMode,
536
+ ): { readonly schema: Schema.Top; readonly shape: Shape } => {
537
+ const replaceable = members.filter(isMemberReplaceable);
538
+ const create = replaceable.length === 0 ? undefined : unionCreate(replaceable);
539
+ const shared = buildShared(members.map(fields), path, paths, mode);
540
+ const takes: Array<Schema.Top> = [];
541
+ if (shared.keys.length > 0 && (!node.optional || mode === "unchecked")) {
542
+ takes.push(shared.patch);
543
+ }
544
+ if (create !== undefined) {
545
+ takes.push(ReplaceSchema([create]));
546
+ }
547
+ if (node.optional) {
548
+ takes.push(firestoreSchema.DeleteField);
549
+ }
550
+ const written = node.optional
551
+ ? create === undefined
552
+ ? firestoreSchema.DeleteField
553
+ : firestoreSchema.DeleteFieldOr(create)
554
+ : create;
555
+ if (written !== undefined) {
556
+ addPath(paths, path, written);
557
+ }
558
+ return {
559
+ schema: takes.length === 1 ? takes[0] : Schema.Union(takes),
560
+ shape: shared.shape,
561
+ };
562
+ };
563
+
564
+ /** What a field was made {@link FirestoreModel.Optional} of, or the field itself. */
565
+ const unwrap = (field: unknown): unknown => (isOptional(field) ? field.field : field);
566
+
567
+ /** The types a type is a union of, or the type itself. */
568
+ const constituents = (ast: SchemaAST.AST): ReadonlyArray<SchemaAST.AST> =>
569
+ SchemaAST.isUnion(ast) ? ast.types.flatMap(constituents) : [ast];
570
+
571
+ /** What a declaration is told apart by, where it says. */
572
+ const declarationId = (ast: SchemaAST.Declaration): unknown =>
573
+ (ast.annotations?.representation as { readonly id?: unknown } | undefined)?.id;
574
+
575
+ /** The type of a literal a keyword is the type of. */
576
+ const keywordOf = (literal: unknown): string =>
577
+ typeof literal === "string"
578
+ ? "String"
579
+ : typeof literal === "number"
580
+ ? "Number"
581
+ : typeof literal === "boolean"
582
+ ? "Boolean"
583
+ : "BigInt";
584
+
585
+ /**
586
+ * Whether every value of `a` is one of `b`, the way TypeScript tells whether
587
+ * one type extends another, as far as the schemas say. Where they do not say -
588
+ * a declaration with nothing to tell it apart by, a suspended schema - it is
589
+ * taken to be, so the encoding decides.
590
+ */
591
+ const isSubtype = (a: SchemaAST.AST, b: SchemaAST.AST): boolean => {
592
+ if (SchemaAST.isUnion(a)) {
593
+ return a.types.every((type) => isSubtype(type, b));
594
+ }
595
+ if (SchemaAST.isUnion(b)) {
596
+ return b.types.some((type) => isSubtype(a, type));
597
+ }
598
+ if (SchemaAST.isAny(b) || SchemaAST.isUnknown(b) || SchemaAST.isNever(a) || SchemaAST.isAny(a)) {
599
+ return true;
600
+ }
601
+ if (SchemaAST.isSuspend(a) || SchemaAST.isSuspend(b)) {
602
+ return true;
603
+ }
604
+ if (SchemaAST.isLiteral(a)) {
605
+ return SchemaAST.isLiteral(b)
606
+ ? a.literal === b.literal
607
+ : SchemaAST.isEnum(b)
608
+ ? b.enums.some(([, value]) => value === a.literal)
609
+ : SchemaAST.isTemplateLiteral(b)
610
+ ? typeof a.literal === "string"
611
+ : b._tag === keywordOf(a.literal);
612
+ }
613
+ if (SchemaAST.isEnum(a)) {
614
+ return a.enums.every(([, value]) => isSubtype(new SchemaAST.Literal(value), b));
615
+ }
616
+ if (SchemaAST.isTemplateLiteral(a)) {
617
+ return SchemaAST.isString(b) || SchemaAST.isTemplateLiteral(b);
618
+ }
619
+ if (SchemaAST.isUniqueSymbol(a)) {
620
+ return SchemaAST.isSymbol(b) || (SchemaAST.isUniqueSymbol(b) && a.symbol === b.symbol);
621
+ }
622
+ if (SchemaAST.isUndefined(a) && SchemaAST.isVoid(b)) {
623
+ return true;
624
+ }
625
+ if (SchemaAST.isObjectKeyword(b)) {
626
+ return (
627
+ SchemaAST.isObjectKeyword(a) ||
628
+ SchemaAST.isObjects(a) ||
629
+ SchemaAST.isArrays(a) ||
630
+ SchemaAST.isDeclaration(a)
631
+ );
632
+ }
633
+ if (SchemaAST.isDeclaration(a)) {
634
+ if (SchemaAST.isDeclaration(b)) {
635
+ const [ida, idb] = [declarationId(a), declarationId(b)];
636
+ return (
637
+ a === b ||
638
+ ida === undefined ||
639
+ idb === undefined ||
640
+ (ida === idb &&
641
+ a.typeParameters.length === b.typeParameters.length &&
642
+ a.typeParameters.every((p, i) => isSubtype(p, b.typeParameters[i])))
643
+ );
644
+ }
645
+ // An instance may well have every property a struct asks for.
646
+ return SchemaAST.isObjects(b);
647
+ }
648
+ if (SchemaAST.isObjects(a)) {
649
+ if (SchemaAST.isDeclaration(b)) {
650
+ return declarationId(b) === undefined;
651
+ }
652
+ if (!SchemaAST.isObjects(b)) {
653
+ return false;
654
+ }
655
+ const own = new Map(a.propertySignatures.map((p) => [p.name, p.type]));
656
+ return (
657
+ b.propertySignatures.every((p) => {
658
+ const type = own.get(p.name);
659
+ return type === undefined
660
+ ? SchemaAST.isOptional(p.type)
661
+ : (SchemaAST.isOptional(p.type) || !SchemaAST.isOptional(type)) &&
662
+ isSubtype(type, p.type);
663
+ }) &&
664
+ b.indexSignatures.every((index) =>
665
+ [...a.propertySignatures, ...a.indexSignatures].every((p) => isSubtype(p.type, index.type)),
666
+ )
667
+ );
668
+ }
669
+ if (SchemaAST.isArrays(a)) {
670
+ if (!SchemaAST.isArrays(b)) {
671
+ return false;
672
+ }
673
+ if (b.elements.length === 0 && b.rest.length === 1) {
674
+ return [...a.elements, ...a.rest].every((type) => isSubtype(type, b.rest[0]));
675
+ }
676
+ if (a.rest.length === 0 && b.rest.length === 0) {
677
+ // A tuple is one of another where each element it may hold is one of
678
+ // the other's at the same place, and the other may go without each
679
+ // element beyond its own.
680
+ return (
681
+ a.elements.length <= b.elements.length &&
682
+ b.elements.every((type, i) => {
683
+ const own = a.elements[i];
684
+ return own === undefined
685
+ ? SchemaAST.isOptional(type)
686
+ : (SchemaAST.isOptional(type) || !SchemaAST.isOptional(own)) && isSubtype(own, type);
687
+ })
688
+ );
689
+ }
690
+ // With a rest, only a tuple shaped alike is told apart element by element.
691
+ return (
692
+ a.elements.length === b.elements.length &&
693
+ a.rest.length === b.rest.length &&
694
+ [...a.elements, ...a.rest].every((type, i) => isSubtype(type, [...b.elements, ...b.rest][i]))
695
+ );
696
+ }
697
+ return a._tag === b._tag;
698
+ };
699
+
700
+ /**
701
+ * The values out of `a` and `b` that the other takes too: each type `a` is a
702
+ * union of that `b` takes whole, and the other way round, as `Meet` works it
703
+ * out in the types.
704
+ */
705
+ const meet = (
706
+ a: ReadonlyArray<SchemaAST.AST>,
707
+ b: ReadonlyArray<SchemaAST.AST>,
708
+ ): ReadonlyArray<SchemaAST.AST> => {
709
+ const within = (types: ReadonlyArray<SchemaAST.AST>) => (type: SchemaAST.AST) =>
710
+ types.some((other) => isSubtype(type, other));
711
+ return [...a.filter(within(b)), ...b.filter(within(a))];
712
+ };
713
+
714
+ /**
715
+ * Whether some value is taken by every one of `schemas`, as `MeetAll` tells it
716
+ * in the types, so a key is shared, and has a path, exactly where its type
717
+ * says. The literal that tells members apart takes none.
718
+ */
719
+ const haveValueInCommon = (schemas: ReadonlyArray<Schema.Top>): boolean =>
720
+ schemas.map((schema) => constituents(schema.ast)).reduceRight((acc, types) => meet(types, acc))
721
+ .length > 0;
722
+
723
+ /**
724
+ * What a Shared Field is patched through: the value is encoded through the
725
+ * field of every member, each of which must take it, and all of which must
726
+ * store it as the same Firestore Value. Each member decides by the canonical
727
+ * codec, not by the encoding the caller asks for, since that is what Firestore
728
+ * ends up holding. It is written as the first member writes it, which is then
729
+ * what every member would write.
730
+ */
731
+ const SharedValueSchema = (schemas: ReadonlyArray<Schema.Top>): Schema.Top => {
732
+ if (schemas.length === 1) {
733
+ return schemas[0];
734
+ }
735
+ // Encoding runs synchronously, as a check does, so a member's encoding may
736
+ // ask for no service.
737
+ const codecs = schemas.map((schema) =>
738
+ firestoreValue.toCodecFirestore(schema as Schema.Codec<unknown, unknown>),
739
+ );
740
+ return schemas[0].check(
741
+ Schema.makeFilter(
742
+ (value: unknown, _, options) => {
743
+ let stored: unknown;
744
+ for (const [i, codec] of codecs.entries()) {
745
+ const result = SchemaParser.encodeUnknownResult(codec)(value, options);
746
+ if (Result.isFailure(result)) {
747
+ return result.failure;
748
+ }
749
+ if (i === 0) {
750
+ stored = result.success;
751
+ } else if (!firestoreValueWalk.equals(stored, result.success)) {
752
+ return "Expected a value every member of the union stores the same way";
753
+ }
754
+ }
755
+ return undefined;
756
+ },
757
+ { expected: "a value every member of the union takes and stores the same way" },
758
+ ),
759
+ );
760
+ };
761
+
762
+ /**
763
+ * The patch of the Shared Fields of a union, refusing a key it does not know,
764
+ * since a `Schema.Struct` would otherwise drop it and write nothing. That is a
765
+ * key some member goes without, or one the members cannot agree on, such as
766
+ * the literal they are told apart by.
767
+ */
768
+ const SharedPatchSchema = (fields: Schema.Top, keys: ReadonlyArray<string>): Schema.Top =>
769
+ Schema.declareConstructor<any, any>()(
770
+ [fields],
771
+ ([fields]) =>
772
+ (input, ast, options) => {
773
+ if (patch.isReplace(input) || fieldValue.isDeleteField(input)) {
774
+ return Effect.fail(new SchemaIssue.InvalidType(ast, input, options));
775
+ }
776
+ if (Predicate.isObject(input)) {
777
+ const unknown = Object.keys(input).find((key) => !keys.includes(key));
778
+ if (unknown !== undefined) {
779
+ return Effect.fail(
780
+ new SchemaIssue.Pointer(
781
+ [unknown],
782
+ new SchemaIssue.UnexpectedKey(ast, (input as Record<string, unknown>)[unknown]),
783
+ ),
784
+ );
785
+ }
786
+ }
787
+ return SchemaParser.decodeUnknownEffect(fields)(input, options);
788
+ },
789
+ { expected: "a patch of the Shared Fields of a union" },
790
+ );
791
+
792
+ /**
793
+ * Builds the patch of the Shared Fields of a union, given the fields of each
794
+ * of its members, along with where the structs among them sit, and gathers the
795
+ * schema of each path it can write at into `paths`.
796
+ *
797
+ * A Shared Field is a key every member holds, at which every member takes an
798
+ * update: a value, patched through {@link SharedValueSchema}, or a struct,
799
+ * patched at its own Shared Fields by the same rule one level down. A struct
800
+ * every member may go without takes the removal alone, and one only some
801
+ * members may go without takes nothing. A union is shared by the rule a union
802
+ * field is written by - see {@link buildSharedUnion}. A Record is not shared.
803
+ * Where no value is taken by every member's type, as
804
+ * {@link haveValueInCommon} tells, the key is not shared either, which is what
805
+ * keeps it out of `paths` exactly where the type keeps it out.
806
+ *
807
+ * Under `unchecked`, a key only some members hold is patched by the same rule
808
+ * over just those members. Its path is gathered in either mode, so the paths
809
+ * are the same in both and only the patch tells them apart.
810
+ *
811
+ * {@link hasSharedField} tells, without building any of it, whether this
812
+ * takes any key under `unchecked`, and must agree with it.
813
+ */
814
+ const buildShared = (
815
+ memberFields: ReadonlyArray<VariantSchema.Struct.Fields>,
816
+ path: Path,
817
+ paths: Paths,
818
+ mode: FirestoreModel.UpdateMode,
819
+ ): { readonly patch: Schema.Top; readonly shape: Shape; readonly keys: ReadonlyArray<string> } => {
820
+ const schemas: Record<string, Schema.Top> = {};
821
+ const shapes: Record<string, Shape> = {};
822
+ for (const key of keysOf(memberFields)) {
823
+ assertFieldName(key);
824
+ // A key only some members hold is checked against just those, and only
825
+ // patched where the caller vouches for the member. It is written at its
826
+ // path in either mode, as a struct a document may go without is.
827
+ const holding = memberFields.filter((f) => Object.hasOwn(f, key));
828
+ const taken = holding.length === memberFields.length || mode === "unchecked";
829
+ const at = [...path, key];
830
+ const atKey = holding.map((f) => f[key]);
831
+ if (atKey.every(isStructField)) {
832
+ if (atKey.every((field) => !isOptional(field))) {
833
+ const inner = buildShared(atKey.map(structFields), at, paths, mode);
834
+ if (taken && inner.keys.length > 0) {
835
+ schemas[key] = Schema.optionalKey(inner.patch);
836
+ shapes[key] = inner.shape;
837
+ }
838
+ } else if (atKey.every(isOptional)) {
839
+ if (taken) {
840
+ schemas[key] = Schema.optionalKey(firestoreSchema.DeleteField);
841
+ }
842
+ addPath(paths, at, firestoreSchema.DeleteField);
843
+ }
844
+ } else if (atKey.every(isUnionField)) {
845
+ const built = buildSharedUnion(atKey, at, paths, mode);
846
+ if (taken && built !== undefined) {
847
+ schemas[key] = Schema.optionalKey(built.schema);
848
+ shapes[key] = built.shape;
849
+ }
850
+ } else if (atKey.every((field) => !isNodeField(field))) {
851
+ const updates = atKey.map(updateSchema);
852
+ if (updates.every((u) => u !== undefined) && haveValueInCommon(updates)) {
853
+ const schema = SharedValueSchema(updates.map(withoutConstructorDefault));
854
+ if (taken) {
855
+ schemas[key] = Schema.optionalKey(schema);
856
+ }
857
+ addPath(paths, at, schema);
858
+ }
859
+ }
860
+ }
861
+ const keys = Object.keys(schemas);
862
+ return {
863
+ patch: SharedPatchSchema(Schema.Struct(schemas), keys),
864
+ shape: { fields: shapes, rest: undefined },
865
+ keys,
866
+ };
867
+ };
868
+
869
+ /** The keys any of the members holding `memberFields` holds. */
870
+ const keysOf = (
871
+ memberFields: ReadonlyArray<VariantSchema.Struct.Fields>,
872
+ ): ReadonlyArray<string> => [...new Set(memberFields.flatMap((f) => Object.keys(f)))];
873
+
874
+ /** Whether a field is a struct, {@link FirestoreModel.Optional} or not. */
875
+ const isStructField = (field: unknown): boolean => VariantSchema.isStruct(unwrap(field));
876
+
877
+ /** The fields of the struct a field is, {@link FirestoreModel.Optional} or not. */
878
+ const structFields = (field: unknown): VariantSchema.Struct.Fields =>
879
+ fields(unwrap(field) as VariantSchema.Struct<any>);
880
+
881
+ /** Whether a field is a union, {@link FirestoreModel.Optional} or not. */
882
+ const isUnionField = (field: unknown): boolean => Predicate.hasProperty(unwrap(field), UnionTypeId);
883
+
884
+ /** The members of the union a field is, {@link FirestoreModel.Optional} or not. */
885
+ const unionMembers = (field: unknown): Members => (unwrap(field) as any)[UnionTypeId];
886
+
887
+ /** Whether an update reaches into a field: a struct, a Record or a union. */
888
+ const isNodeField = (field: unknown): boolean =>
889
+ isStructField(field) || isUnionField(field) || Predicate.hasProperty(unwrap(field), RecordTypeId);
890
+
891
+ /** The fields of every member of each of the unions `atKey`, in one list. */
892
+ const unionsMemberFields = (
893
+ atKey: ReadonlyArray<unknown>,
894
+ ): ReadonlyArray<VariantSchema.Struct.Fields> =>
895
+ atKey.flatMap((field) => unionMembers(field).map(fields));
896
+
897
+ /**
898
+ * What each of the unions `atKey` is replaced with, where every one of them is
899
+ * replaced with something and some value is taken by all of them, as the type
900
+ * tells it.
901
+ */
902
+ const sharedReplacement = (
903
+ atKey: ReadonlyArray<unknown>,
904
+ ): ReadonlyArray<Schema.Top> | undefined => {
905
+ const creates: Array<Schema.Top> = [];
906
+ for (const field of atKey) {
907
+ const replaceable = unionMembers(field).filter(isMemberReplaceable);
908
+ if (replaceable.length === 0) {
909
+ return undefined;
910
+ }
911
+ creates.push(unionCreate(replaceable));
912
+ }
913
+ return haveValueInCommon(creates) ? creates : undefined;
914
+ };
915
+
916
+ /**
917
+ * Builds what the unions `atKey` the members of a union hold at one key take
918
+ * in `mode`, gathering the paths it writes at into `paths`, or `undefined`
919
+ * where they take nothing. It is the rule a union field is written by, held to
920
+ * every one of them: a replacement every one takes and stores the same way,
921
+ * the removal where every one may be missing, and a patch at the Shared Fields
922
+ * of all their members together, refused where any of them may be missing and
923
+ * the patch is checked.
924
+ */
925
+ const buildSharedUnion = (
926
+ atKey: ReadonlyArray<unknown>,
927
+ path: Path,
928
+ paths: Paths,
929
+ mode: FirestoreModel.UpdateMode,
930
+ ): { readonly schema: Schema.Top; readonly shape: Shape } | undefined => {
931
+ const optional = atKey.every(isOptional);
932
+ const creates = sharedReplacement(atKey);
933
+ const shared = buildShared(unionsMemberFields(atKey), path, paths, mode);
934
+ const takes: Array<Schema.Top> = [];
935
+ if (shared.keys.length > 0 && (mode === "unchecked" || !atKey.some(isOptional))) {
936
+ takes.push(shared.patch);
937
+ }
938
+ if (creates !== undefined) {
939
+ takes.push(ReplaceSchema(creates));
940
+ }
941
+ if (optional) {
942
+ takes.push(firestoreSchema.DeleteField);
943
+ }
944
+ const whole = creates === undefined ? undefined : SharedValueSchema(creates);
945
+ const written = optional
946
+ ? whole === undefined
947
+ ? firestoreSchema.DeleteField
948
+ : firestoreSchema.DeleteFieldOr(whole)
949
+ : whole;
950
+ if (written !== undefined) {
951
+ addPath(paths, path, written);
952
+ }
953
+ return takes.length === 0
954
+ ? undefined
955
+ : { schema: takes.length === 1 ? takes[0] : Schema.Union(takes), shape: shared.shape };
956
+ };
957
+
958
+ /**
959
+ * Whether members holding `memberFields` take any key under `unchecked`, as
960
+ * {@link buildShared} would build it, but without building a schema.
961
+ */
962
+ const hasSharedField = (memberFields: ReadonlyArray<VariantSchema.Struct.Fields>): boolean =>
963
+ keysOf(memberFields).some((key) => {
964
+ const atKey = memberFields.filter((f) => Object.hasOwn(f, key)).map((f) => f[key]);
965
+ if (atKey.every(isStructField)) {
966
+ return atKey.every((field) => !isOptional(field))
967
+ ? hasSharedField(atKey.map(structFields))
968
+ : atKey.every(isOptional);
969
+ }
970
+ if (atKey.every(isUnionField)) {
971
+ return (
972
+ atKey.every(isOptional) ||
973
+ sharedReplacement(atKey) !== undefined ||
974
+ hasSharedField(unionsMemberFields(atKey))
975
+ );
976
+ }
977
+ if (atKey.every((field) => !isNodeField(field))) {
978
+ const updates = atKey.map(updateSchema);
979
+ return updates.every((u) => u !== undefined) && haveValueInCommon(updates);
980
+ }
981
+ return false;
982
+ });
983
+
984
+ /** Builds the patch of a Record at the keys only the data names. */
985
+ const buildRest = (
986
+ rest: Rest<unknown>,
987
+ path: Path,
988
+ paths: Paths,
989
+ mode: FirestoreModel.UpdateMode,
990
+ ): { readonly patch: Schema.Top; readonly shape: Shape } => {
991
+ const entryPath = [...path, rest.key];
992
+ const node = nodeOf(rest.value, true);
993
+ let entry: Schema.Top;
994
+ let shape: Shape | undefined;
995
+ if (node !== undefined) {
996
+ const built = buildNode({ ...node, optional: true }, entryPath, paths, mode);
997
+ entry = built.schema;
998
+ shape = built.shape;
999
+ } else {
1000
+ entry = firestoreSchema.DeleteFieldOr(
1001
+ withoutConstructorDefault(withoutOptionalKey(updateSchema(rest.value)!)),
1002
+ );
1003
+ addPath(paths, entryPath, entry);
1004
+ }
1005
+ return {
1006
+ patch: Schema.Record(Schema.toType(rest.key) as Schema.Record.Key, entry),
1007
+ shape: { fields: {}, rest: { value: shape } },
1008
+ };
1009
+ };
1010
+
1011
+ /**
1012
+ * Goes one level down where the shape says a struct or a Record sits, unless it
1013
+ * is being removed or replaced. It runs on the patch as the caller wrote it,
1014
+ * before anything is encoded, so the removal and the replacement are still
1015
+ * this library's to recognise.
1016
+ *
1017
+ * A replacement is written whole, as the struct it holds. A key of a Record
1018
+ * fails where no path could name it; the paths it builds are encoded by the
1019
+ * schema of each, keys and all.
1020
+ */
1021
+ const flatten = (
1022
+ value: Record<string, unknown>,
1023
+ shape: Shape,
1024
+ prefix: string,
1025
+ out: Record<string, unknown>,
1026
+ options: SchemaAST.ParseOptions,
1027
+ ): Effect.Effect<Record<string, unknown>, SchemaIssue.Issue> =>
1028
+ Effect.gen(function* () {
1029
+ for (const key of Object.keys(value)) {
1030
+ const inner = value[key];
1031
+ let child: Shape | undefined;
1032
+ if (shape.rest !== undefined) {
1033
+ const reason = unaddressable(key);
1034
+ if (reason !== undefined) {
1035
+ return yield* Effect.fail(
1036
+ new SchemaIssue.InvalidValue(
1037
+ {
1038
+ message: `Invalid Firestore field name "${key}" in "${prefix.slice(0, -1)}": it ${reason}`,
1039
+ },
1040
+ key,
1041
+ options,
1042
+ ),
1043
+ );
1044
+ }
1045
+ child = shape.rest.value;
1046
+ } else if (Object.hasOwn(shape.fields, key)) {
1047
+ child = shape.fields[key];
1048
+ }
1049
+ if (child === undefined || fieldValue.isDeleteField(inner)) {
1050
+ out[prefix + key] = inner;
1051
+ } else if (patch.isReplace(inner)) {
1052
+ out[prefix + key] = inner.value;
1053
+ } else {
1054
+ yield* flatten(inner as Record<string, unknown>, child, `${prefix}${key}.`, out, options);
1055
+ }
1056
+ }
1057
+ return out;
1058
+ });
1059
+
1060
+ /**
1061
+ * The update of a document, out of the patch it takes, where the patch goes
1062
+ * one level down, and the paths it writes at.
1063
+ */
1064
+ const updateOf = (patch: Schema.Top, shape: Shape, paths: Paths): Schema.Top => {
1065
+ const known = Schema.Struct(paths.known);
1066
+ const patterns = [...paths.patterns.values()];
1067
+ return (patterns.length === 0 ? known : Schema.StructWithRest(known, patterns as any)).pipe(
1068
+ Schema.decodeTo(Schema.toType(patch), {
1069
+ decode: SchemaGetter.forbidden(() => "An update is only ever written, never read back"),
1070
+ encode: SchemaGetter.transformEffect((value, options) =>
1071
+ flatten(value as Record<string, unknown>, shape, "", {}, options),
1072
+ ),
1073
+ }),
1074
+ );
1075
+ };
1076
+
1077
+ const update = (
1078
+ fields: VariantSchema.Struct.Fields,
1079
+ mode: FirestoreModel.UpdateMode,
1080
+ ): Schema.Top => {
1081
+ const paths = emptyPaths();
1082
+ const { patch, shape } = build(fields, [], paths, mode);
1083
+ return updateOf(patch, shape, paths);
1084
+ };
1085
+
1086
+ /**
1087
+ * The update of a document that is a union, patched at its Shared Fields - and,
1088
+ * unchecked, at a key only some members hold - by the rule a union field is
1089
+ * patched by. It is never written whole: a document changes member through
1090
+ * `set`.
1091
+ */
1092
+ const unionUpdate = (members: Members, mode: FirestoreModel.UpdateMode): Schema.Top => {
1093
+ const paths = emptyPaths();
1094
+ const { patch, shape } = buildShared(members.map(fields), [], paths, mode);
1095
+ return updateOf(patch, shape, paths);
1096
+ };
1097
+
1098
+ /** @internal */
1099
+ export const Class =
1100
+ (identifier: string) =>
1101
+ (
1102
+ fields: VariantSchema.Struct.Fields,
1103
+ annotations?: Schema.Annotations.Declaration<any, any>,
1104
+ ): any => {
1105
+ const Base = Variants.Class<any>(identifier)(fields as any, annotations) as any;
1106
+ // `Variants.Class` defines each variant as a non-configurable property, so
1107
+ // the patch `update` is shadowed from a subclass rather than written over
1108
+ // it. A class built this way is extended by the caller anyway, which is
1109
+ // what `Self` is for, so this is one more link in a chain that already has
1110
+ // one.
1111
+ class Model extends Base {}
1112
+ Object.defineProperty(Model, "update", {
1113
+ value: update(fields, "checked").annotate({
1114
+ id: `${identifier}.update`,
1115
+ title: `${identifier}.update`,
1116
+ }),
1117
+ });
1118
+ Object.defineProperty(Model, "updateUnchecked", {
1119
+ value: update(fields, "unchecked").annotate({
1120
+ id: `${identifier}.updateUnchecked`,
1121
+ title: `${identifier}.updateUnchecked`,
1122
+ }),
1123
+ });
1124
+ return Model;
1125
+ };
1126
+
1127
+ /** @internal */
1128
+ export const Immutable = <S extends Schema.Top>(schema: S): FirestoreModel.Immutable<S> =>
1129
+ Field({ read: schema, create: schema });
1130
+
1131
+ /** @internal */
1132
+ export const Timestamp = <S extends FirestoreSchema.TimeSchema>(
1133
+ schema: S,
1134
+ ): FirestoreModel.Timestamp<S> =>
1135
+ Field({
1136
+ read: schema,
1137
+ create: firestoreSchema.ServerTimestampOr(schema),
1138
+ update: firestoreSchema.ServerTimestampOr(schema),
1139
+ });
1140
+
1141
+ /** @internal */
1142
+ export const Number = <S extends Schema.Codec<unknown, number, unknown, unknown>>(
1143
+ schema: S,
1144
+ ): FirestoreModel.Number<S> =>
1145
+ Field({
1146
+ read: schema,
1147
+ create: firestoreSchema.IncrementOr(schema),
1148
+ update: firestoreSchema.IncrementOr(schema),
1149
+ });
1150
+
1151
+ /** @internal */
1152
+ export const Array = <S extends Schema.Top>(item: S): FirestoreModel.Array<S> =>
1153
+ Field({
1154
+ read: Schema.Array(item),
1155
+ create: Schema.Array(item),
1156
+ update: firestoreSchema.ArrayOr(item),
1157
+ });
1158
+
1159
+ const optionalField = fieldEvolve({
1160
+ read: Schema.optionalKey,
1161
+ create: Schema.optionalKey,
1162
+ update: (schema: Schema.Top) => Schema.optionalKey(firestoreSchema.DeleteFieldOr(schema)),
1163
+ } as any) as (self: unknown) => unknown;
1164
+
1165
+ /**
1166
+ * What a struct or a Record takes as a field of its own, which is what
1167
+ * `extract` hands back: the patches an unchecked update takes.
1168
+ */
1169
+ const nodeUpdate = (node: Node): Schema.Top =>
1170
+ Schema.optionalKey(buildNode(node, [], emptyPaths(), "unchecked").schema);
1171
+
1172
+ /** @internal */
1173
+ export const Record = <K extends Schema.Record.Key, V extends FirestoreModel.Value>(
1174
+ key: K,
1175
+ value: V,
1176
+ ): FirestoreModel.Record<K, V> => {
1177
+ const { rest } = recordKeys(key);
1178
+ const of = (variant: "read" | "create"): Schema.Top | undefined => {
1179
+ const schema = variantSchema(value, variant);
1180
+ // A key only the data names may be missing anyway, and Effect takes no
1181
+ // optional key at one.
1182
+ return schema && Schema.Record(key, rest ? withoutOptionalKey(schema) : schema);
1183
+ };
1184
+ const read = of("read");
1185
+ const create = of("create");
1186
+ const node = recordNode(key, value, () => create!, false);
1187
+ return Object.assign(
1188
+ Field({
1189
+ ...(read && { read }),
1190
+ ...(create && { create }),
1191
+ ...(node.writable && { update: nodeUpdate(node) }),
1192
+ }),
1193
+ { [RecordTypeId]: { key, value } },
1194
+ ) as any;
1195
+ };
1196
+
1197
+ /** @internal */
1198
+ export const Union = (members: Members): any => {
1199
+ // Each member as the default variant is read, which keeps a class a class.
1200
+ // Effect builds the same for its own union, though only it can extract
1201
+ // that, and its union defines the variants for good, so it is rebuilt here.
1202
+ const read = Schema.Union((Variants.Union(members) as Schema.Union<any>).members);
1203
+ const node = unionNode(members, false);
1204
+ const schemas = {
1205
+ read,
1206
+ create: node.create(),
1207
+ ...(node.writable && { update: nodeUpdate(node) }),
1208
+ };
1209
+ return Object.defineProperties(read, {
1210
+ [FieldTypeId]: { value: FieldTypeId },
1211
+ schemas: { value: schemas },
1212
+ read: { value: Schema.Union(members.map((member) => extract(member, "read") as Schema.Top)) },
1213
+ create: { value: schemas.create },
1214
+ // What a document that is the union is updated through.
1215
+ update: { value: unionUpdate(members, "checked") },
1216
+ updateUnchecked: { value: unionUpdate(members, "unchecked") },
1217
+ [UnionTypeId]: { value: members },
1218
+ });
1219
+ };
1220
+
1221
+ const optionalNode = (self: unknown, node: Node): unknown =>
1222
+ Field({
1223
+ read: Schema.optionalKey(variantSchema(self, "read")!),
1224
+ create: Schema.optionalKey(node.create()),
1225
+ ...(node.writable && { update: nodeUpdate(node) }),
1226
+ });
1227
+
1228
+ /** @internal */
1229
+ export const Optional = (self: FirestoreModel.Value): any => {
1230
+ // Made optional already, which a second time leaves as it is.
1231
+ if (isOptional(self)) {
1232
+ return self;
1233
+ }
1234
+ const node = nodeOf(self, true);
1235
+ return Object.assign(
1236
+ (node === undefined ? optionalField(self) : optionalNode(self, node)) as object,
1237
+ {
1238
+ "~optionality": "optional",
1239
+ field: self,
1240
+ },
1241
+ );
1242
+ };