From e989389d57be01c8bd2cc1543c09fad7a21ea64c Mon Sep 17 00:00:00 2001 From: Frederik Wallner Date: Sun, 19 Jul 2026 11:06:35 +0200 Subject: [PATCH 1/4] feat: add json annotations and make portable --- .../src/lib/firestore/firestore.ts | 1 + .../src/lib/firestore/model/geopoint.spec.ts | 106 ++++++++++++++++++ .../src/lib/firestore/model/geopoint.ts | 43 +++++++ .../src/lib/firestore/model/index.ts | 1 + .../src/lib/firestore/schema/geopoint.ts | 15 +++ 5 files changed, 166 insertions(+) create mode 100644 packages/effect-firebase/src/lib/firestore/model/geopoint.spec.ts create mode 100644 packages/effect-firebase/src/lib/firestore/model/geopoint.ts diff --git a/packages/effect-firebase/src/lib/firestore/firestore.ts b/packages/effect-firebase/src/lib/firestore/firestore.ts index 74a145f4..9cd2cb4c 100644 --- a/packages/effect-firebase/src/lib/firestore/firestore.ts +++ b/packages/effect-firebase/src/lib/firestore/firestore.ts @@ -4,6 +4,7 @@ export * from './fields/array.js'; // Firestore-specific model field type helpers export * from './model/datetime.js'; +export * from './model/geopoint.js'; export * from './model/reference.js'; export * from './model/optional.js'; export * from './model/array.js'; diff --git a/packages/effect-firebase/src/lib/firestore/model/geopoint.spec.ts b/packages/effect-firebase/src/lib/firestore/model/geopoint.spec.ts new file mode 100644 index 00000000..9e2a2e99 --- /dev/null +++ b/packages/effect-firebase/src/lib/firestore/model/geopoint.spec.ts @@ -0,0 +1,106 @@ +import { Schema } from 'effect'; +import { describe, expect, it } from 'vitest'; +import { Model } from 'effect/unstable/schema'; +import { GeoPoint } from './geopoint.js'; +import { GeoPoint as GeoPointClass } from '../schema/geopoint.js'; + +describe('Model.GeoPoint', () => { + const PlaceId = Schema.String.pipe(Schema.brand('PlaceId')); + + class TestModel extends Model.Class('TestModel')({ + id: Model.GeneratedByDb(PlaceId), + location: GeoPoint, + }) {} + + describe('select variant', () => { + it('should decode a GeoPoint instance to a GeoPoint instance', () => { + const decode = Schema.decodeUnknownSync(TestModel); + const result = decode({ + id: 'place-1', + location: new GeoPointClass({ + latitude: 37.7749, + longitude: -122.4194, + }), + }); + + expect(result.location).toBeInstanceOf(GeoPointClass); + expect(result.location.latitude).toBe(37.7749); + expect(result.location.longitude).toBe(-122.4194); + }); + + it('should encode a GeoPoint instance to a GeoPoint instance', () => { + const encode = Schema.encodeSync(TestModel); + const result = encode( + new TestModel({ + id: PlaceId.make('place-1'), + location: new GeoPointClass({ + latitude: 37.7749, + longitude: -122.4194, + }), + }) + ); + + expect(result.location).toBeInstanceOf(GeoPointClass); + expect(result.location.latitude).toBe(37.7749); + expect(result.location.longitude).toBe(-122.4194); + }); + }); + + describe('insert variant', () => { + it('should encode a GeoPoint instance to a GeoPoint instance', () => { + const encode = Schema.encodeSync(TestModel.insert); + const result = encode({ + location: new GeoPointClass({ latitude: 51.5074, longitude: -0.1278 }), + }); + + expect(result.location).toBeInstanceOf(GeoPointClass); + }); + }); + + describe('json variant', () => { + it('should decode a plain object to a GeoPoint instance', () => { + const decode = Schema.decodeUnknownSync(TestModel.json); + const result = decode({ + id: 'place-1', + location: { latitude: 35.6762, longitude: 139.6503 }, + }); + + expect(result.location).toBeInstanceOf(GeoPointClass); + expect(result.location.latitude).toBe(35.6762); + expect(result.location.longitude).toBe(139.6503); + }); + + it('should encode a GeoPoint instance to a plain object', () => { + const encode = Schema.encodeSync(TestModel.json); + const result = encode({ + id: PlaceId.make('place-1'), + location: new GeoPointClass({ latitude: 35.6762, longitude: 139.6503 }), + }); + + expect(result.location).toEqual({ + latitude: 35.6762, + longitude: 139.6503, + }); + expect(result.location).not.toBeInstanceOf(GeoPointClass); + }); + + it('should survive a JSON.stringify/parse roundtrip', () => { + const encode = Schema.encodeSync(TestModel.json); + const decode = Schema.decodeUnknownSync(TestModel.json); + + const original = new TestModel({ + id: PlaceId.make('place-1'), + location: new GeoPointClass({ + latitude: -33.8688, + longitude: 151.2093, + }), + }); + + const roundtripped = decode(JSON.parse(JSON.stringify(encode(original)))); + + expect(roundtripped.location).toBeInstanceOf(GeoPointClass); + expect(roundtripped.location.latitude).toBe(-33.8688); + expect(roundtripped.location.longitude).toBe(151.2093); + }); + }); +}); diff --git a/packages/effect-firebase/src/lib/firestore/model/geopoint.ts b/packages/effect-firebase/src/lib/firestore/model/geopoint.ts new file mode 100644 index 00000000..49ff2735 --- /dev/null +++ b/packages/effect-firebase/src/lib/firestore/model/geopoint.ts @@ -0,0 +1,43 @@ +import { Schema } from 'effect'; +import { Model, VariantSchema } from 'effect/unstable/schema'; +import * as FirestoreSchema from '../schema/schema.js'; + +/** + * A field that stores a GeoPoint in Firestore and survives JSON serialization. + * + * - App (Type): `GeoPoint` class instance + * - DB (Encoded): `GeoPoint` class instance (preserved through the driver) + * - JSON (Encoded): plain `{ latitude, longitude }` object + * + * The DB variants keep the class instance intact so the Firestore driver can + * map it to a native GeoPoint, while the JSON variants encode to a plain object + * so the value can round-trip through `JSON.stringify`/`JSON.parse` like the + * other model field types. + * + * @example + * ```ts + * import { Model } from 'effect-firebase'; + * + * class PlaceModel extends Model.Class('PlaceModel')({ + * id: Model.GeneratedByDb(PlaceId), + * location: Model.GeoPoint, + * }) {} + * ``` + */ +export type GeoPoint = VariantSchema.Field<{ + select: typeof FirestoreSchema.GeoPointInstance; + insert: typeof FirestoreSchema.GeoPointInstance; + update: typeof FirestoreSchema.GeoPointInstance; + json: typeof FirestoreSchema.GeoPoint; + jsonCreate: typeof FirestoreSchema.GeoPoint; + jsonUpdate: typeof FirestoreSchema.GeoPoint; +}>; + +export const GeoPoint: GeoPoint = Model.Field({ + select: FirestoreSchema.GeoPointInstance, + insert: FirestoreSchema.GeoPointInstance, + update: FirestoreSchema.GeoPointInstance, + json: FirestoreSchema.GeoPoint, + jsonCreate: FirestoreSchema.GeoPoint, + jsonUpdate: FirestoreSchema.GeoPoint, +}); diff --git a/packages/effect-firebase/src/lib/firestore/model/index.ts b/packages/effect-firebase/src/lib/firestore/model/index.ts index ee1d149d..c494a5de 100644 --- a/packages/effect-firebase/src/lib/firestore/model/index.ts +++ b/packages/effect-firebase/src/lib/firestore/model/index.ts @@ -1,5 +1,6 @@ export * from './array.js'; export * from './datetime.js'; +export * from './geopoint.js'; export * from './optional.js'; export * from './reference.js'; export * from './repository.js'; diff --git a/packages/effect-firebase/src/lib/firestore/schema/geopoint.ts b/packages/effect-firebase/src/lib/firestore/schema/geopoint.ts index d0618a43..b69f8470 100644 --- a/packages/effect-firebase/src/lib/firestore/schema/geopoint.ts +++ b/packages/effect-firebase/src/lib/firestore/schema/geopoint.ts @@ -7,3 +7,18 @@ export class GeoPoint extends Schema.Class('GeoPoint')({ latitude: Schema.Number, longitude: Schema.Number, }) {} + +/** + * Schema where GeoPoint class instance is both Type and Encoded. + * Using instanceOf ensures the class instance is preserved through Schema.encode. + */ +export const GeoPointInstance = Schema.instanceOf(GeoPoint, { + jsonSchema: { + type: 'object', + required: ['latitude', 'longitude'], + properties: { + latitude: { type: 'number' }, + longitude: { type: 'number' }, + }, + }, +}); From ea3615f3a02787ee21e02cc4c0e2d1abeabb15b7 Mon Sep 17 00:00:00 2001 From: Frederik Wallner Date: Sun, 19 Jul 2026 11:06:47 +0200 Subject: [PATCH 2/4] chore: formatting --- example/app/package.json | 2 +- example/backend/package.json | 2 +- example/shared/package.json | 2 +- package.json | 2 +- packages/effect-firebase/README.md | 20 ++++++++++---------- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/example/app/package.json b/example/app/package.json index b9b3d58b..ff5f4427 100644 --- a/example/app/package.json +++ b/example/app/package.json @@ -35,4 +35,4 @@ "@effect-firebase/client": "workspace:*", "@example/shared": "workspace:*" } -} \ No newline at end of file +} diff --git a/example/backend/package.json b/example/backend/package.json index 7e679d29..d2d97bcf 100644 --- a/example/backend/package.json +++ b/example/backend/package.json @@ -19,4 +19,4 @@ "@example/shared": "workspace:*" }, "devDependencies": {} -} \ No newline at end of file +} diff --git a/example/shared/package.json b/example/shared/package.json index e3f94a60..460cf445 100644 --- a/example/shared/package.json +++ b/example/shared/package.json @@ -29,4 +29,4 @@ "devDependencies": { "effect-firebase": "workspace:*" } -} \ No newline at end of file +} diff --git a/package.json b/package.json index 21730d46..76ba5c0d 100644 --- a/package.json +++ b/package.json @@ -86,4 +86,4 @@ "packages/*", "example/*" ] -} \ No newline at end of file +} diff --git a/packages/effect-firebase/README.md b/packages/effect-firebase/README.md index dcca472b..06d5edf2 100644 --- a/packages/effect-firebase/README.md +++ b/packages/effect-firebase/README.md @@ -34,16 +34,16 @@ class PostModel extends Model.Class('PostModel')({ Built-in field helpers: -| Helper | Behaviour | -| ------------------------------------------- | --------------------------------------------------------------------- | -| `Model.GeneratedByDb(schema)` | Auto-generated (e.g. IDs). Excluded from `add` and `update`. | -| `Model.DateTimeInsert` | Server timestamp on create. Excluded from `update`. | -| `Model.DateTimeUpdate` | Server timestamp on every write. | -| `Model.Reference(id, collection)` | Branded ID in app, `DocumentReference` in Firestore. | -| `Model.ReferenceAsInstance(id, collection)` | Same, but exposes `DocumentReference` in the app layer. | -| `Model.OptionalDeletable(schema)` | Optional field that can be deleted with a sentinel value. | -| `Model.Array(schema)` | Array field. | -| `Model.Field({get, add, update, json})` | Fully custom per-variant schemas. | +| Helper | Behaviour | +| ------------------------------------------- | ------------------------------------------------------------ | +| `Model.GeneratedByDb(schema)` | Auto-generated (e.g. IDs). Excluded from `add` and `update`. | +| `Model.DateTimeInsert` | Server timestamp on create. Excluded from `update`. | +| `Model.DateTimeUpdate` | Server timestamp on every write. | +| `Model.Reference(id, collection)` | Branded ID in app, `DocumentReference` in Firestore. | +| `Model.ReferenceAsInstance(id, collection)` | Same, but exposes `DocumentReference` in the app layer. | +| `Model.OptionalDeletable(schema)` | Optional field that can be deleted with a sentinel value. | +| `Model.Array(schema)` | Array field. | +| `Model.Field({get, add, update, json})` | Fully custom per-variant schemas. | ## Repository From cc700f4e1985e0b41c1ca34a53ccc3f5fb0db550 Mon Sep 17 00:00:00 2001 From: Frederik Wallner Date: Sun, 19 Jul 2026 11:19:27 +0200 Subject: [PATCH 3/4] chore: remove unused import --- packages/effect-firebase/src/lib/firestore/model/geopoint.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/effect-firebase/src/lib/firestore/model/geopoint.ts b/packages/effect-firebase/src/lib/firestore/model/geopoint.ts index 49ff2735..b52b515e 100644 --- a/packages/effect-firebase/src/lib/firestore/model/geopoint.ts +++ b/packages/effect-firebase/src/lib/firestore/model/geopoint.ts @@ -1,4 +1,3 @@ -import { Schema } from 'effect'; import { Model, VariantSchema } from 'effect/unstable/schema'; import * as FirestoreSchema from '../schema/schema.js'; From 0af27fa138ac24d5065de1694415544791de3173 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:09:40 +0000 Subject: [PATCH 4/4] fix: apply CodeRabbit auto-fixes Fixed 1 file(s) based on 1 unresolved review comment. Co-authored-by: CodeRabbit --- packages/effect-firebase/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/effect-firebase/README.md b/packages/effect-firebase/README.md index 06d5edf2..6196e1c7 100644 --- a/packages/effect-firebase/README.md +++ b/packages/effect-firebase/README.md @@ -43,6 +43,7 @@ Built-in field helpers: | `Model.ReferenceAsInstance(id, collection)` | Same, but exposes `DocumentReference` in the app layer. | | `Model.OptionalDeletable(schema)` | Optional field that can be deleted with a sentinel value. | | `Model.Array(schema)` | Array field. | +| `Model.GeoPoint` | Geographic point with latitude and longitude. | | `Model.Field({get, add, update, json})` | Fully custom per-variant schemas. | ## Repository