Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/bright-squids-care.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'typesync-cli': minor
---

Add full Firestore `bytes` support across Typesync.

- support `bytes` in schema definitions, schema conversion, validation, and Zod generation
- generate the correct platform-specific bytes types for TypeScript, Python, Swift, and Firestore Rules
- add emulator-backed integration coverage for TypeScript, Python, and Swift bytes round-trips
33 changes: 33 additions & 0 deletions docs/schema/types.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,39 @@ function isValidExample(data) {

</CodeGroup>

## `bytes`

Represents a Firestore bytes value.

<CodeGroup>

```yaml definition.yml
Example:
model: alias
type: bytes
```

```ts models.ts
// Firebase Web targets emit firestore.Bytes; React Native Firebase emits firestore.Blob.
export type Example = Buffer;
```

```swift models.swift
typealias Example = Data
```

```python models.py
Example = bytes
```

```javascript firestore.rules
function isValidExample(data) {
return (data is bytes);
}
```

</CodeGroup>

## `literal`

Represents a literal type.
Expand Down
66 changes: 62 additions & 4 deletions scripts/integration-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,26 @@ interface PlatformConfig {
generatedDir: string;
generatedExtension: string;
generate: (definitionPath: string, outFile: string) => Promise<void>;
/**
* Optional follow-up generation passes. Used by TypeScript to additionally
* emit code against alternative SDK targets (e.g. the web SDK) so that
* cross-target features like the `bytes` primitive can be round-tripped
* with each target's native representation.
*/
extraGenerations?: {
/**
* Subdirectory under `generatedDir` that the extra pass writes to. The
* platform's `tsconfig.json` / `Package.swift` etc. must already include
* files in this directory.
*/
subdir: string;
/**
* Restricts the pass to a subset of fixtures (matched by basename, e.g.
* `'secrets'`). Omit to apply to every fixture.
*/
onlyFixtures?: string[];
generate: (definitionPath: string, outFile: string) => Promise<void>;
}[];
runs: { description: string; cwd: string; cmd: string; args: string[]; underEmulator: boolean }[];
}

Expand Down Expand Up @@ -94,6 +114,30 @@ const PLATFORMS: Record<Platform, PlatformConfig> = {
objectTypeFormat: 'interface',
});
},
// The `bytes` scenario is the only place where the wire-level
// representation differs across TS targets (Buffer vs firestore.Bytes
// vs firestore.Blob), so we emit it against the web SDK in addition
// to the admin SDK. The admin pass above writes `generated/secrets.ts`;
// this pass writes `generated/web/secrets.ts`, which is imported by
// the dedicated `secrets.web.test.ts` round-trip suite. The
// react-native-firebase target is verified by the unit/snapshot tests
// under `src/renderers/ts/__tests__/`; we don't run it here because
// `@react-native-firebase/firestore` is RN-runtime-only and cannot
// execute under Node.
extraGenerations: [
{
subdir: 'web',
onlyFixtures: ['secrets'],
async generate(definition, outFile) {
await typesync.generateTs({
definition,
outFile,
target: 'firebase@10',
objectTypeFormat: 'interface',
});
},
},
],
runs: [
{
description: 'tsc --noEmit (compile-time check)',
Expand Down Expand Up @@ -129,21 +173,35 @@ function listSchemaFixtures(): { path: string; name: string }[] {

function clearGeneratedDir(generatedDir: string, extension: string): void {
if (!existsSync(generatedDir)) return;
for (const entry of readdirSync(generatedDir)) {
if (entry.endsWith(extension)) {
rmSync(resolve(generatedDir, entry));
for (const entry of readdirSync(generatedDir, { withFileTypes: true })) {
const entryPath = resolve(generatedDir, entry.name);
if (entry.isDirectory()) {
clearGeneratedDir(entryPath, extension);
continue;
}
if (entry.name.endsWith(extension)) {
rmSync(entryPath);
}
}
}

async function generateAll(platform: Platform, config: PlatformConfig): Promise<void> {
console.log(`\n[${platform}] generating fixtures…`);
clearGeneratedDir(config.generatedDir, config.generatedExtension);
for (const fixture of listSchemaFixtures()) {
const fixtures = listSchemaFixtures();
for (const fixture of fixtures) {
const outFile = resolve(config.generatedDir, `${fixture.name}${config.generatedExtension}`);
await config.generate(fixture.path, outFile);
console.log(` → ${fixture.name} -> ${outFile}`);
}
for (const extra of config.extraGenerations ?? []) {
const targets = extra.onlyFixtures ? fixtures.filter(f => extra.onlyFixtures!.includes(f.name)) : fixtures;
for (const fixture of targets) {
const outFile = resolve(config.generatedDir, extra.subdir, `${fixture.name}${config.generatedExtension}`);
await extra.generate(fixture.path, outFile);
console.log(` → [${extra.subdir}] ${fixture.name} -> ${outFile}`);
}
}
}

const FIREBASE_BIN = resolve(REPO_ROOT, 'node_modules/.bin/firebase');
Expand Down
2 changes: 2 additions & 0 deletions src/converters/definition-to-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export function primitiveTypeToSchema(t: definition.types.Primitive): schema.typ
return { type: 'double' };
case 'timestamp':
return { type: 'timestamp' };
case 'bytes':
return { type: 'bytes' };
default:
assertNever(t);
}
Expand Down
7 changes: 7 additions & 0 deletions src/core/zod/__tests__/build-zod-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ describe('buildZodSchemaMap()', () => {
DoubleAlias: { model: 'alias', type: 'double' },
BoolAlias: { model: 'alias', type: 'boolean' },
TimestampAlias: { model: 'alias', type: 'timestamp' },
BytesAlias: { model: 'alias', type: 'bytes' },
NilAlias: { model: 'alias', type: 'nil' },
AnyAlias: { model: 'alias', type: 'any' },
UnknownAlias: { model: 'alias', type: 'unknown' },
Expand Down Expand Up @@ -46,6 +47,12 @@ describe('buildZodSchemaMap()', () => {
expect(getSchemaForModel(s, 'TimestampAlias').safeParse(new Date()).success).toBe(false);
expect(getSchemaForModel(s, 'TimestampAlias').safeParse('2020-01-01').success).toBe(false);
});

it('accepts only Buffer instances for bytes', () => {
expect(getSchemaForModel(s, 'BytesAlias').safeParse(Buffer.from('hello')).success).toBe(true);
expect(getSchemaForModel(s, 'BytesAlias').safeParse(new Uint8Array([1, 2, 3])).success).toBe(false);
expect(getSchemaForModel(s, 'BytesAlias').safeParse('hello').success).toBe(false);
});
});

describe('enums and literals', () => {
Expand Down
1 change: 1 addition & 0 deletions src/core/zod/_emitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export interface ZodEmitter<TOut> {
int(): TOut;
double(): TOut;
timestamp(): TOut;
bytes(): TOut;

stringLiteral(value: string): TOut;
intLiteral(value: number): TOut;
Expand Down
1 change: 1 addition & 0 deletions src/core/zod/_runtime-emitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export function createRuntimeZodEmitter(registry: RuntimeZodRegistry): ZodEmitte
int: () => z.number().int(),
double: () => z.number(),
timestamp: () => z.instanceof(Timestamp),
bytes: () => z.instanceof(Buffer),

stringLiteral: value => z.literal(value),
intLiteral: value => z.literal(value),
Expand Down
2 changes: 2 additions & 0 deletions src/core/zod/build-zod-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export function buildZodFromType<TOut>(type: schema.types.Type, emitter: ZodEmit
return emitter.double();
case 'timestamp':
return emitter.timestamp();
case 'bytes':
return emitter.bytes();
case 'string-literal':
return emitter.stringLiteral(type.value);
case 'int-literal':
Expand Down
1 change: 1 addition & 0 deletions src/definition/_guards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export function isPrimitiveType(candidate: unknown): candidate is types.Primitiv
case 'int':
case 'double':
case 'timestamp':
case 'bytes':
return true;
default:
assertNeverNoThrow(c);
Expand Down
7 changes: 7 additions & 0 deletions src/definition/impl/__tests__/consistent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
anyType,
booleanLiteralType,
booleanType,
bytesType,
definition,
discriminatedUnionType,
documentModel,
Expand Down Expand Up @@ -86,6 +87,12 @@ type IsExact<T, U> = [Required<T>] extends [Required<U>] ? ([Required<U>] extend
assertEmpty<IsExact<DeclaredType, InferredType>>(true);
})();

(() => {
type DeclaredType = types.Bytes;
type InferredType = z.infer<typeof bytesType>;
assertEmpty<IsExact<DeclaredType, InferredType>>(true);
})();

(() => {
type DeclaredType = types.Primitive;
type InferredType = z.infer<typeof primitiveType>;
Expand Down
4 changes: 3 additions & 1 deletion src/definition/impl/_zod-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ export const doubleType = z.literal('double').describe('A double type.');

export const timestampType = z.literal('timestamp').describe('A timestamp type.');

export const bytesType = z.literal('bytes').describe('A bytes type.');

export const primitiveType = z
.union([anyType, unknownType, nilType, stringType, booleanType, intType, doubleType, timestampType])
.union([anyType, unknownType, nilType, stringType, booleanType, intType, doubleType, timestampType, bytesType])
.describe('A primitive type');

export const stringLiteralType = z
Expand Down
4 changes: 3 additions & 1 deletion src/definition/types/_types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ export type Double = 'double';

export type Timestamp = 'timestamp';

export type Primitive = Any | Unknown | Nil | String | Boolean | Int | Double | Timestamp;
export type Bytes = 'bytes';

export type Primitive = Any | Unknown | Nil | String | Boolean | Int | Double | Timestamp | Bytes;

export interface StringLiteral {
type: 'literal';
Expand Down
2 changes: 2 additions & 0 deletions src/generators/python/__tests__/generator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ describe('PythonGeneratorImpl', () => {
IntAlias: { model: 'alias', type: 'int' },
DoubleAlias: { model: 'alias', type: 'double' },
TimestampAlias: { model: 'alias', type: 'timestamp' },
BytesAlias: { model: 'alias', type: 'bytes' },
});

const generation = createGenerator().generate(s);
Expand All @@ -34,6 +35,7 @@ describe('PythonGeneratorImpl', () => {
IntAlias: { type: 'int' },
DoubleAlias: { type: 'float' },
TimestampAlias: { type: 'datetime' },
BytesAlias: { type: 'bytes' },
};

expect(generation.declarations).toHaveLength(Object.keys(expectedPythonTypeByModelName).length);
Expand Down
1 change: 1 addition & 0 deletions src/generators/python/_adjust-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ export function adjustSchemaForPython(prevSchema: schema.Schema): schema.python.
case 'int':
case 'double':
case 'timestamp':
case 'bytes':
case 'string-literal':
case 'int-literal':
case 'boolean-literal':
Expand Down
6 changes: 6 additions & 0 deletions src/generators/python/_converters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ export function timestampTypeToPython(_t: schema.python.types.Timestamp): python
return { type: 'datetime' };
}

export function bytesTypeToPython(_t: schema.python.types.Bytes): python.Bytes {
return { type: 'bytes' };
}

export function literalTypeToPython(t: schema.python.types.Literal): python.Literal {
return { type: 'literal', value: t.value };
}
Expand Down Expand Up @@ -82,6 +86,8 @@ export function flatTypeToPython(t: schema.python.types.Type): python.Type {
return doubleTypeToPython(t);
case 'timestamp':
return timestampTypeToPython(t);
case 'bytes':
return bytesTypeToPython(t);
case 'string-literal':
case 'int-literal':
case 'boolean-literal':
Expand Down
1 change: 1 addition & 0 deletions src/generators/python/_impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class PythonGeneratorImpl implements PythonGenerator {
case 'int':
case 'double':
case 'timestamp':
case 'bytes':
case 'string-literal':
case 'int-literal':
case 'boolean-literal':
Expand Down
2 changes: 2 additions & 0 deletions src/generators/rules/__tests__/generator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ describe('RulesGeneratorImpl', () => {
AInt: { model: 'alias', type: 'int' },
ADouble: { model: 'alias', type: 'double' },
ATs: { model: 'alias', type: 'timestamp' },
ABytes: { model: 'alias', type: 'bytes' },
});

const generation = createGenerator().generate(s);
Expand All @@ -38,6 +39,7 @@ describe('RulesGeneratorImpl', () => {
AInt: { type: 'int' },
ADouble: { type: 'number' },
ATs: { type: 'timestamp' },
ABytes: { type: 'bytes' },
};

expect(generation.typeValidatorDeclarations).toHaveLength(Object.keys(expectedRulesTypeByModelName).length);
Expand Down
1 change: 1 addition & 0 deletions src/generators/rules/_adjust-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export function adjustSchemaForRules(prevSchema: schema.Schema): schema.rules.Sc
case 'int':
case 'double':
case 'timestamp':
case 'bytes':
case 'string-literal':
case 'int-literal':
case 'boolean-literal':
Expand Down
6 changes: 6 additions & 0 deletions src/generators/rules/_converters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ export function timestampTypeToRules(_t: schema.rules.types.Timestamp): rules.Ti
return { type: 'timestamp' };
}

export function bytesTypeToRules(_t: schema.rules.types.Bytes): rules.Bytes {
return { type: 'bytes' };
}

export function stringLiteralTypeToRules(t: schema.rules.types.StringLiteral): rules.Literal {
return { type: 'literal', value: t.value };
}
Expand Down Expand Up @@ -121,6 +125,8 @@ export function flatTypeToRules(t: schema.rules.types.Type): rules.Type {
return doubleTypeToRules(t);
case 'timestamp':
return timestampTypeToRules(t);
case 'bytes':
return bytesTypeToRules(t);
case 'string-literal':
return stringLiteralTypeToRules(t);
case 'int-literal':
Expand Down
1 change: 1 addition & 0 deletions src/generators/rules/_has-readonly-field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export function typeHasReadonlyField(t: schema.rules.types.Type, adjustedSchema:
case 'int':
case 'double':
case 'timestamp':
case 'bytes':
case 'string-literal':
case 'int-literal':
case 'boolean-literal':
Expand Down
1 change: 1 addition & 0 deletions src/generators/rules/_readonly-field-predicates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
case 'int':
case 'double':
case 'timestamp':
case 'bytes':
case 'string-literal':
case 'int-literal':
case 'boolean-literal':
Expand Down Expand Up @@ -145,10 +146,10 @@
}

export function readonlyFieldPredicateForSimpleUnionType(
t: schema.rules.types.SimpleUnion,

Check warning on line 149 in src/generators/rules/_readonly-field-predicates.ts

View workflow job for this annotation

GitHub Actions / Lint

't' is defined but never used. Allowed unused args must match /^_/u
prevDataParam: string,

Check warning on line 150 in src/generators/rules/_readonly-field-predicates.ts

View workflow job for this annotation

GitHub Actions / Lint

'prevDataParam' is defined but never used. Allowed unused args must match /^_/u
nextDataParam: string,

Check warning on line 151 in src/generators/rules/_readonly-field-predicates.ts

View workflow job for this annotation

GitHub Actions / Lint

'nextDataParam' is defined but never used. Allowed unused args must match /^_/u
ctx: Context

Check warning on line 152 in src/generators/rules/_readonly-field-predicates.ts

View workflow job for this annotation

GitHub Actions / Lint

'ctx' is defined but never used. Allowed unused args must match /^_/u
): rules.Predicate {
// TODO: Implement
return { type: 'boolean', value: false };
Expand Down
6 changes: 6 additions & 0 deletions src/generators/rules/_type-predicates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ export function typePredicateForTimestampType(t: rules.Timestamp, varName: strin
return { type: 'type-equality', varName, varType: t };
}

export function typePredicateForBytesType(t: rules.Bytes, varName: string): rules.Predicate {
return { type: 'type-equality', varName, varType: t };
}

export function typePredicateForLiteralType(t: rules.Literal, varName: string): rules.Predicate {
return { type: 'value-equality', varName, varValue: typeof t.value === 'string' ? `'${t.value}'` : `${t.value}` };
}
Expand Down Expand Up @@ -168,6 +172,8 @@ export function typePredicateForType(t: rules.Type, varName: string, ctx: Contex
return typePredicateForNumberType(t, varName);
case 'timestamp':
return typePredicateForTimestampType(t, varName);
case 'bytes':
return typePredicateForBytesType(t, varName);
case 'literal':
return typePredicateForLiteralType(t, varName);
case 'enum':
Expand Down
Loading
Loading