firesmelt 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mwpryer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,437 @@
1
+ <p align="center">
2
+ <img src="assets/firesmelt.png" alt="firesmelt" width="192">
3
+ </p>
4
+
5
+ <h1 align="center">firesmelt</h1>
6
+
7
+ <p align="center">Both Firestore SDKs behind one thin, schema-typed TypeScript interface.</p>
8
+
9
+ <p align="center">
10
+ <a href="https://www.npmjs.com/package/firesmelt"><img src="https://img.shields.io/npm/v/firesmelt" alt="npm version"></a>
11
+ <a href="https://github.com/mwpryer/firesmelt/stargazers"><img src="https://img.shields.io/github/stars/mwpryer/firesmelt" alt="GitHub stars"></a>
12
+ </p>
13
+
14
+ > [!IMPORTANT]
15
+ > This project is under active development. Expect breaking changes before v1.0.
16
+
17
+ firesmelt is a thin wrapper over Firestore's SDKs, giving both one schema-typed interface: `firebase-admin` on the server and `firebase` on the web. Define a collection's schema once, in a validator you already use. Reads come back coerced and typed, and queries are typed to its fields. Underneath it's still plain Firestore, so you can reach for the SDK whenever you like.
18
+
19
+ ## Why
20
+
21
+ Every Firestore read hands you `DocumentData`. You cast it to your model, the compiler takes your word for it, and the `Date` you wrote still comes back as a `Timestamp`, because a cast cannot change a runtime value. So each model grows a converter full of `.toDate()` calls. Writes are no safer: `setDoc` accepts whatever shape you hand it. Queries take field names as bare strings, and a misspelt one quietly returns no documents. And if you run Firestore on the server and in the browser, you write all of this twice, because the admin and web SDKs are different libraries with different types.
22
+
23
+ ```ts
24
+ // raw SDK
25
+ const snap = await getDoc(doc(db, "posts", "hello-world"));
26
+ const data = snap.data(); // DocumentData | undefined
27
+ const post = data && {
28
+ id: snap.id,
29
+ title: data.title as string,
30
+ likes: data.likes as number,
31
+ createdAt: (data.createdAt as Timestamp).toDate(),
32
+ };
33
+ await setDoc(doc(db, "posts", "hello-world"), {
34
+ title: "Hello world",
35
+ likes: "10", // wrong type, stored anyway
36
+ });
37
+
38
+ // firesmelt
39
+ const post = await db.collection(posts).get("hello-world");
40
+ // { id: string; title: string; likes: number; createdAt: Date } | null
41
+ await db.collection(posts).set("hello-world", {
42
+ title: "Hello world",
43
+ likes: 10,
44
+ createdAt: new Date(), // becomes a Timestamp on write
45
+ });
46
+ ```
47
+
48
+ firesmelt moves all of this onto the collection definition, built from a schema you already have.
49
+
50
+ The schema is a [Standard Schema](https://standardschema.dev) validator, so Zod, Valibot, ArkType or any other compliant library supplies the types. firesmelt depends only on the spec's type definitions and never runs your schema; there is no runtime validation unless you want some, and then you run the same schema yourself.
51
+
52
+ Timestamps round-trip. Write a `Date`, read a `Date`, however deeply it sits in maps and arrays. And one definition covers both SDKs: the same typed surface works with `firebase-admin` on the server and `firebase` on the web, so the model code you used to duplicate lives in one module, and that module imports no Firebase.
53
+
54
+ The whole surface is typed against the schema: reads and writes, the query builder and its aggregations, listeners, transactions, subcollections and collection-group queries. A misspelt field is a compile error rather than an empty result.
55
+
56
+ Underneath, it is still plain Firestore. firesmelt is a thin wrapper, not an ORM, and every handle exposes `.ref`, so you can always drop to the raw SDK.
57
+
58
+ ## Install
59
+
60
+ ```sh
61
+ npm install firesmelt
62
+ # plus whichever SDK you use
63
+ npm install firebase-admin # server
64
+ npm install firebase # web
65
+ ```
66
+
67
+ ## Quick start
68
+
69
+ Define a collection, connect a database, then read and write typed documents.
70
+
71
+ ```ts
72
+ import { collection } from "firesmelt";
73
+ import { z } from "zod";
74
+
75
+ const posts = collection(
76
+ "posts",
77
+ z.object({
78
+ title: z.string(),
79
+ likes: z.number(),
80
+ createdAt: z.date(),
81
+ }),
82
+ );
83
+
84
+ import { createDatabase } from "firesmelt/admin";
85
+ import { getFirestore } from "firebase-admin/firestore";
86
+
87
+ const db = createDatabase(getFirestore());
88
+
89
+ await db.collection(posts).set("hello-world", {
90
+ title: "Hello world",
91
+ likes: 0,
92
+ createdAt: new Date(),
93
+ });
94
+
95
+ // { id, title, likes, createdAt } | null
96
+ const post = await db.collection(posts).get("hello-world");
97
+ ```
98
+
99
+ ## Define a schema
100
+
101
+ A collection definition is a plain value (`name` + `schema`) with no database binding, so it's reusable at any path, including subcollections. The schema module imports only `firesmelt`, so Firebase never reaches a frontend bundle.
102
+
103
+ The schema should describe a document Firestore can store: an object of fields, with no directly nested arrays (an array of arrays). firesmelt does not police this, so a non-storable shape surfaces as an SDK error at write time rather than a firesmelt one.
104
+
105
+ ```ts
106
+ import { collection } from "firesmelt";
107
+ import { z } from "zod";
108
+
109
+ export const posts = collection(
110
+ "posts",
111
+ z.object({
112
+ title: z.string(),
113
+ likes: z.number(),
114
+ // round-trips a Firestore Timestamp at the boundary
115
+ createdAt: z.date(),
116
+ }),
117
+ );
118
+ ```
119
+
120
+ ## Connect
121
+
122
+ Pass a Firestore instance to `createDatabase`. The server entrypoint uses `firebase-admin`.
123
+
124
+ ```ts
125
+ // server.ts
126
+ import { createDatabase } from "firesmelt/admin";
127
+ import { getFirestore } from "firebase-admin/firestore";
128
+
129
+ const db = createDatabase(getFirestore());
130
+ ```
131
+
132
+ The web entrypoint is the same, but uses the modular `firebase` SDK.
133
+
134
+ ```ts
135
+ import { createDatabase } from "firesmelt/web";
136
+ import { getFirestore } from "firebase/firestore";
137
+
138
+ const db = createDatabase(getFirestore(app));
139
+ ```
140
+
141
+ `db.collection(def)` returns a typed collection handle. `.doc(id)` narrows it to a single document.
142
+
143
+ ## Read
144
+
145
+ ```ts
146
+ // Doc | null, where Doc = T & { id }
147
+ const post = await db.collection(posts).get("hello-world");
148
+ // the same, from a document handle
149
+ const same = await db.collection(posts).doc("hello-world").get();
150
+ const there = await db.collection(posts).doc("hello-world").exists();
151
+ const total = await db.collection(posts).count();
152
+ ```
153
+
154
+ ## Write
155
+
156
+ ```ts
157
+ // full write, coerced
158
+ await db.collection(posts).set("hello-world", {
159
+ title: "Hello world",
160
+ likes: 0,
161
+ createdAt: new Date(),
162
+ });
163
+
164
+ // merge / partial update, coerced and typed
165
+ await db.collection(posts).set("hello-world", { likes: 10 }, { merge: true });
166
+ await db.collection(posts).update("hello-world", { likes: increment(1) });
167
+
168
+ // merge only the listed fields
169
+ await db
170
+ .collection(posts)
171
+ .set(
172
+ "hello-world",
173
+ { likes: 10, title: "ignored" },
174
+ { mergeFields: ["likes"] },
175
+ );
176
+
177
+ // auto-id insert returns the new id
178
+ const id = await db
179
+ .collection(posts)
180
+ .add({ title: "Second post", likes: 0, createdAt: new Date() });
181
+ await db.collection(posts).delete("hello-world");
182
+ ```
183
+
184
+ Every write also works on a document handle, like `db.collection(posts).doc("hello-world").set(...) / .update(...) / .delete()`.
185
+
186
+ `increment`, `serverTimestamp`, `arrayUnion`, `arrayRemove` and `deleteField` import from `firesmelt`, see [Sentinels](#sentinels).
187
+
188
+ ## Query
189
+
190
+ The builder is immutable and chainable. `.get()` (alias `.list()`) returns `Doc[]`.
191
+
192
+ ```ts
193
+ const popular = await db
194
+ .collection(posts)
195
+ .where("likes", ">=", 10)
196
+ .orderBy("likes")
197
+ .limit(10)
198
+ .get();
199
+
200
+ // cursors for pagination, startAt / startAfter / endAt / endBefore
201
+ const nextPage = await db
202
+ .collection(posts)
203
+ .orderBy("title")
204
+ .startAfter("Hello world")
205
+ .limit(10)
206
+ .get();
207
+
208
+ const count = await db.collection(posts).where("likes", ">=", 10).count();
209
+ const totalLikes = await db.collection(posts).sum("likes");
210
+ // number | null, null over an empty match
211
+ const meanLikes = await db.collection(posts).average("likes");
212
+ ```
213
+
214
+ `where` is typed against the schema. The field must exist and the value must match its type (`in` / `not-in` take an array, `array-contains` takes an element, and so on). A mismatch is a compile error.
215
+
216
+ Dotted paths reach into nested maps, typed end to end. `where("customer.address.city", "==", "London")` requires that path to exist and its value to be a string. Paths stop at arrays and timestamps (those are queried as whole values), so `where("tags", "array-contains", "vip")` is valid but `where("tags.0", ...)` is not.
217
+
218
+ The document id is not a schema field, so target it with `documentId()` (imported from `firesmelt`). Use it to filter by id, or as an ordering tiebreak. Id values are plain strings, not the schema's field types.
219
+
220
+ ```ts
221
+ import { documentId } from "firesmelt";
222
+
223
+ const some = await db
224
+ .collection(posts)
225
+ .where(documentId(), "in", ["hello-world", "second-post"])
226
+ .get();
227
+
228
+ const ordered = await db
229
+ .collection(posts)
230
+ .orderBy("likes")
231
+ .orderBy(documentId())
232
+ .get();
233
+ ```
234
+
235
+ ## Live updates
236
+
237
+ `onSnapshot` returns an unsubscribe function. Pass a callback, or an observer object `{ next, error }`.
238
+
239
+ ```ts
240
+ const unsub = db
241
+ .collection(posts)
242
+ .where("likes", ">=", 10)
243
+ .onSnapshot((docs) => {
244
+ // docs: Doc[]
245
+ });
246
+
247
+ db.collection(posts)
248
+ .doc("hello-world")
249
+ .onSnapshot({
250
+ next: (doc) => {
251
+ // doc: Doc | null
252
+ },
253
+ error: (err) => {
254
+ // network or permission error
255
+ },
256
+ });
257
+ ```
258
+
259
+ On the web SDK, a pending `serverTimestamp()` in a latency-compensated snapshot reads as an estimated `Date` rather than the SDK's default `null`, so the field keeps its schema type. Admin always reads committed server data, so the case never arises there.
260
+
261
+ ## Subcollections and collection groups
262
+
263
+ A collection definition is reusable, so the same `def` works at any depth. Open a subcollection from a document handle, and query across every collection of that name with `collectionGroup`.
264
+
265
+ ```ts
266
+ const comments = collection(
267
+ "comments",
268
+ z.object({ text: z.string(), createdAt: z.date() }),
269
+ );
270
+
271
+ await db.collection(posts).doc("hello-world").collection(comments).set("c1", {
272
+ text: "Great post",
273
+ createdAt: new Date(),
274
+ });
275
+
276
+ // every "comments" subcollection, regardless of parent
277
+ const recent = await db
278
+ .collectionGroup(comments)
279
+ .orderBy("createdAt", "desc")
280
+ .limit(20)
281
+ .get();
282
+ ```
283
+
284
+ ## Transactions
285
+
286
+ `runTransaction` hands you transaction-scoped handles. As with the SDK, reads must precede writes and the callback may retry, so keep it side-effect free.
287
+
288
+ ```ts
289
+ await db.runTransaction(async (tx) => {
290
+ const post = await tx.collection(posts).get("hello-world");
291
+ if (!post) return;
292
+ tx.collection(posts).update("hello-world", { likes: post.likes + 1 });
293
+ });
294
+ ```
295
+
296
+ Inside a transaction, reads are async (await them), but writes buffer synchronously and commit at the end, so they take no `await`.
297
+
298
+ Cap the retries with `runTransaction(fn, { maxAttempts })`; Firestore defaults to 5.
299
+
300
+ ## Batched writes
301
+
302
+ `db.batch()` returns a batch whose collection handles expose write-only `set` / `update` / `delete`. Each buffers synchronously and takes no `await`; nothing is applied until `commit()`, which writes the whole batch atomically.
303
+
304
+ ```ts
305
+ const batch = db.batch();
306
+ batch.collection(posts).update("hello-world", { likes: increment(1) });
307
+ batch.collection(posts).delete("old-draft");
308
+ await batch.commit();
309
+ ```
310
+
311
+ It is the right tool for blind atomic writes. A transaction would cover these too, but brings retry and contention machinery along for reads you never make, and in the web SDK a transaction cannot run offline while a batch can. The handle is write-only: there is no `get` (a batch never reads) and no `add` (nothing returns an id before commit). Writes are coerced and typed exactly as on a normal handle.
312
+
313
+ ## What's guaranteed
314
+
315
+ firesmelt types and coerces. It does not validate.
316
+
317
+ Reads are coerced and typed. A read coerces stored Firestore values to neutral types (every `Timestamp` becomes a `Date`), then merges the document id in flat as `(T & { id }) | null`. firesmelt never runs your schema, so a document that has drifted from it still comes back, typed as valid. Validate on read yourself where that matters.
318
+
319
+ Writes are coerced. `set`, `add`, `update` and merge `set` coerce `Date` to `Timestamp` and translate sentinels, then write. The typed surface constrains every field at compile time, but nothing is checked at runtime.
320
+
321
+ ### Sentinels
322
+
323
+ firesmelt provides its own sentinels (`serverTimestamp`, `increment`, `arrayUnion`, `arrayRemove`, `deleteField`) because a neutral schema can't reference the admin or web `FieldValue` class. Each driver translates them to its own SDK at write time.
324
+
325
+ In `update` and merge `set`, each sentinel is constrained to the field types it fits. `increment` works only on a number field, `arrayUnion` / `arrayRemove` only on a matching array, `serverTimestamp` only on a `Date` or `Timestamp` field, and `deleteField` only on an optional field. A mismatch is a compile error.
326
+
327
+ ```ts
328
+ // ok
329
+ await db.collection(posts).update("hello-world", { likes: increment(1) });
330
+ // @ts-expect-error increment is not valid on a string field
331
+ await db.collection(posts).update("hello-world", { title: increment(1) });
332
+ ```
333
+
334
+ ### Nested updates
335
+
336
+ `update` also takes dotted-path keys reaching into nested maps, typed end to end against the schema. The value is typed to that path and sentinels stay constrained, so `increment` works on a nested number path and `deleteField` only on an optional one. A whole-map write still replaces the map; a dotted path touches one field and leaves its siblings intact.
337
+
338
+ ```ts
339
+ // ok, siblings kept
340
+ await db.collection(orders).update("o1", { "totals.items": increment(1) });
341
+ // @ts-expect-error value type must match the path
342
+ await db.collection(orders).update("o1", { "totals.items": "many" });
343
+ ```
344
+
345
+ Dotted keys are a field-path notation in `update` only. In a merge `set`, Firestore writes them as a field named literally `"totals.items"`, so they stay off that surface; nest with an object instead (`set({ totals: { items } }, { merge: true })`).
346
+
347
+ ## Timestamps
348
+
349
+ Schemas speak `Date`. The boundary coerces between `Date` and `Timestamp` deeply (including nested maps and arrays) by runtime type. On read, every Firestore `Timestamp` becomes a `Date`. On write, a `Date` becomes a `Timestamp`.
350
+
351
+ > [!WARNING]
352
+ > `Date` is millisecond precision and Firestore `Timestamp` is nanosecond, so the round-trip truncates sub-millisecond precision.
353
+
354
+ If a field needs full nanosecond precision, keep it raw with the `raw` option (a list of dotted field paths). Those paths return the raw SDK value uncoerced on read, whatever the type, so your schema types them as the SDK type (`Timestamp` here) rather than a `Date`. The same option keeps any other field raw too, for example an SDK `Bytes` instead of a coerced `Uint8Array`.
355
+
356
+ ```ts
357
+ import { collection, isTimestampLike, type Timestamp } from "firesmelt";
358
+ import { z } from "zod";
359
+
360
+ const events = collection(
361
+ "events",
362
+ z.object({
363
+ name: z.string(),
364
+ // stays a Timestamp, full precision
365
+ at: z.custom<Timestamp>(isTimestampLike),
366
+ }),
367
+ { raw: ["at"] },
368
+ );
369
+ ```
370
+
371
+ ## Bytes
372
+
373
+ Bytes are the binary analogue of timestamps. Schemas speak `Uint8Array`, the JS-native binary type, with no SDK import. The boundary coerces it to the SDK bytes type on write (the web `Bytes` class, an admin `Buffer`) and back to a plain `Uint8Array` on read, deeply, the same as `Date` and `Timestamp`. Lossless, so no precision caveat.
374
+
375
+ ```ts
376
+ import { collection } from "firesmelt";
377
+ import { z } from "zod";
378
+
379
+ const files = collection("files", z.object({ blob: z.custom<Uint8Array>() }));
380
+
381
+ await db.collection(files).set("f", { blob: new Uint8Array([1, 2, 3]) });
382
+ const file = await db.collection(files).get("f");
383
+ // a plain Uint8Array on both SDKs
384
+ file?.blob;
385
+ ```
386
+
387
+ ## Neutral value types
388
+
389
+ A schema can name Firestore's other value types without importing either SDK. firesmelt ships structural `GeoPoint`, `DocumentReference` and `VectorValue` interfaces that mirror neutral `Timestamp`. They are types only: firesmelt does not coerce them. They round-trip uncoerced as the SDK class instance you read and write.
390
+
391
+ ```ts
392
+ import { collection, type GeoPoint } from "firesmelt";
393
+ import { z } from "zod";
394
+
395
+ const places = collection(
396
+ "places",
397
+ z.object({
398
+ name: z.string(),
399
+ // an SDK GeoPoint, passed through untouched
400
+ at: z.custom<GeoPoint>(),
401
+ }),
402
+ );
403
+ ```
404
+
405
+ ## Schema drift
406
+
407
+ firesmelt does not validate, so it does not catch drift. Stored data can diverge from the current schema: legacy docs, partial migrations, edits from other services or the console. A drifted document comes back coerced and typed as valid rather than throwing. Where that matters, run your schema on the result yourself, or drop to the SDK via `.ref`.
408
+
409
+ ## Escape hatch
410
+
411
+ Every handle exposes a `.ref` with the converter attached, so raw Firestore calls that firesmelt doesn't wrap still run through it where the SDK invokes it. The converter coerces both directions: a read coerces stored values to neutral types and merges the id in, a write coerces `Date` to `Timestamp` and translates sentinels.
412
+
413
+ `.ref` is typed `unknown` so the neutral core imports no SDK. Each entrypoint ships typed helpers, `docRef` / `collectionRef` / `queryRef`, that hand back the SDK ref typed to your schema, so you don't cast by hand.
414
+
415
+ ```ts
416
+ // or "firesmelt/web"
417
+ import { docRef } from "firesmelt/admin";
418
+
419
+ // DocumentReference<Doc<typeof posts.schema>>
420
+ const ref = docRef(db.collection(posts).doc("hello-world"));
421
+
422
+ // raw SDK call firesmelt does not wrap, data() is still coerced
423
+ const unsub = ref.onSnapshot((snap) => {
424
+ // post: Doc<typeof posts.schema> | undefined
425
+ const post = snap.data();
426
+ });
427
+ ```
428
+
429
+ If you need to drop fully to the raw SDK type yourself, `.ref` is still there to cast at the boundary.
430
+
431
+ > [!WARNING]
432
+ > Firestore runs no converter for `updateDoc` / `ref.update(...)`, so a raw `update` through `.ref` skips coercion entirely (you pass native values). The typed `.update(...)` and merge `set` still coerce.
433
+
434
+ ## Errors
435
+
436
+ - `FiresmeltError` is the base class for the few errors firesmelt raises itself (such as an unknown sentinel). firesmelt polices neither ids nor write/query shapes, so those surface as the SDK's own error.
437
+ - Firestore, network, and permission errors propagate untouched.
@@ -0,0 +1,11 @@
1
+ import { StandardSchemaV1 } from '@standard-schema/spec';
2
+ import { CollectionReference, Firestore, DocumentReference, Query } from 'firebase-admin/firestore';
3
+ import { CollectionHandle, Doc, Database, DocumentHandle, QueryBuilder } from './index.js';
4
+ export { ArrayElement, ArrayRemoveSentinel, ArrayUnionSentinel, Batch, BatchCollectionHandle, BatchDocumentHandle, CollectionDef, CollectionOptions, DeleteFieldSentinel, DocSnapshotObserver, DocumentIdRef, DocumentReference, FieldPath, FiresmeltError, GeoPoint, IncrementSentinel, InferOutput, MergeOptions, NumericField, NumericFieldPath, OrderByDirection, QuerySnapshotObserver, SentinelFor, ServerTimestampSentinel, Timestamp, Transaction, TransactionOptions, TxCollectionHandle, TxDocumentHandle, Unsubscribe, UpdateData, UpdatePaths, UpdateValue, ValueAtPath, VectorValue, WhereField, WhereFilterOp, WhereValue, WithFieldValue, arrayRemove, arrayUnion, collection, deleteField, documentId, increment, isTimestampLike, serverTimestamp } from './index.js';
5
+
6
+ declare function createDatabase(firestore: Firestore): Database;
7
+ declare function docRef<S extends StandardSchemaV1>(handle: DocumentHandle<S>): DocumentReference<Doc<S>>;
8
+ declare function collectionRef<S extends StandardSchemaV1>(handle: CollectionHandle<S>): CollectionReference<Doc<S>>;
9
+ declare function queryRef<S extends StandardSchemaV1>(builder: QueryBuilder<S>): Query<Doc<S>>;
10
+
11
+ export { CollectionHandle, Database, Doc, DocumentHandle, QueryBuilder, collectionRef, createDatabase, docRef, queryRef };