Skip to content

Commit 96a90bb

Browse files
authored
feat: implement document-reference type (#227)
* Implement * Integration tests * Allow specifying model * Add changeset * Gitignore * Renderer tests
1 parent f8c390f commit 96a90bb

83 files changed

Lines changed: 2637 additions & 107 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"typesync-cli": minor
3+
---
4+
5+
Add Firestore `document-reference` schema support, including optional target-model narrowing in generated TypeScript and Zod types, schema validation for referenced models, and integration coverage across TypeScript, Zod, Python, and Swift.

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,6 @@ tests/integration/swift/Package.resolved
5050
ui-debug.log
5151
database-debug.log
5252
pubsub-debug.log
53-
### INTEGRATION TESTS END
53+
### INTEGRATION TESTS END
54+
55+
.cursor/

docs/schema/types.mdx

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,114 @@ function isValidExample(data) {
296296

297297
</CodeGroup>
298298

299+
## `document-reference`
300+
301+
Represents a Firestore [document reference](https://firebase.google.com/docs/reference/js/firestore_.documentreference) — a pointer to another Firestore document. Use this in place of storing a document ID as a `string` when you want the field to round-trip as a native reference under each Firestore SDK.
302+
303+
<Info>
304+
Firestore only supports storing **document** references in document fields, not collection references — the stored value's path must end on a document id. The type name mirrors the SDK class `DocumentReference` to make this constraint obvious at the schema-definition layer.
305+
</Info>
306+
307+
<Info>
308+
Without an explicit `model`, the `document-reference` type intentionally does not encode the target document's shape. If you want the generated TypeScript and Zod types to be narrowed to a specific target model, use the parameterized form below.
309+
</Info>
310+
311+
<CodeGroup>
312+
313+
```yaml definition.yml
314+
Example:
315+
model: alias
316+
type: document-reference
317+
```
318+
319+
```ts models.ts
320+
export type Example = firestore.DocumentReference<firestore.DocumentData>;
321+
```
322+
323+
```swift models.swift
324+
typealias Example = DocumentReference
325+
```
326+
327+
```python models.py
328+
Example = firestore.DocumentReference
329+
```
330+
331+
```javascript firestore.rules
332+
function isValidExample(data) {
333+
return (data is path);
334+
}
335+
```
336+
337+
</CodeGroup>
338+
339+
### Parameterized form
340+
341+
`document-reference` also accepts an object form with an optional `model` field that names the target document (or alias) model. Typesync validates that the referenced model exists in the same schema and threads the target through to the generated code.
342+
343+
The narrowing only takes effect on platforms whose Firestore SDK class is generic:
344+
345+
- **TypeScript**`firestore.DocumentReference<TargetModel>` is emitted instead of `firestore.DocumentReference<firestore.DocumentData>`.
346+
- **Zod** — the inferred type from `z.infer<typeof Schema>` carries the same narrowed shape. The runtime `instanceof` check is identical for both forms — narrowing is purely at the type level.
347+
- **Python**, **Swift**, **Security Rules** — these SDK classes are not generic, so the emitted type is identical to the bare form. The schema-level validation that `model` resolves still runs.
348+
349+
<Info>
350+
Zod self-references (e.g. `NoteLink.next: DocumentReference<NoteLink>`) are emitted **without** the generic in Zod to avoid `TS2456 Type alias circularly references itself` on the `z.infer`-derived type. Pure `generate-ts` output is unaffected and emits the narrowed self-reference. Cross-model references narrow on both targets.
351+
</Info>
352+
353+
<CodeGroup>
354+
355+
```yaml definition.yml
356+
Author:
357+
model: document
358+
path: authors/{authorId}
359+
type:
360+
type: object
361+
fields:
362+
name: { type: string }
363+
364+
Book:
365+
model: document
366+
path: books/{bookId}
367+
type:
368+
type: object
369+
fields:
370+
title: { type: string }
371+
author:
372+
type:
373+
type: document-reference
374+
model: Author
375+
```
376+
377+
```ts models.ts
378+
export interface Author {
379+
name: string;
380+
}
381+
export interface Book {
382+
title: string;
383+
author: firestore.DocumentReference<Author>;
384+
}
385+
```
386+
387+
```python models.py
388+
class Author(TypesyncModel):
389+
name: str
390+
class Book(TypesyncModel):
391+
title: str
392+
author: firestore.DocumentReference # not narrowed; Python SDK class is not generic
393+
```
394+
395+
```swift models.swift
396+
struct Author: Codable {
397+
var name: String
398+
}
399+
struct Book: Codable {
400+
var title: String
401+
var author: DocumentReference // not narrowed; iOS SDK class is not generic
402+
}
403+
```
404+
405+
</CodeGroup>
406+
299407
## `literal`
300408

301409
Represents a literal type.

schema.local.json

Lines changed: 69 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -34,52 +34,78 @@
3434
{
3535
"anyOf": [
3636
{
37-
"type": "string",
38-
"const": "any",
39-
"description": "Any type."
40-
},
41-
{
42-
"type": "string",
43-
"const": "unknown",
44-
"description": "An unknown type."
45-
},
46-
{
47-
"type": "string",
48-
"const": "nil",
49-
"description": "A nil type."
50-
},
51-
{
52-
"type": "string",
53-
"const": "string",
54-
"description": "A string type."
55-
},
56-
{
57-
"type": "string",
58-
"const": "boolean",
59-
"description": "A boolean type."
60-
},
61-
{
62-
"type": "string",
63-
"const": "int",
64-
"description": "An integer type."
65-
},
66-
{
67-
"type": "string",
68-
"const": "double",
69-
"description": "A double type."
70-
},
71-
{
72-
"type": "string",
73-
"const": "timestamp",
74-
"description": "A timestamp type."
37+
"anyOf": [
38+
{
39+
"type": "string",
40+
"const": "any",
41+
"description": "Any type."
42+
},
43+
{
44+
"type": "string",
45+
"const": "unknown",
46+
"description": "An unknown type."
47+
},
48+
{
49+
"type": "string",
50+
"const": "nil",
51+
"description": "A nil type."
52+
},
53+
{
54+
"type": "string",
55+
"const": "string",
56+
"description": "A string type."
57+
},
58+
{
59+
"type": "string",
60+
"const": "boolean",
61+
"description": "A boolean type."
62+
},
63+
{
64+
"type": "string",
65+
"const": "int",
66+
"description": "An integer type."
67+
},
68+
{
69+
"type": "string",
70+
"const": "double",
71+
"description": "A double type."
72+
},
73+
{
74+
"type": "string",
75+
"const": "timestamp",
76+
"description": "A timestamp type."
77+
},
78+
{
79+
"type": "string",
80+
"const": "bytes",
81+
"description": "A bytes type."
82+
},
83+
{
84+
"type": "string",
85+
"const": "document-reference",
86+
"description": "A Firestore document reference type. Use this for fields that store a pointer to another Firestore document (e.g. a `DocumentReference` to `users/alice`) rather than the document id as a `string`. Firestore only allows storing document references in fields (not collection references), hence the explicit name."
87+
}
88+
],
89+
"description": "A primitive type"
7590
},
7691
{
77-
"type": "string",
78-
"const": "bytes",
79-
"description": "A bytes type."
92+
"type": "object",
93+
"properties": {
94+
"type": {
95+
"type": "string",
96+
"const": "document-reference"
97+
},
98+
"model": {
99+
"description": "Name of the target model (alias or document) that the referenced document belongs to. When specified, generators that support generics (TypeScript and Zod) narrow the emitted type to `DocumentReference<TargetModel>` instead of `DocumentReference<DocumentData>`. Generators without generic `DocumentReference` classes (Python, Swift) ignore this field at the type level but the schema validator still checks that the named model exists.",
100+
"type": "string",
101+
"minLength": 1
102+
}
103+
},
104+
"required": ["type"],
105+
"additionalProperties": false,
106+
"description": "A Firestore document reference type, parameterized by the target model. Equivalent to the bare `document-reference` string form when `model` is omitted."
80107
}
81-
],
82-
"description": "A primitive type"
108+
]
83109
},
84110
{
85111
"anyOf": [

scripts/integration-test.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -156,20 +156,23 @@ const PLATFORMS: Record<Platform, PlatformConfig> = {
156156
objectTypeFormat: 'interface',
157157
});
158158
},
159-
// The `bytes` scenario is the only place where the wire-level
160-
// representation differs across TS targets (Buffer vs firestore.Bytes
161-
// vs firestore.Blob), so we emit it against the web SDK in addition
162-
// to the admin SDK. The admin pass above writes `generated/secrets.ts`;
163-
// this pass writes `generated/web/secrets.ts`, which is imported by
164-
// the dedicated `secrets.web.test.ts` round-trip suite. The
159+
// The `bytes` and `document-reference` scenarios are the only places
160+
// where the wire-level representation differs across TS targets
161+
// (Buffer vs firestore.Bytes vs firestore.Blob for bytes; and
162+
// firebase-admin's vs firebase-web's `DocumentReference` for refs),
163+
// so we emit those fixtures against the web SDK in addition to the
164+
// admin SDK. The admin pass above writes
165+
// `generated/{secrets,references}.ts`; this pass writes
166+
// `generated/web/{secrets,references}.ts`, which is imported by the
167+
// dedicated `*.web.test.ts` round-trip suites. The
165168
// react-native-firebase target is verified by the unit/snapshot tests
166169
// under `src/renderers/ts/__tests__/`; we don't run it here because
167170
// `@react-native-firebase/firestore` is RN-runtime-only and cannot
168171
// execute under Node.
169172
extraGenerations: [
170173
{
171174
subdir: 'web',
172-
onlyFixtures: ['secrets'],
175+
onlyFixtures: ['secrets', 'references'],
173176
async generate(definition, outFile) {
174177
await typesync.generateTs({
175178
definition,
@@ -241,7 +244,7 @@ const PLATFORMS: Record<Platform, PlatformConfig> = {
241244
},
242245
{
243246
subdir: 'v4-web',
244-
onlyFixtures: ['secrets'],
247+
onlyFixtures: ['secrets', 'references'],
245248
async generate(definition, outFile) {
246249
await typesync.generateZod({
247250
definition,

src/converters/definition-to-schema.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,19 @@ export function primitiveTypeToSchema(t: definition.types.Primitive): schema.typ
2222
return { type: 'timestamp' };
2323
case 'bytes':
2424
return { type: 'bytes' };
25+
case 'document-reference':
26+
return { type: 'document-reference' };
2527
default:
2628
assertNever(t);
2729
}
2830
}
2931

32+
export function parameterizedDocumentReferenceTypeToSchema(
33+
t: definition.types.ParameterizedDocumentReference
34+
): schema.types.DocumentReference {
35+
return t.model !== undefined ? { type: 'document-reference', model: t.model } : { type: 'document-reference' };
36+
}
37+
3038
export function stringLiteralTypeToSchema(t: definition.types.StringLiteral): schema.types.StringLiteral {
3139
return { type: 'string-literal', value: t.value };
3240
}
@@ -145,6 +153,8 @@ export function typeToSchema(t: definition.types.Type): schema.types.Type {
145153
}
146154

147155
switch (t.type) {
156+
case 'document-reference':
157+
return parameterizedDocumentReferenceTypeToSchema(t);
148158
case 'literal':
149159
return literalTypeToSchema(t);
150160
case 'enum':

src/core/zod/__tests__/build-zod-schema.test.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Timestamp } from 'firebase-admin/firestore';
1+
import { DocumentReference, Timestamp } from 'firebase-admin/firestore';
22

33
import { schema } from '../../../schema/index.js';
44
import { buildZodSchemaMap } from '../build-zod-schema.js';
@@ -19,6 +19,7 @@ describe('buildZodSchemaMap()', () => {
1919
BoolAlias: { model: 'alias', type: 'boolean' },
2020
TimestampAlias: { model: 'alias', type: 'timestamp' },
2121
BytesAlias: { model: 'alias', type: 'bytes' },
22+
ReferenceAlias: { model: 'alias', type: 'document-reference' },
2223
NilAlias: { model: 'alias', type: 'nil' },
2324
AnyAlias: { model: 'alias', type: 'any' },
2425
UnknownAlias: { model: 'alias', type: 'unknown' },
@@ -53,6 +54,61 @@ describe('buildZodSchemaMap()', () => {
5354
expect(getSchemaForModel(s, 'BytesAlias').safeParse(new Uint8Array([1, 2, 3])).success).toBe(false);
5455
expect(getSchemaForModel(s, 'BytesAlias').safeParse('hello').success).toBe(false);
5556
});
57+
58+
it('accepts only firestore DocumentReference instances for reference', () => {
59+
// We can't construct a real `DocumentReference` directly (its constructor
60+
// is private in the admin typings), so we fake an object whose prototype
61+
// chain matches the class — this mirrors how validators see refs that
62+
// arrive via the admin SDK at runtime.
63+
const fakeRef = Object.create(DocumentReference.prototype) as DocumentReference;
64+
expect(getSchemaForModel(s, 'ReferenceAlias').safeParse(fakeRef).success).toBe(true);
65+
expect(getSchemaForModel(s, 'ReferenceAlias').safeParse({ path: 'users/abc' }).success).toBe(false);
66+
expect(getSchemaForModel(s, 'ReferenceAlias').safeParse('users/abc').success).toBe(false);
67+
});
68+
69+
it('treats the parameterized form identically to the bare form at runtime', () => {
70+
// The runtime emitter discards the target-model hint: the validator
71+
// only checks `instanceof DocumentReference`. Narrowing happens purely
72+
// at the type level via the codegen emitter; the runtime semantics are
73+
// the same for both forms.
74+
const sParam = schema.createSchemaFromDefinition({
75+
Target: {
76+
model: 'document',
77+
path: 'targets/{targetId}',
78+
type: { type: 'object', fields: { name: { type: 'string' } } },
79+
},
80+
Source: {
81+
model: 'document',
82+
path: 'sources/{sourceId}',
83+
type: {
84+
type: 'object',
85+
fields: { ref: { type: { type: 'document-reference', model: 'Target' } } },
86+
},
87+
},
88+
});
89+
const sourceSchema = getSchemaForModel(sParam, 'Source');
90+
const fakeRef = Object.create(DocumentReference.prototype) as DocumentReference;
91+
expect(sourceSchema.safeParse({ ref: fakeRef }).success).toBe(true);
92+
expect(sourceSchema.safeParse({ ref: 'targets/canonical' }).success).toBe(false);
93+
});
94+
95+
it('rejects a parameterized document-reference whose `model` does not exist in the schema', () => {
96+
// Schema-level validation guards us against typos and dangling
97+
// references at definition-parse time, before any code generation
98+
// runs.
99+
expect(() =>
100+
schema.createSchemaFromDefinition({
101+
Source: {
102+
model: 'document',
103+
path: 'sources/{sourceId}',
104+
type: {
105+
type: 'object',
106+
fields: { ref: { type: { type: 'document-reference', model: 'Ghost' } } },
107+
},
108+
},
109+
})
110+
).toThrow(/Ghost/);
111+
});
56112
});
57113

58114
describe('enums and literals', () => {

0 commit comments

Comments
 (0)