Skip to content

Commit b59e809

Browse files
authored
feat: add bytes support across generators and integrations (#220)
* Implement bytes * Integration tests * Update docs * Add changeset
1 parent c8e752c commit b59e809

64 files changed

Lines changed: 1020 additions & 33 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/bright-squids-care.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'typesync-cli': minor
3+
---
4+
5+
Add full Firestore `bytes` support across Typesync.
6+
7+
- support `bytes` in schema definitions, schema conversion, validation, and Zod generation
8+
- generate the correct platform-specific bytes types for TypeScript, Python, Swift, and Firestore Rules
9+
- add emulator-backed integration coverage for TypeScript, Python, and Swift bytes round-trips

docs/schema/types.mdx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,39 @@ function isValidExample(data) {
263263

264264
</CodeGroup>
265265

266+
## `bytes`
267+
268+
Represents a Firestore bytes value.
269+
270+
<CodeGroup>
271+
272+
```yaml definition.yml
273+
Example:
274+
model: alias
275+
type: bytes
276+
```
277+
278+
```ts models.ts
279+
// Firebase Web targets emit firestore.Bytes; React Native Firebase emits firestore.Blob.
280+
export type Example = Buffer;
281+
```
282+
283+
```swift models.swift
284+
typealias Example = Data
285+
```
286+
287+
```python models.py
288+
Example = bytes
289+
```
290+
291+
```javascript firestore.rules
292+
function isValidExample(data) {
293+
return (data is bytes);
294+
}
295+
```
296+
297+
</CodeGroup>
298+
266299
## `literal`
267300

268301
Represents a literal type.

scripts/integration-test.ts

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,26 @@ interface PlatformConfig {
3939
generatedDir: string;
4040
generatedExtension: string;
4141
generate: (definitionPath: string, outFile: string) => Promise<void>;
42+
/**
43+
* Optional follow-up generation passes. Used by TypeScript to additionally
44+
* emit code against alternative SDK targets (e.g. the web SDK) so that
45+
* cross-target features like the `bytes` primitive can be round-tripped
46+
* with each target's native representation.
47+
*/
48+
extraGenerations?: {
49+
/**
50+
* Subdirectory under `generatedDir` that the extra pass writes to. The
51+
* platform's `tsconfig.json` / `Package.swift` etc. must already include
52+
* files in this directory.
53+
*/
54+
subdir: string;
55+
/**
56+
* Restricts the pass to a subset of fixtures (matched by basename, e.g.
57+
* `'secrets'`). Omit to apply to every fixture.
58+
*/
59+
onlyFixtures?: string[];
60+
generate: (definitionPath: string, outFile: string) => Promise<void>;
61+
}[];
4262
runs: { description: string; cwd: string; cmd: string; args: string[]; underEmulator: boolean }[];
4363
}
4464

@@ -94,6 +114,30 @@ const PLATFORMS: Record<Platform, PlatformConfig> = {
94114
objectTypeFormat: 'interface',
95115
});
96116
},
117+
// The `bytes` scenario is the only place where the wire-level
118+
// representation differs across TS targets (Buffer vs firestore.Bytes
119+
// vs firestore.Blob), so we emit it against the web SDK in addition
120+
// to the admin SDK. The admin pass above writes `generated/secrets.ts`;
121+
// this pass writes `generated/web/secrets.ts`, which is imported by
122+
// the dedicated `secrets.web.test.ts` round-trip suite. The
123+
// react-native-firebase target is verified by the unit/snapshot tests
124+
// under `src/renderers/ts/__tests__/`; we don't run it here because
125+
// `@react-native-firebase/firestore` is RN-runtime-only and cannot
126+
// execute under Node.
127+
extraGenerations: [
128+
{
129+
subdir: 'web',
130+
onlyFixtures: ['secrets'],
131+
async generate(definition, outFile) {
132+
await typesync.generateTs({
133+
definition,
134+
outFile,
135+
target: 'firebase@10',
136+
objectTypeFormat: 'interface',
137+
});
138+
},
139+
},
140+
],
97141
runs: [
98142
{
99143
description: 'tsc --noEmit (compile-time check)',
@@ -129,21 +173,35 @@ function listSchemaFixtures(): { path: string; name: string }[] {
129173

130174
function clearGeneratedDir(generatedDir: string, extension: string): void {
131175
if (!existsSync(generatedDir)) return;
132-
for (const entry of readdirSync(generatedDir)) {
133-
if (entry.endsWith(extension)) {
134-
rmSync(resolve(generatedDir, entry));
176+
for (const entry of readdirSync(generatedDir, { withFileTypes: true })) {
177+
const entryPath = resolve(generatedDir, entry.name);
178+
if (entry.isDirectory()) {
179+
clearGeneratedDir(entryPath, extension);
180+
continue;
181+
}
182+
if (entry.name.endsWith(extension)) {
183+
rmSync(entryPath);
135184
}
136185
}
137186
}
138187

139188
async function generateAll(platform: Platform, config: PlatformConfig): Promise<void> {
140189
console.log(`\n[${platform}] generating fixtures…`);
141190
clearGeneratedDir(config.generatedDir, config.generatedExtension);
142-
for (const fixture of listSchemaFixtures()) {
191+
const fixtures = listSchemaFixtures();
192+
for (const fixture of fixtures) {
143193
const outFile = resolve(config.generatedDir, `${fixture.name}${config.generatedExtension}`);
144194
await config.generate(fixture.path, outFile);
145195
console.log(` → ${fixture.name} -> ${outFile}`);
146196
}
197+
for (const extra of config.extraGenerations ?? []) {
198+
const targets = extra.onlyFixtures ? fixtures.filter(f => extra.onlyFixtures!.includes(f.name)) : fixtures;
199+
for (const fixture of targets) {
200+
const outFile = resolve(config.generatedDir, extra.subdir, `${fixture.name}${config.generatedExtension}`);
201+
await extra.generate(fixture.path, outFile);
202+
console.log(` → [${extra.subdir}] ${fixture.name} -> ${outFile}`);
203+
}
204+
}
147205
}
148206

149207
const FIREBASE_BIN = resolve(REPO_ROOT, 'node_modules/.bin/firebase');

src/converters/definition-to-schema.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ export function primitiveTypeToSchema(t: definition.types.Primitive): schema.typ
2020
return { type: 'double' };
2121
case 'timestamp':
2222
return { type: 'timestamp' };
23+
case 'bytes':
24+
return { type: 'bytes' };
2325
default:
2426
assertNever(t);
2527
}

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ describe('buildZodSchemaMap()', () => {
1818
DoubleAlias: { model: 'alias', type: 'double' },
1919
BoolAlias: { model: 'alias', type: 'boolean' },
2020
TimestampAlias: { model: 'alias', type: 'timestamp' },
21+
BytesAlias: { model: 'alias', type: 'bytes' },
2122
NilAlias: { model: 'alias', type: 'nil' },
2223
AnyAlias: { model: 'alias', type: 'any' },
2324
UnknownAlias: { model: 'alias', type: 'unknown' },
@@ -46,6 +47,12 @@ describe('buildZodSchemaMap()', () => {
4647
expect(getSchemaForModel(s, 'TimestampAlias').safeParse(new Date()).success).toBe(false);
4748
expect(getSchemaForModel(s, 'TimestampAlias').safeParse('2020-01-01').success).toBe(false);
4849
});
50+
51+
it('accepts only Buffer instances for bytes', () => {
52+
expect(getSchemaForModel(s, 'BytesAlias').safeParse(Buffer.from('hello')).success).toBe(true);
53+
expect(getSchemaForModel(s, 'BytesAlias').safeParse(new Uint8Array([1, 2, 3])).success).toBe(false);
54+
expect(getSchemaForModel(s, 'BytesAlias').safeParse('hello').success).toBe(false);
55+
});
4956
});
5057

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

src/core/zod/_emitter.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export interface ZodEmitter<TOut> {
2323
int(): TOut;
2424
double(): TOut;
2525
timestamp(): TOut;
26+
bytes(): TOut;
2627

2728
stringLiteral(value: string): TOut;
2829
intLiteral(value: number): TOut;

src/core/zod/_runtime-emitter.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export function createRuntimeZodEmitter(registry: RuntimeZodRegistry): ZodEmitte
2727
int: () => z.number().int(),
2828
double: () => z.number(),
2929
timestamp: () => z.instanceof(Timestamp),
30+
bytes: () => z.instanceof(Buffer),
3031

3132
stringLiteral: value => z.literal(value),
3233
intLiteral: value => z.literal(value),

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ export function buildZodFromType<TOut>(type: schema.types.Type, emitter: ZodEmit
2929
return emitter.double();
3030
case 'timestamp':
3131
return emitter.timestamp();
32+
case 'bytes':
33+
return emitter.bytes();
3234
case 'string-literal':
3335
return emitter.stringLiteral(type.value);
3436
case 'int-literal':

src/definition/_guards.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export function isPrimitiveType(candidate: unknown): candidate is types.Primitiv
1212
case 'int':
1313
case 'double':
1414
case 'timestamp':
15+
case 'bytes':
1516
return true;
1617
default:
1718
assertNeverNoThrow(c);

src/definition/impl/__tests__/consistent.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
anyType,
99
booleanLiteralType,
1010
booleanType,
11+
bytesType,
1112
definition,
1213
discriminatedUnionType,
1314
documentModel,
@@ -86,6 +87,12 @@ type IsExact<T, U> = [Required<T>] extends [Required<U>] ? ([Required<U>] extend
8687
assertEmpty<IsExact<DeclaredType, InferredType>>(true);
8788
})();
8889

90+
(() => {
91+
type DeclaredType = types.Bytes;
92+
type InferredType = z.infer<typeof bytesType>;
93+
assertEmpty<IsExact<DeclaredType, InferredType>>(true);
94+
})();
95+
8996
(() => {
9097
type DeclaredType = types.Primitive;
9198
type InferredType = z.infer<typeof primitiveType>;

0 commit comments

Comments
 (0)