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..6196e1c7 100644 --- a/packages/effect-firebase/README.md +++ b/packages/effect-firebase/README.md @@ -34,16 +34,17 @@ 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.GeoPoint` | Geographic point with latitude and longitude. | +| `Model.Field({get, add, update, json})` | Fully custom per-variant schemas. | ## Repository 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..b52b515e --- /dev/null +++ b/packages/effect-firebase/src/lib/firestore/model/geopoint.ts @@ -0,0 +1,42 @@ +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' }, + }, + }, +});