diff --git a/.changeset/bright-squids-care.md b/.changeset/bright-squids-care.md
new file mode 100644
index 00000000..0c29eff9
--- /dev/null
+++ b/.changeset/bright-squids-care.md
@@ -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
diff --git a/docs/schema/types.mdx b/docs/schema/types.mdx
index 0a617fb0..86915a20 100644
--- a/docs/schema/types.mdx
+++ b/docs/schema/types.mdx
@@ -263,6 +263,39 @@ function isValidExample(data) {
+## `bytes`
+
+Represents a Firestore bytes value.
+
+
+
+```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);
+}
+```
+
+
+
## `literal`
Represents a literal type.
diff --git a/scripts/integration-test.ts b/scripts/integration-test.ts
index 71762c55..67f5e56b 100644
--- a/scripts/integration-test.ts
+++ b/scripts/integration-test.ts
@@ -39,6 +39,26 @@ interface PlatformConfig {
generatedDir: string;
generatedExtension: string;
generate: (definitionPath: string, outFile: string) => Promise;
+ /**
+ * 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;
+ }[];
runs: { description: string; cwd: string; cmd: string; args: string[]; underEmulator: boolean }[];
}
@@ -94,6 +114,30 @@ const PLATFORMS: Record = {
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)',
@@ -129,9 +173,14 @@ 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);
}
}
}
@@ -139,11 +188,20 @@ function clearGeneratedDir(generatedDir: string, extension: string): void {
async function generateAll(platform: Platform, config: PlatformConfig): Promise {
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');
diff --git a/src/converters/definition-to-schema.ts b/src/converters/definition-to-schema.ts
index 85a55351..62bf386d 100644
--- a/src/converters/definition-to-schema.ts
+++ b/src/converters/definition-to-schema.ts
@@ -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);
}
diff --git a/src/core/zod/__tests__/build-zod-schema.test.ts b/src/core/zod/__tests__/build-zod-schema.test.ts
index 1bc5ecd7..e2cd411f 100644
--- a/src/core/zod/__tests__/build-zod-schema.test.ts
+++ b/src/core/zod/__tests__/build-zod-schema.test.ts
@@ -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' },
@@ -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', () => {
diff --git a/src/core/zod/_emitter.ts b/src/core/zod/_emitter.ts
index 4d47b51f..4fd64444 100644
--- a/src/core/zod/_emitter.ts
+++ b/src/core/zod/_emitter.ts
@@ -23,6 +23,7 @@ export interface ZodEmitter {
int(): TOut;
double(): TOut;
timestamp(): TOut;
+ bytes(): TOut;
stringLiteral(value: string): TOut;
intLiteral(value: number): TOut;
diff --git a/src/core/zod/_runtime-emitter.ts b/src/core/zod/_runtime-emitter.ts
index fa809903..1c78f8a8 100644
--- a/src/core/zod/_runtime-emitter.ts
+++ b/src/core/zod/_runtime-emitter.ts
@@ -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),
diff --git a/src/core/zod/build-zod-schema.ts b/src/core/zod/build-zod-schema.ts
index 02ff021c..fa9e9fe2 100644
--- a/src/core/zod/build-zod-schema.ts
+++ b/src/core/zod/build-zod-schema.ts
@@ -29,6 +29,8 @@ export function buildZodFromType(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':
diff --git a/src/definition/_guards.ts b/src/definition/_guards.ts
index f73ca93a..be684aa7 100644
--- a/src/definition/_guards.ts
+++ b/src/definition/_guards.ts
@@ -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);
diff --git a/src/definition/impl/__tests__/consistent.ts b/src/definition/impl/__tests__/consistent.ts
index 42659b64..1865018e 100644
--- a/src/definition/impl/__tests__/consistent.ts
+++ b/src/definition/impl/__tests__/consistent.ts
@@ -8,6 +8,7 @@ import {
anyType,
booleanLiteralType,
booleanType,
+ bytesType,
definition,
discriminatedUnionType,
documentModel,
@@ -86,6 +87,12 @@ type IsExact = [Required] extends [Required] ? ([Required] extend
assertEmpty>(true);
})();
+(() => {
+ type DeclaredType = types.Bytes;
+ type InferredType = z.infer;
+ assertEmpty>(true);
+})();
+
(() => {
type DeclaredType = types.Primitive;
type InferredType = z.infer;
diff --git a/src/definition/impl/_zod-schemas.ts b/src/definition/impl/_zod-schemas.ts
index c8c1b0bc..71a9eb5a 100644
--- a/src/definition/impl/_zod-schemas.ts
+++ b/src/definition/impl/_zod-schemas.ts
@@ -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
diff --git a/src/definition/types/_types.ts b/src/definition/types/_types.ts
index c724c891..4878dcdb 100644
--- a/src/definition/types/_types.ts
+++ b/src/definition/types/_types.ts
@@ -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';
diff --git a/src/generators/python/__tests__/generator.test.ts b/src/generators/python/__tests__/generator.test.ts
index 447951b6..7aec1c8a 100644
--- a/src/generators/python/__tests__/generator.test.ts
+++ b/src/generators/python/__tests__/generator.test.ts
@@ -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);
@@ -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);
diff --git a/src/generators/python/_adjust-schema.ts b/src/generators/python/_adjust-schema.ts
index 72754332..042b50b7 100644
--- a/src/generators/python/_adjust-schema.ts
+++ b/src/generators/python/_adjust-schema.ts
@@ -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':
diff --git a/src/generators/python/_converters.ts b/src/generators/python/_converters.ts
index 94144fc7..267e491a 100644
--- a/src/generators/python/_converters.ts
+++ b/src/generators/python/_converters.ts
@@ -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 };
}
@@ -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':
diff --git a/src/generators/python/_impl.ts b/src/generators/python/_impl.ts
index 71b257cb..3b83d184 100644
--- a/src/generators/python/_impl.ts
+++ b/src/generators/python/_impl.ts
@@ -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':
diff --git a/src/generators/rules/__tests__/generator.test.ts b/src/generators/rules/__tests__/generator.test.ts
index 307a343c..3ed60254 100644
--- a/src/generators/rules/__tests__/generator.test.ts
+++ b/src/generators/rules/__tests__/generator.test.ts
@@ -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);
@@ -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);
diff --git a/src/generators/rules/_adjust-schema.ts b/src/generators/rules/_adjust-schema.ts
index c93ab4a9..3e5b5d5e 100644
--- a/src/generators/rules/_adjust-schema.ts
+++ b/src/generators/rules/_adjust-schema.ts
@@ -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':
diff --git a/src/generators/rules/_converters.ts b/src/generators/rules/_converters.ts
index a35ff406..71221374 100644
--- a/src/generators/rules/_converters.ts
+++ b/src/generators/rules/_converters.ts
@@ -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 };
}
@@ -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':
diff --git a/src/generators/rules/_has-readonly-field.ts b/src/generators/rules/_has-readonly-field.ts
index 84248a1a..e229499a 100644
--- a/src/generators/rules/_has-readonly-field.ts
+++ b/src/generators/rules/_has-readonly-field.ts
@@ -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':
diff --git a/src/generators/rules/_readonly-field-predicates.ts b/src/generators/rules/_readonly-field-predicates.ts
index 9037ce7a..3fbcf5bc 100644
--- a/src/generators/rules/_readonly-field-predicates.ts
+++ b/src/generators/rules/_readonly-field-predicates.ts
@@ -26,6 +26,7 @@ export function readonlyFieldPredicateForType(
case 'int':
case 'double':
case 'timestamp':
+ case 'bytes':
case 'string-literal':
case 'int-literal':
case 'boolean-literal':
diff --git a/src/generators/rules/_type-predicates.ts b/src/generators/rules/_type-predicates.ts
index 41e6a573..a73a7ad1 100644
--- a/src/generators/rules/_type-predicates.ts
+++ b/src/generators/rules/_type-predicates.ts
@@ -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}` };
}
@@ -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':
diff --git a/src/generators/swift/__tests__/generator.test.ts b/src/generators/swift/__tests__/generator.test.ts
index 9c0b2b1f..3d398dfb 100644
--- a/src/generators/swift/__tests__/generator.test.ts
+++ b/src/generators/swift/__tests__/generator.test.ts
@@ -25,6 +25,7 @@ describe('SwiftGeneratorImpl', () => {
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);
@@ -38,6 +39,7 @@ describe('SwiftGeneratorImpl', () => {
IntAlias: { type: 'int' },
DoubleAlias: { type: 'double' },
TimestampAlias: { type: 'date' },
+ BytesAlias: { type: 'data' },
};
expect(generation.declarations).toHaveLength(Object.keys(expectedSwiftTypeByModelName).length);
diff --git a/src/generators/swift/_adjust-schema.ts b/src/generators/swift/_adjust-schema.ts
index 27f569d1..e2e44c1b 100644
--- a/src/generators/swift/_adjust-schema.ts
+++ b/src/generators/swift/_adjust-schema.ts
@@ -114,6 +114,7 @@ export function adjustSchemaForSwift(prevSchema: schema.Schema): schema.swift.Sc
case 'int':
case 'double':
case 'timestamp':
+ case 'bytes':
case 'string-literal':
case 'int-literal':
case 'boolean-literal':
diff --git a/src/generators/swift/_converters.ts b/src/generators/swift/_converters.ts
index 45deb493..111ef8f6 100644
--- a/src/generators/swift/_converters.ts
+++ b/src/generators/swift/_converters.ts
@@ -34,6 +34,10 @@ export function timestampTypeToSwift(_t: schema.swift.types.Timestamp): swift.Da
return { type: 'date' };
}
+export function bytesTypeToSwift(_t: schema.swift.types.Bytes): swift.Data {
+ return { type: 'data' };
+}
+
export function stringLiteralTypeToSwift(_t: schema.swift.types.StringLiteral): swift.String {
return { type: 'string' };
}
@@ -93,6 +97,8 @@ export function flatTypeToSwift(t: schema.swift.types.Type): swift.Type {
return doubleTypeToSwift(t);
case 'timestamp':
return timestampTypeToSwift(t);
+ case 'bytes':
+ return bytesTypeToSwift(t);
case 'string-literal':
return stringLiteralTypeToSwift(t);
case 'int-literal':
diff --git a/src/generators/swift/_impl.ts b/src/generators/swift/_impl.ts
index e139fa5b..23aca336 100644
--- a/src/generators/swift/_impl.ts
+++ b/src/generators/swift/_impl.ts
@@ -62,6 +62,7 @@ class SwiftGeneratorImpl implements SwiftGenerator {
case 'int':
case 'double':
case 'timestamp':
+ case 'bytes':
case 'string-literal':
case 'int-literal':
case 'boolean-literal':
diff --git a/src/generators/ts/__tests__/generator.test.ts b/src/generators/ts/__tests__/generator.test.ts
index 44774d21..0822914b 100644
--- a/src/generators/ts/__tests__/generator.test.ts
+++ b/src/generators/ts/__tests__/generator.test.ts
@@ -22,6 +22,7 @@ describe('TSGeneratorImpl', () => {
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);
@@ -35,6 +36,7 @@ describe('TSGeneratorImpl', () => {
IntAlias: { type: 'number' },
DoubleAlias: { type: 'number' },
TimestampAlias: { type: 'timestamp' },
+ BytesAlias: { type: 'bytes' },
};
expect(generation.declarations).toHaveLength(Object.keys(expectedTsTypeByModelName).length);
diff --git a/src/generators/ts/_converters.ts b/src/generators/ts/_converters.ts
index ba67f036..886efbe0 100644
--- a/src/generators/ts/_converters.ts
+++ b/src/generators/ts/_converters.ts
@@ -34,6 +34,10 @@ export function timestampTypeToTS(_t: schema.ts.types.Timestamp): ts.Timestamp {
return { type: 'timestamp' };
}
+export function bytesTypeToTS(_t: schema.ts.types.Bytes): ts.Bytes {
+ return { type: 'bytes' };
+}
+
export function stringLiteralTypeToTS(t: schema.ts.types.StringLiteral): ts.Literal {
return { type: 'literal', value: t.value };
}
@@ -104,6 +108,8 @@ export function typeToTS(t: schema.ts.types.Type): ts.Type {
return doubleTypeToTS(t);
case 'timestamp':
return timestampTypeToTS(t);
+ case 'bytes':
+ return bytesTypeToTS(t);
case 'string-literal':
return stringLiteralTypeToTS(t);
case 'int-literal':
diff --git a/src/platforms/python/_expressions.ts b/src/platforms/python/_expressions.ts
index 2895c300..4e8ee944 100644
--- a/src/platforms/python/_expressions.ts
+++ b/src/platforms/python/_expressions.ts
@@ -4,6 +4,7 @@ import type {
Alias,
Any,
Bool,
+ Bytes,
Datetime,
Dict,
DiscriminatedUnion,
@@ -55,6 +56,10 @@ export function expressionForDatetimeType(_t: Datetime): Expression {
return { content: 'datetime.datetime' };
}
+export function expressionForBytesType(_t: Bytes): Expression {
+ return { content: 'bytes' };
+}
+
export function expressionForLiteralType(t: Literal): Expression {
switch (typeof t.value) {
case 'string':
@@ -117,6 +122,8 @@ export function expressionForType(t: Type): Expression {
return expressionForFloatType(t);
case 'datetime':
return expressionForDatetimeType(t);
+ case 'bytes':
+ return expressionForBytesType(t);
case 'literal':
return expressionForLiteralType(t);
case 'tuple':
diff --git a/src/platforms/python/_guards.ts b/src/platforms/python/_guards.ts
index 0d61ffc4..db0206ba 100644
--- a/src/platforms/python/_guards.ts
+++ b/src/platforms/python/_guards.ts
@@ -9,6 +9,7 @@ export function isPrimitiveType(t: Type): t is Primitive {
case 'str':
case 'bool':
case 'datetime':
+ case 'bytes':
case 'int':
case 'float':
return true;
diff --git a/src/platforms/python/_types.ts b/src/platforms/python/_types.ts
index e12c6fd4..1dbbe2fa 100644
--- a/src/platforms/python/_types.ts
+++ b/src/platforms/python/_types.ts
@@ -30,7 +30,11 @@ export interface Datetime {
readonly type: 'datetime';
}
-export type Primitive = Undefined | Any | None | Str | Bool | Int | Float | Datetime;
+export interface Bytes {
+ readonly type: 'bytes';
+}
+
+export type Primitive = Undefined | Any | None | Str | Bool | Int | Float | Datetime | Bytes;
export interface Literal {
readonly type: 'literal';
diff --git a/src/platforms/rules/_guards.ts b/src/platforms/rules/_guards.ts
index 141a1c71..1c15b8db 100644
--- a/src/platforms/rules/_guards.ts
+++ b/src/platforms/rules/_guards.ts
@@ -9,6 +9,7 @@ export function isRulesDataType(t: Type): t is RulesDataType {
case 'int':
case 'number':
case 'timestamp':
+ case 'bytes':
case 'list':
case 'map':
return true;
diff --git a/src/platforms/rules/_types.ts b/src/platforms/rules/_types.ts
index eb140d20..a26c886b 100644
--- a/src/platforms/rules/_types.ts
+++ b/src/platforms/rules/_types.ts
@@ -26,7 +26,11 @@ export interface Timestamp {
readonly type: 'timestamp';
}
-export type Primitive = Any | String | Bool | Float | Int | Number | Timestamp;
+export interface Bytes {
+ readonly type: 'bytes';
+}
+
+export type Primitive = Any | String | Bool | Float | Int | Number | Timestamp | Bytes;
export interface Literal {
readonly type: 'literal';
@@ -83,4 +87,4 @@ export interface Alias {
export type Type = Primitive | Literal | Enum | Tuple | List | Map | Object | DiscriminatedUnion | SimpleUnion | Alias;
-export type RulesDataType = String | Bool | Float | Int | Number | Timestamp | List | Map;
+export type RulesDataType = String | Bool | Float | Int | Number | Timestamp | Bytes | List | Map;
diff --git a/src/platforms/swift/_expressions.ts b/src/platforms/swift/_expressions.ts
index a8af1760..2f65a8e9 100644
--- a/src/platforms/swift/_expressions.ts
+++ b/src/platforms/swift/_expressions.ts
@@ -1,5 +1,19 @@
import { assertNever } from '../../util/assert.js';
-import type { Alias, Any, Bool, Date, Dictionary, Double, Int, List, Nil, String, Tuple, Type } from './_types.js';
+import type {
+ Alias,
+ Any,
+ Bool,
+ Data,
+ Date,
+ Dictionary,
+ Double,
+ Int,
+ List,
+ Nil,
+ String,
+ Tuple,
+ Type,
+} from './_types.js';
export interface Expression {
content: string;
@@ -33,6 +47,10 @@ export function expressionForDateType(_t: Date): Expression {
return { content: 'Date' };
}
+export function expressionForDataType(_t: Data): Expression {
+ return { content: 'Data' };
+}
+
export function expressionForTupleType(t: Tuple): Expression {
const commaSeparatedExpressions = t.elements.map(vt => expressionForType(vt).content).join(', ');
return { content: `(${commaSeparatedExpressions})` };
@@ -68,6 +86,8 @@ export function expressionForType(t: Type): Expression {
return expressionForDoubleType(t);
case 'date':
return expressionForDateType(t);
+ case 'data':
+ return expressionForDataType(t);
case 'tuple':
return expressionForTupleType(t);
case 'list':
diff --git a/src/platforms/swift/_guards.ts b/src/platforms/swift/_guards.ts
index 41d6ed60..ccf01c71 100644
--- a/src/platforms/swift/_guards.ts
+++ b/src/platforms/swift/_guards.ts
@@ -10,6 +10,7 @@ export function isPrimitiveType(t: Type): t is Primitive {
case 'int':
case 'double':
case 'date':
+ case 'data':
return true;
case 'tuple':
case 'list':
diff --git a/src/platforms/swift/_types.ts b/src/platforms/swift/_types.ts
index 86d6d253..4cfb3244 100644
--- a/src/platforms/swift/_types.ts
+++ b/src/platforms/swift/_types.ts
@@ -26,7 +26,11 @@ export interface Date {
readonly type: 'date';
}
-export type Primitive = Any | Nil | String | Bool | Int | Double | Date;
+export interface Data {
+ readonly type: 'data';
+}
+
+export type Primitive = Any | Nil | String | Bool | Int | Double | Date | Data;
export interface Tuple {
readonly type: 'tuple';
diff --git a/src/platforms/ts/_expressions.ts b/src/platforms/ts/_expressions.ts
index a49bb656..63e9964b 100644
--- a/src/platforms/ts/_expressions.ts
+++ b/src/platforms/ts/_expressions.ts
@@ -1,10 +1,12 @@
import { StringBuilder } from '@proficient/ds';
+import type { TSGenerationTarget } from '../../api/index.js';
import { assertNever } from '../../util/assert.js';
import type {
Alias,
Any,
Boolean,
+ Bytes,
Enum,
List,
Literal,
@@ -24,6 +26,19 @@ export interface Expression {
content: string;
}
+/**
+ * Per-call context threaded through `expressionForType` and friends.
+ *
+ * The TypeScript output of a few primitive types depends on which Firebase
+ * SDK the caller is generating against (e.g. `bytes` becomes `Buffer` for
+ * the admin SDK, `firestore.Bytes` for the web SDK, and `firestore.Blob`
+ * for the React Native SDK), so each expression-emitter takes the active
+ * generation target rather than guessing a default.
+ */
+export interface ExpressionOptions {
+ target: TSGenerationTarget;
+}
+
export function expressionForAnyType(_t: Any): Expression {
return { content: 'any' };
}
@@ -52,6 +67,26 @@ export function expressionForTimestampType(_t: Timestamp): Expression {
return { content: 'firestore.Timestamp' };
}
+export function expressionForBytesType(_t: Bytes, options: ExpressionOptions): Expression {
+ switch (options.target) {
+ case 'firebase-admin@13':
+ case 'firebase-admin@12':
+ case 'firebase-admin@11':
+ case 'firebase-admin@10':
+ return { content: 'Buffer' };
+ case 'firebase@11':
+ case 'firebase@10':
+ case 'firebase@9':
+ return { content: 'firestore.Bytes' };
+ case 'react-native-firebase@21':
+ case 'react-native-firebase@20':
+ case 'react-native-firebase@19':
+ return { content: 'firestore.Blob' };
+ default:
+ assertNever(options.target);
+ }
+}
+
export function expressionForLiteralType(t: Literal): Expression {
switch (typeof t.value) {
case 'string':
@@ -82,22 +117,22 @@ export function expressionForEnumType(t: Enum): Expression {
return { content };
}
-export function expressionForTupleType(t: Tuple): Expression {
- const commaSeparatedExpressions = t.elements.map(vt => expressionForType(vt).content).join(', ');
+export function expressionForTupleType(t: Tuple, options: ExpressionOptions): Expression {
+ const commaSeparatedExpressions = t.elements.map(vt => expressionForType(vt, options).content).join(', ');
return { content: `[${commaSeparatedExpressions}]` };
}
-export function expressionForListType(t: List): Expression {
- const expression = expressionForType(t.elementType);
+export function expressionForListType(t: List, options: ExpressionOptions): Expression {
+ const expression = expressionForType(t.elementType, options);
return { content: `${expression.content}[]` };
}
-export function expressionForRecordType(t: Record): Expression {
- const expression = expressionForType(t.valueType);
+export function expressionForRecordType(t: Record, options: ExpressionOptions): Expression {
+ const expression = expressionForType(t.valueType, options);
return { content: `Record` };
}
-export function expressionForObjectType(t: Object): Expression {
+export function expressionForObjectType(t: Object, options: ExpressionOptions): Expression {
const { properties, additionalProperties } = t;
const b = new StringBuilder();
@@ -106,7 +141,7 @@ export function expressionForObjectType(t: Object): Expression {
if (prop.docs !== null) {
b.append(`/** ${prop.docs} */\n`);
}
- const expression = expressionForType(prop.type);
+ const expression = expressionForType(prop.type, options);
b.append(`${prop.name}${prop.optional ? '?' : ''}: ${expression.content};\n`);
});
if (additionalProperties) {
@@ -116,8 +151,8 @@ export function expressionForObjectType(t: Object): Expression {
return { content: b.toString() };
}
-export function expressionForUnionType(t: Union): Expression {
- const separatedExpressions = t.variants.map(vt => expressionForType(vt).content).join(' | ');
+export function expressionForUnionType(t: Union, options: ExpressionOptions): Expression {
+ const separatedExpressions = t.variants.map(vt => expressionForType(vt, options).content).join(' | ');
return { content: `${separatedExpressions}` };
}
@@ -125,7 +160,7 @@ export function expressionForAliasType(t: Alias): Expression {
return { content: t.name };
}
-export function expressionForType(t: Type): Expression {
+export function expressionForType(t: Type, options: ExpressionOptions): Expression {
switch (t.type) {
case 'any':
return expressionForAnyType(t);
@@ -141,20 +176,22 @@ export function expressionForType(t: Type): Expression {
return expressionForNumberType(t);
case 'timestamp':
return expressionForTimestampType(t);
+ case 'bytes':
+ return expressionForBytesType(t, options);
case 'literal':
return expressionForLiteralType(t);
case 'enum':
return expressionForEnumType(t);
case 'tuple':
- return expressionForTupleType(t);
+ return expressionForTupleType(t, options);
case 'list':
- return expressionForListType(t);
+ return expressionForListType(t, options);
case 'record':
- return expressionForRecordType(t);
+ return expressionForRecordType(t, options);
case 'object':
- return expressionForObjectType(t);
+ return expressionForObjectType(t, options);
case 'union':
- return expressionForUnionType(t);
+ return expressionForUnionType(t, options);
case 'alias':
return expressionForAliasType(t);
default:
diff --git a/src/platforms/ts/_guards.ts b/src/platforms/ts/_guards.ts
index b0d77544..db4e0953 100644
--- a/src/platforms/ts/_guards.ts
+++ b/src/platforms/ts/_guards.ts
@@ -10,6 +10,7 @@ export function isPrimitiveType(t: Type): t is Primitive {
case 'boolean':
case 'number':
case 'timestamp':
+ case 'bytes':
return true;
case 'literal':
case 'enum':
diff --git a/src/platforms/ts/_types.ts b/src/platforms/ts/_types.ts
index 14fc7b2d..675cf1bb 100644
--- a/src/platforms/ts/_types.ts
+++ b/src/platforms/ts/_types.ts
@@ -26,7 +26,11 @@ export interface Timestamp {
readonly type: 'timestamp';
}
-export type Primitive = Any | Unknown | Null | String | Boolean | Number | Timestamp;
+export interface Bytes {
+ readonly type: 'bytes';
+}
+
+export type Primitive = Any | Unknown | Null | String | Boolean | Number | Timestamp | Bytes;
export interface Literal {
readonly type: 'literal';
diff --git a/src/renderers/python/__tests__/__file_snapshots__/all-type-expressions.py b/src/renderers/python/__tests__/__file_snapshots__/all-type-expressions.py
index a2bbd2a6..5d86c136 100644
--- a/src/renderers/python/__tests__/__file_snapshots__/all-type-expressions.py
+++ b/src/renderers/python/__tests__/__file_snapshots__/all-type-expressions.py
@@ -63,6 +63,8 @@ def model_dump(self, **kwargs) -> typing.Dict[str, typing.Any]:
CreatedAt = datetime.datetime
+Avatar = bytes
+
TheAnswer = typing.Literal[42]
Coords = tuple[int, int]
diff --git a/src/renderers/python/__tests__/renderer.test.ts b/src/renderers/python/__tests__/renderer.test.ts
index 82bb7dbe..e3b4c7a2 100644
--- a/src/renderers/python/__tests__/renderer.test.ts
+++ b/src/renderers/python/__tests__/renderer.test.ts
@@ -30,6 +30,7 @@ describe('PythonRendererImpl', () => {
{ type: 'alias', modelName: 'Age', modelType: { type: 'int' }, modelDocs: null },
{ type: 'alias', modelName: 'Pi', modelType: { type: 'float' }, modelDocs: null },
{ type: 'alias', modelName: 'CreatedAt', modelType: { type: 'datetime' }, modelDocs: null },
+ { type: 'alias', modelName: 'Avatar', modelType: { type: 'bytes' }, modelDocs: null },
{ type: 'alias', modelName: 'TheAnswer', modelType: { type: 'literal', value: 42 }, modelDocs: null },
{
type: 'alias',
diff --git a/src/renderers/swift/__tests__/__file_snapshots__/all-type-expressions.swift b/src/renderers/swift/__tests__/__file_snapshots__/all-type-expressions.swift
index 10da6b7c..b2447621 100644
--- a/src/renderers/swift/__tests__/__file_snapshots__/all-type-expressions.swift
+++ b/src/renderers/swift/__tests__/__file_snapshots__/all-type-expressions.swift
@@ -13,6 +13,8 @@ typealias Pi = Double
typealias CreatedAt = Date
+typealias Avatar = Data
+
typealias Coords = (Int, Int)
typealias Tags = [String]
diff --git a/src/renderers/swift/__tests__/renderer.test.ts b/src/renderers/swift/__tests__/renderer.test.ts
index c6bb8a15..3f6eda43 100644
--- a/src/renderers/swift/__tests__/renderer.test.ts
+++ b/src/renderers/swift/__tests__/renderer.test.ts
@@ -21,6 +21,7 @@ describe('SwiftRendererImpl', () => {
{ type: 'typealias', modelName: 'Age', modelType: { type: 'int' }, modelDocs: null },
{ type: 'typealias', modelName: 'Pi', modelType: { type: 'double' }, modelDocs: null },
{ type: 'typealias', modelName: 'CreatedAt', modelType: { type: 'date' }, modelDocs: null },
+ { type: 'typealias', modelName: 'Avatar', modelType: { type: 'data' }, modelDocs: null },
{
type: 'typealias',
modelName: 'Coords',
diff --git a/src/renderers/ts/__tests__/__file_snapshots__/all-type-expressions.ts b/src/renderers/ts/__tests__/__file_snapshots__/all-type-expressions.ts
index 19ec8b51..de59c47a 100644
--- a/src/renderers/ts/__tests__/__file_snapshots__/all-type-expressions.ts
+++ b/src/renderers/ts/__tests__/__file_snapshots__/all-type-expressions.ts
@@ -15,6 +15,8 @@ export type Age = number;
export type CreatedAt = firestore.Timestamp;
+export type Avatar = Buffer;
+
export type TheAnswer = 42;
export type Color = 'red' | 'blue';
diff --git a/src/renderers/ts/__tests__/renderer.test.ts b/src/renderers/ts/__tests__/renderer.test.ts
index ef43cf8f..4309697b 100644
--- a/src/renderers/ts/__tests__/renderer.test.ts
+++ b/src/renderers/ts/__tests__/renderer.test.ts
@@ -26,6 +26,7 @@ describe('TSRendererImpl', () => {
{ type: 'alias', modelName: 'Active', modelType: { type: 'boolean' }, modelDocs: null },
{ type: 'alias', modelName: 'Age', modelType: { type: 'number' }, modelDocs: null },
{ type: 'alias', modelName: 'CreatedAt', modelType: { type: 'timestamp' }, modelDocs: null },
+ { type: 'alias', modelName: 'Avatar', modelType: { type: 'bytes' }, modelDocs: null },
{ type: 'alias', modelName: 'TheAnswer', modelType: { type: 'literal', value: 42 }, modelDocs: null },
{
type: 'alias',
@@ -162,4 +163,21 @@ describe('TSRendererImpl', () => {
}
}
});
+
+ it('renders bytes using the Firestore byte type for each target family', async () => {
+ const generation: TSGeneration = {
+ type: 'ts',
+ declarations: [{ type: 'alias', modelName: 'Payload', modelType: { type: 'bytes' }, modelDocs: null }],
+ };
+
+ await expect((await createRenderer({ target: 'firebase-admin@13' }).render(generation)).content).toContain(
+ 'export type Payload = Buffer;'
+ );
+ await expect((await createRenderer({ target: 'firebase@10' }).render(generation)).content).toContain(
+ 'export type Payload = firestore.Bytes;'
+ );
+ await expect((await createRenderer({ target: 'react-native-firebase@21' }).render(generation)).content).toContain(
+ 'export type Payload = firestore.Blob;'
+ );
+ });
});
diff --git a/src/renderers/ts/_impl.ts b/src/renderers/ts/_impl.ts
index 5a3af7aa..ede94988 100644
--- a/src/renderers/ts/_impl.ts
+++ b/src/renderers/ts/_impl.ts
@@ -41,7 +41,7 @@ class TSRendererImpl implements TSRenderer {
switch (declaration.type) {
case 'alias': {
const { modelName, modelType, modelDocs } = declaration;
- const expression = ts.expressionForType(modelType);
+ const expression = ts.expressionForType(modelType, { target: this.config.target });
if (modelDocs !== null) {
output += `/** ${modelDocs} */\n`;
}
@@ -50,7 +50,7 @@ class TSRendererImpl implements TSRenderer {
}
case 'interface': {
const { modelName, modelType, modelDocs } = declaration;
- const expression = ts.expressionForType(modelType);
+ const expression = ts.expressionForType(modelType, { target: this.config.target });
if (modelDocs !== null) {
output += `/** ${modelDocs} */\n`;
}
diff --git a/src/schema/core/__tests__/validate-type.test.ts b/src/schema/core/__tests__/validate-type.test.ts
index f341c01a..b8d8e8bb 100644
--- a/src/schema/core/__tests__/validate-type.test.ts
+++ b/src/schema/core/__tests__/validate-type.test.ts
@@ -66,6 +66,14 @@ describe('schema.validateType()', () => {
});
});
+ describe('bytes', () => {
+ it('does not throw if the type is valid', () => {
+ const schema = createSchema();
+ const t: types.Bytes = { type: 'bytes' };
+ expect(() => schema.validateType(t)).not.toThrow();
+ });
+ });
+
describe('string-literal', () => {
it('does not throw if the type is valid', () => {
const schema = createSchema();
diff --git a/src/schema/core/_zod-schemas.ts b/src/schema/core/_zod-schemas.ts
index 792060a3..f91bed2d 100644
--- a/src/schema/core/_zod-schemas.ts
+++ b/src/schema/core/_zod-schemas.ts
@@ -55,6 +55,12 @@ export function createZodSchemasForSchema(schema: Schema) {
})
.strict();
+ const bytesType = z
+ .object({
+ type: z.literal('bytes'),
+ })
+ .strict();
+
const primitiveType = anyType
.or(unknownType)
.or(nilType)
@@ -62,7 +68,8 @@ export function createZodSchemasForSchema(schema: Schema) {
.or(booleanType)
.or(intType)
.or(doubleType)
- .or(timestampType);
+ .or(timestampType)
+ .or(bytesType);
const stringLiteralType = z
.object({
@@ -381,6 +388,7 @@ export function createZodSchemasForSchema(schema: Schema) {
intType,
doubleType,
timestampType,
+ bytesType,
primitiveType,
stringLiteralType,
intLiteralType,
diff --git a/src/schema/core/types.ts b/src/schema/core/types.ts
index 9c2bd2d6..97e70c35 100644
--- a/src/schema/core/types.ts
+++ b/src/schema/core/types.ts
@@ -21,6 +21,7 @@ export {
Int,
Double,
Timestamp,
+ Bytes,
Primitive,
StringLiteral,
IntLiteral,
diff --git a/src/schema/generic.ts b/src/schema/generic.ts
index f732b298..e08116eb 100644
--- a/src/schema/generic.ts
+++ b/src/schema/generic.ts
@@ -34,7 +34,11 @@ export interface Timestamp {
type: 'timestamp';
}
-export type Primitive = Any | Unknown | Nil | String | Boolean | Int | Double | Timestamp;
+export interface Bytes {
+ type: 'bytes';
+}
+
+export type Primitive = Any | Unknown | Nil | String | Boolean | Int | Double | Timestamp | Bytes;
export interface StringLiteral {
type: 'string-literal';
diff --git a/src/schema/python/types.ts b/src/schema/python/types.ts
index 776ca6ed..677d9523 100644
--- a/src/schema/python/types.ts
+++ b/src/schema/python/types.ts
@@ -20,6 +20,7 @@ export {
Int,
Double,
Timestamp,
+ Bytes,
Primitive,
StringLiteral,
IntLiteral,
diff --git a/src/schema/rules/types.ts b/src/schema/rules/types.ts
index 9c2bd2d6..97e70c35 100644
--- a/src/schema/rules/types.ts
+++ b/src/schema/rules/types.ts
@@ -21,6 +21,7 @@ export {
Int,
Double,
Timestamp,
+ Bytes,
Primitive,
StringLiteral,
IntLiteral,
diff --git a/src/schema/swift/types.ts b/src/schema/swift/types.ts
index 40bd9b68..7e2686d1 100644
--- a/src/schema/swift/types.ts
+++ b/src/schema/swift/types.ts
@@ -20,6 +20,7 @@ export {
Int,
Double,
Timestamp,
+ Bytes,
Primitive,
StringLiteral,
IntLiteral,
diff --git a/src/schema/ts/types.ts b/src/schema/ts/types.ts
index 9c2bd2d6..97e70c35 100644
--- a/src/schema/ts/types.ts
+++ b/src/schema/ts/types.ts
@@ -21,6 +21,7 @@ export {
Int,
Double,
Timestamp,
+ Bytes,
Primitive,
StringLiteral,
IntLiteral,
diff --git a/tests/integration/_fixtures/samples/secrets/api-key.json b/tests/integration/_fixtures/samples/secrets/api-key.json
new file mode 100644
index 00000000..8d5a3f4c
--- /dev/null
+++ b/tests/integration/_fixtures/samples/secrets/api-key.json
@@ -0,0 +1,11 @@
+{
+ "label": "primary-api-key",
+ "payload_base64": "SGVsbG8sIHdvcmxkIQ==",
+ "checksum_base64": "MV9b23bQeMQ7isAGTkoBZGErH853yGk0W/yUx1iU7dM=",
+ "shards_base64": [
+ "c2hhcmQtMQ==",
+ "c2hhcmQtMg==",
+ "AAECAwQF"
+ ],
+ "created_at": "2024-05-09T10:00:00.000Z"
+}
diff --git a/tests/integration/_fixtures/schemas/secrets.yml b/tests/integration/_fixtures/schemas/secrets.yml
new file mode 100644
index 00000000..c706085c
--- /dev/null
+++ b/tests/integration/_fixtures/schemas/secrets.yml
@@ -0,0 +1,41 @@
+# yaml-language-server: $schema=../../../../schema.local.json
+#
+# Shared integration-test fixture for the `bytes` primitive. Each platform
+# consumes it differently because each Firestore SDK represents bytes with
+# a platform-native type:
+#
+# - TypeScript (firebase-admin@13): Buffer
+# - TypeScript (firebase@10, web): firestore.Bytes
+# - Python (firebase-admin@6): bytes
+# - Swift (firebase@10): Data
+#
+# The schema deliberately mixes a top-level bytes field, a second bytes
+# field with different content, a list of bytes (so we exercise nested
+# bytes in collection types), a sibling string and a timestamp. That is
+# enough to confirm bytes round-trip alongside other primitives without
+# accidental coercion (e.g. to base64 strings) on either the encode or
+# decode side.
+
+Secret:
+ model: document
+ path: secrets/{secretId}
+ docs: A document storing opaque binary material alongside metadata.
+ type:
+ type: object
+ fields:
+ label:
+ type: string
+ docs: Human-readable label for the secret.
+ payload:
+ type: bytes
+ docs: Opaque binary blob (encrypted material, key bytes, etc.).
+ checksum:
+ type: bytes
+ docs: A second bytes field, e.g. a SHA-256 digest of the payload.
+ shards:
+ type:
+ type: list
+ elementType: bytes
+ docs: Additional bytes blobs stored as a list to exercise bytes-in-list.
+ created_at:
+ type: timestamp
diff --git a/tests/integration/python/tests/test_secrets.py b/tests/integration/python/tests/test_secrets.py
new file mode 100644
index 00000000..d127276e
--- /dev/null
+++ b/tests/integration/python/tests/test_secrets.py
@@ -0,0 +1,116 @@
+"""Round-trip tests for the `secrets` fixture.
+
+Verifies that the `bytes` primitive emitted by the Python generator
+(`bytes` / `typing.List[bytes]`) round-trips correctly through the
+Firestore emulator using the official `google-cloud-firestore` client,
+which is what `firebase-admin` uses underneath. The Firestore Python
+client maps Python `bytes` directly to the protobuf `bytes_value` and
+decodes them back into Python `bytes` on read.
+"""
+
+from __future__ import annotations
+
+import base64
+import json
+import uuid
+from pathlib import Path
+
+import pytest
+from google.cloud import firestore
+
+
+def _load_sample(fixtures_root: Path, name: str) -> dict:
+ return json.loads((fixtures_root / "samples" / "secrets" / f"{name}.json").read_text())
+
+
+@pytest.fixture
+def secrets_module(import_generated_module):
+ return import_generated_module("secrets")
+
+
+def test_secret_round_trips_bytes_via_firestore_emulator(
+ secrets_module,
+ fixtures_root: Path,
+ firestore_client: firestore.Client,
+ isolated_collection: firestore.CollectionReference,
+) -> None:
+ """A document with multiple `bytes` fields (including a list of bytes)
+ survives a Pydantic-validate -> emulator-write -> emulator-read ->
+ Pydantic-validate cycle byte-for-byte."""
+
+ Secret = secrets_module.Secret
+
+ sample = _load_sample(fixtures_root, "api-key")
+ payload = base64.b64decode(sample["payload_base64"])
+ checksum = base64.b64decode(sample["checksum_base64"])
+ shards = [base64.b64decode(s) for s in sample["shards_base64"]]
+
+ # Sanity-check the fixture itself: a buggy generator that aliased
+ # every bytes field to the same value would still pass otherwise.
+ assert len(payload) > 0
+ assert len(checksum) == 32
+ assert payload != checksum
+ assert len(shards) == 3
+
+ secret_in = Secret.model_validate(
+ {
+ "label": sample["label"],
+ "payload": payload,
+ "checksum": checksum,
+ "shards": shards,
+ "created_at": sample["created_at"],
+ }
+ )
+
+ # The generator emits Pydantic types that store bytes verbatim; check
+ # that nothing has been auto-coerced (e.g. to a base64 string) before
+ # we even reach Firestore.
+ assert isinstance(secret_in.payload, bytes)
+ assert isinstance(secret_in.checksum, bytes)
+ assert all(isinstance(s, bytes) for s in secret_in.shards)
+ assert secret_in.payload == payload
+ assert secret_in.checksum == checksum
+
+ doc_ref = isolated_collection.document(uuid.uuid4().hex)
+ doc_ref.set(secret_in.model_dump())
+
+ snapshot = doc_ref.get()
+ assert snapshot.exists, "expected the written document to be readable"
+
+ raw = snapshot.to_dict()
+ # Wire-level expectations: the Firestore Python client returns bytes
+ # as plain `bytes` for both top-level fields and entries nested
+ # inside a list.
+ assert isinstance(raw["payload"], bytes)
+ assert isinstance(raw["checksum"], bytes)
+ assert isinstance(raw["shards"], list)
+ assert all(isinstance(s, bytes) for s in raw["shards"])
+
+ # Re-validate through the generated Pydantic model to confirm the
+ # generated schema round-trips without rejecting plain `bytes`.
+ secret_out = Secret.model_validate(raw)
+ assert secret_out.label == sample["label"]
+ assert secret_out.payload == payload
+ assert secret_out.checksum == checksum
+ assert secret_out.shards == shards
+
+
+def test_secret_rejects_non_bytes_payload(secrets_module) -> None:
+ """The generated Pydantic class should reject obvious type mismatches
+ on the bytes-typed fields (e.g. a dict where bytes are expected).
+ Pydantic accepts `str` as input for `bytes` (encoding it as UTF-8),
+ so we use a non-string non-bytes value to make the rejection
+ unambiguous."""
+
+ Secret = secrets_module.Secret
+
+ with pytest.raises(Exception):
+ Secret.model_validate(
+ {
+ "label": "x",
+ "payload": {"not": "bytes"},
+ "checksum": b"\x00",
+ "shards": [],
+ "created_at": "2024-01-01T00:00:00.000Z",
+ }
+ )
diff --git a/tests/integration/swift/Tests/TypesyncIntegrationTests/SecretsIntegrationTests.swift b/tests/integration/swift/Tests/TypesyncIntegrationTests/SecretsIntegrationTests.swift
new file mode 100644
index 00000000..663e1b74
--- /dev/null
+++ b/tests/integration/swift/Tests/TypesyncIntegrationTests/SecretsIntegrationTests.swift
@@ -0,0 +1,116 @@
+import FirebaseFirestore
+import Foundation
+import Testing
+
+@testable import TypesyncIntegration
+
+// Round-trips a `bytes`-typed document through the Firestore emulator using
+// the **firebase-ios-sdk** Codable bridge. The Swift generator emits
+// `Foundation.Data` for each `bytes` field, which is what the Firebase iOS
+// SDK uses to represent Firestore's `bytes` value type when encoding /
+// decoding via `Firestore.Encoder` / `Firestore.Decoder`.
+//
+// We assert both the wire-level and Codable-level shapes:
+//
+// 1. `setData(from:)` accepts a `Secret` whose bytes-typed fields are
+// `Data` values without coercion.
+// 2. The raw snapshot (`snapshot.data()`) exposes those fields as
+// `Data` (the Objective-C bridge from `NSData`), including bytes
+// nested inside a list.
+// 3. `snapshot.data(as: Secret.self)` rebuilds an equivalent `Secret`
+// whose `Data` payloads are byte-for-byte identical to the input.
+
+@Suite("Secrets bytes round-trip via emulator")
+struct SecretsIntegrationTests {
+ @Test("a Secret with multiple Data fields (including [Data]) round-trips through the emulator with byte-for-byte preservation")
+ func secretRoundTripsThroughEmulator() async throws {
+ let firestore = EmulatorClient.firestore()
+ let collection = firestore.collection(uniqueCollectionName())
+
+ let sample = try Fixtures.loadSampleAsDict(scenario: "secrets", name: "api-key")
+ let payload = try decodeBase64(try unwrap(sample["payload_base64"] as? String, "secrets/api-key.payload_base64"))
+ let checksum = try decodeBase64(try unwrap(sample["checksum_base64"] as? String, "secrets/api-key.checksum_base64"))
+ let shardsBase64 = try unwrap(sample["shards_base64"] as? [String], "secrets/api-key.shards_base64")
+ let shards = try shardsBase64.map { try decodeBase64($0) }
+
+ // Sanity-check the fixture: a buggy generator that aliased all
+ // bytes fields to the same `Data` value would still pass
+ // otherwise.
+ #expect(!payload.isEmpty)
+ #expect(checksum.count == 32)
+ #expect(payload != checksum)
+ #expect(shards.count == 3)
+
+ let secretIn = Secret(
+ label: try unwrap(sample["label"] as? String, "secrets/api-key.label"),
+ payload: payload,
+ checksum: checksum,
+ shards: shards,
+ createdAt: try parseISO8601(try unwrap(sample["created_at"] as? String, "secrets/api-key.created_at"))
+ )
+
+ let docRef = collection.document(UUID().uuidString)
+ try docRef.setData(from: secretIn)
+
+ let snapshot = try await docRef.getDocument()
+ try #require(snapshot.exists)
+ let raw = try #require(snapshot.data(), "expected non-empty document data")
+
+ // Wire-level expectations: the iOS SDK exposes bytes through
+ // Foundation.Data on the snapshot (which bridges NSData), both
+ // for top-level fields and for entries nested inside a list.
+ #expect(raw["payload"] is Data)
+ #expect(raw["checksum"] is Data)
+ let rawShards = try #require(raw["shards"] as? [Data], "shards should decode as [Data]")
+ #expect(rawShards.count == shards.count)
+
+ let secretOut = try snapshot.data(as: Secret.self)
+
+ #expect(secretOut.label == secretIn.label)
+ #expect(secretOut.payload == payload)
+ #expect(secretOut.checksum == checksum)
+ #expect(secretOut.shards.count == shards.count)
+ for (i, shard) in secretOut.shards.enumerated() {
+ #expect(shard == shards[i])
+ }
+ #expect(
+ abs(secretOut.createdAt.timeIntervalSince1970 - secretIn.createdAt.timeIntervalSince1970) < 0.001,
+ "timestamp should round-trip through Firestore within ms precision"
+ )
+ }
+}
+
+private func uniqueCollectionName() -> String {
+ "test_" + UUID().uuidString.replacingOccurrences(of: "-", with: "")
+}
+
+private struct FixtureError: Error, CustomStringConvertible {
+ let description: String
+}
+
+private func unwrap(_ value: T?, _ name: String) throws -> T {
+ guard let value else {
+ throw FixtureError(description: "Missing or wrongly-typed fixture field '\(name)'.")
+ }
+ return value
+}
+
+private func decodeBase64(_ raw: String) throws -> Data {
+ guard let data = Data(base64Encoded: raw) else {
+ throw FixtureError(description: "Invalid base64 string: \(raw)")
+ }
+ return data
+}
+
+private func parseISO8601(_ raw: String) throws -> Date {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ if let date = formatter.date(from: raw) {
+ return date
+ }
+ formatter.formatOptions = [.withInternetDateTime]
+ if let date = formatter.date(from: raw) {
+ return date
+ }
+ throw FixtureError(description: "Invalid ISO-8601 date: \(raw)")
+}
diff --git a/tests/integration/typescript/generated/web/secrets.ts b/tests/integration/typescript/generated/web/secrets.ts
new file mode 100644
index 00000000..cd06c1d5
--- /dev/null
+++ b/tests/integration/typescript/generated/web/secrets.ts
@@ -0,0 +1,14 @@
+import type * as firestore from 'firebase/firestore';
+
+/** A document storing opaque binary material alongside metadata. */
+export interface Secret {
+ /** Human-readable label for the secret. */
+ label: string;
+ /** Opaque binary blob (encrypted material, key bytes, etc.). */
+ payload: firestore.Bytes;
+ /** A second bytes field, e.g. a SHA-256 digest of the payload. */
+ checksum: firestore.Bytes;
+ /** Additional bytes blobs stored as a list to exercise bytes-in-list. */
+ shards: firestore.Bytes[];
+ created_at: firestore.Timestamp;
+}
diff --git a/tests/integration/typescript/tests/secrets.admin.test.ts b/tests/integration/typescript/tests/secrets.admin.test.ts
new file mode 100644
index 00000000..9eb2c3c7
--- /dev/null
+++ b/tests/integration/typescript/tests/secrets.admin.test.ts
@@ -0,0 +1,127 @@
+import { type App, initializeApp } from 'firebase-admin/app';
+import { type Firestore, Timestamp, getFirestore } from 'firebase-admin/firestore';
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+
+import type { Secret } from '../generated/secrets.js';
+
+// Round-trips a `bytes`-typed document through the Firestore emulator using
+// the **firebase-admin@13** SDK. The admin SDK represents bytes as Node.js
+// `Buffer`s, which is the type our generator emits for the
+// `firebase-admin@*` targets. This test asserts that:
+//
+// 1. A typed `Secret` constructed with `Buffer` values can be written to
+// the emulator without coercion.
+// 2. Reading the same document back yields `Buffer`-shaped bytes.
+// 3. The byte contents are preserved exactly (no UTF-8 round-tripping,
+// no base64 mangling, no truncation), including a `bytes` field
+// nested inside a list (`shards`).
+
+const FIXTURES_ROOT = resolve(__dirname, '../../_fixtures');
+
+interface SecretSample {
+ label: string;
+ payload_base64: string;
+ checksum_base64: string;
+ shards_base64: string[];
+ created_at: string;
+}
+
+function loadSample(scenario: string, name: string): SecretSample {
+ const samplePath = resolve(FIXTURES_ROOT, 'samples', scenario, `${name}.json`);
+ return JSON.parse(readFileSync(samplePath, 'utf8')) as SecretSample;
+}
+
+function ensureEmulatorEnv(): void {
+ if (!process.env.FIRESTORE_EMULATOR_HOST) {
+ throw new Error('FIRESTORE_EMULATOR_HOST is not set. Run via `yarn test:integration:typescript`.');
+ }
+ process.env.GOOGLE_CLOUD_PROJECT ??= 'demo-integration';
+ process.env.GCLOUD_PROJECT ??= process.env.GOOGLE_CLOUD_PROJECT;
+}
+
+/**
+ * Byte-level equality between any two byte views (Node `Buffer` or
+ * plain `Uint8Array`). Avoids `Buffer.from(view)` so we don't have to
+ * reconcile `Buffer` vs `Uint8Array` under strict TS.
+ */
+function bytesEqual(a: Buffer | Uint8Array, b: Buffer | Uint8Array): boolean {
+ if (a.byteLength !== b.byteLength) return false;
+ for (let i = 0; i < a.byteLength; i += 1) {
+ if (a[i] !== b[i]) return false;
+ }
+ return true;
+}
+
+describe('Secrets bytes round-trip (firebase-admin@13)', () => {
+ let app: App;
+ let firestore: Firestore;
+
+ beforeAll(() => {
+ ensureEmulatorEnv();
+ app = initializeApp({ projectId: process.env.GOOGLE_CLOUD_PROJECT }, 'secrets-admin-test-app');
+ firestore = getFirestore(app);
+ });
+
+ afterAll(async () => {
+ await firestore.terminate();
+ });
+
+ it('round-trips a typed Secret document with Buffer-valued bytes fields through the emulator', async () => {
+ const sample = loadSample('secrets', 'api-key');
+
+ const payloadBuf = Buffer.from(sample.payload_base64, 'base64');
+ const checksumBuf = Buffer.from(sample.checksum_base64, 'base64');
+ const shardsBufs = sample.shards_base64.map(s => Buffer.from(s, 'base64'));
+
+ // Sanity-check the test fixture itself: the base64 strings must decode
+ // to non-empty bytes that are *not* coincidentally equal to each other,
+ // otherwise an SDK that quietly aliased all bytes fields would still
+ // pass.
+ expect(payloadBuf.length).toBeGreaterThan(0);
+ expect(checksumBuf.length).toBe(32);
+ expect(shardsBufs.length).toBe(3);
+ expect(bytesEqual(payloadBuf, checksumBuf)).toBe(false);
+
+ const secretIn: Secret = {
+ label: sample.label,
+ payload: payloadBuf,
+ checksum: checksumBuf,
+ shards: shardsBufs,
+ created_at: Timestamp.fromDate(new Date(sample.created_at)),
+ };
+
+ const collection = firestore.collection(`test_${crypto.randomUUID().replaceAll('-', '')}`);
+ const docRef = collection.doc(crypto.randomUUID());
+ await docRef.set(secretIn);
+
+ const snapshot = await docRef.get();
+ expect(snapshot.exists).toBe(true);
+
+ const raw = snapshot.data() as Record;
+ // Wire-level expectations: the admin SDK returns bytes as Buffer
+ // (which is a subclass of Uint8Array). We assert against the wider
+ // Uint8Array contract so the test is robust against a future SDK that
+ // might switch to plain typed arrays.
+ expect(raw.payload).toBeInstanceOf(Uint8Array);
+ expect(raw.checksum).toBeInstanceOf(Uint8Array);
+ expect(Array.isArray(raw.shards)).toBe(true);
+ for (const s of raw.shards as unknown[]) {
+ expect(s).toBeInstanceOf(Uint8Array);
+ }
+
+ const secretOut = snapshot.data() as Secret;
+
+ expect(secretOut.label).toBe(secretIn.label);
+ expect(bytesEqual(secretOut.payload, payloadBuf)).toBe(true);
+ expect(bytesEqual(secretOut.checksum, checksumBuf)).toBe(true);
+ expect(secretOut.shards.length).toBe(shardsBufs.length);
+ secretOut.shards.forEach((shard, i) => {
+ const expected = shardsBufs[i];
+ expect(expected).toBeDefined();
+ expect(bytesEqual(shard, expected!)).toBe(true);
+ });
+ expect(secretOut.created_at.toMillis()).toBe(secretIn.created_at.toMillis());
+ });
+});
diff --git a/tests/integration/typescript/tests/secrets.web.test.ts b/tests/integration/typescript/tests/secrets.web.test.ts
new file mode 100644
index 00000000..4d05c5c5
--- /dev/null
+++ b/tests/integration/typescript/tests/secrets.web.test.ts
@@ -0,0 +1,153 @@
+import { type FirebaseApp, initializeApp as initializeWebApp } from 'firebase/app';
+import {
+ Bytes,
+ type Firestore,
+ Timestamp,
+ connectFirestoreEmulator,
+ doc,
+ getDoc,
+ getFirestore,
+ setDoc,
+ terminate,
+} from 'firebase/firestore';
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+
+import type { Secret } from '../generated/web/secrets.js';
+
+// Round-trips a `bytes`-typed document through the Firestore emulator using
+// the **firebase@10 web SDK**. Where the admin SDK uses `Buffer`, the web
+// SDK exposes bytes through the immutable `Bytes` value type. Our generator
+// emits `firestore.Bytes` for the `firebase@*` targets, and this test
+// proves that the generated type is the right one in practice:
+//
+// 1. A typed `Secret` constructed with `Bytes.fromUint8Array(...)`
+// values is accepted by `setDoc` without coercion.
+// 2. Reading back with `getDoc` returns `Bytes` instances for every
+// bytes-typed field, including bytes nested inside a list.
+// 3. The byte contents are preserved exactly when round-tripped through
+// base64.
+//
+// Confirming both the admin (`Buffer`) and web (`Bytes`) shapes via the
+// emulator is the integration-level proof that the cross-target plumbing
+// for the `bytes` primitive really works.
+
+const FIXTURES_ROOT = resolve(__dirname, '../../_fixtures');
+
+interface SecretSample {
+ label: string;
+ payload_base64: string;
+ checksum_base64: string;
+ shards_base64: string[];
+ created_at: string;
+}
+
+function loadSample(scenario: string, name: string): SecretSample {
+ const samplePath = resolve(FIXTURES_ROOT, 'samples', scenario, `${name}.json`);
+ return JSON.parse(readFileSync(samplePath, 'utf8')) as SecretSample;
+}
+
+function ensureEmulatorEnv(): { host: string; port: number } {
+ const raw = process.env.FIRESTORE_EMULATOR_HOST;
+ if (!raw) {
+ throw new Error('FIRESTORE_EMULATOR_HOST is not set. Run via `yarn test:integration:typescript`.');
+ }
+ // FIRESTORE_EMULATOR_HOST is conventionally `host:port`, e.g.
+ // `localhost:8080`. The web SDK takes them as separate args.
+ const lastColon = raw.lastIndexOf(':');
+ if (lastColon < 0) {
+ throw new Error(`FIRESTORE_EMULATOR_HOST=${raw} is not in host:port form`);
+ }
+ const host = raw.slice(0, lastColon);
+ const port = Number(raw.slice(lastColon + 1));
+ if (!Number.isFinite(port) || port <= 0) {
+ throw new Error(`FIRESTORE_EMULATOR_HOST=${raw} has an invalid port`);
+ }
+ process.env.GOOGLE_CLOUD_PROJECT ??= 'demo-integration';
+ return { host, port };
+}
+
+function bytesToHex(bytes: Uint8Array): string {
+ return Array.from(bytes)
+ .map(b => b.toString(16).padStart(2, '0'))
+ .join('');
+}
+
+describe('Secrets bytes round-trip (firebase@10 web SDK)', () => {
+ let app: FirebaseApp;
+ let firestore: Firestore;
+
+ beforeAll(() => {
+ const { host, port } = ensureEmulatorEnv();
+ app = initializeWebApp(
+ {
+ // Placeholder credentials. The emulator does not validate them
+ // and we never reach a real Firebase project.
+ apiKey: 'fake-api-key',
+ projectId: process.env.GOOGLE_CLOUD_PROJECT,
+ },
+ 'secrets-web-test-app'
+ );
+ firestore = getFirestore(app);
+ connectFirestoreEmulator(firestore, host, port);
+ });
+
+ afterAll(async () => {
+ await terminate(firestore);
+ });
+
+ it('round-trips a typed Secret document with firestore.Bytes values through the emulator', async () => {
+ const sample = loadSample('secrets', 'api-key');
+
+ const payloadBytes = Bytes.fromBase64String(sample.payload_base64);
+ const checksumBytes = Bytes.fromBase64String(sample.checksum_base64);
+ const shardsBytes = sample.shards_base64.map(s => Bytes.fromBase64String(s));
+
+ // Sanity-check the fixture so a buggy `Bytes` implementation that
+ // collapsed every input to the same value would still be caught.
+ expect(payloadBytes.toBase64()).toBe(sample.payload_base64);
+ expect(checksumBytes.toBase64()).toBe(sample.checksum_base64);
+ expect(payloadBytes.isEqual(checksumBytes)).toBe(false);
+
+ const secretIn: Secret = {
+ label: sample.label,
+ payload: payloadBytes,
+ checksum: checksumBytes,
+ shards: shardsBytes,
+ created_at: Timestamp.fromDate(new Date(sample.created_at)),
+ };
+
+ const collection = `test_${crypto.randomUUID().replaceAll('-', '')}`;
+ const docRef = doc(firestore, collection, crypto.randomUUID());
+ await setDoc(docRef, secretIn);
+
+ const snapshot = await getDoc(docRef);
+ expect(snapshot.exists()).toBe(true);
+
+ const raw = snapshot.data() as Record;
+ // Wire-level expectations: the web SDK exposes bytes as the `Bytes`
+ // value type, including for entries nested inside a list.
+ expect(raw.payload).toBeInstanceOf(Bytes);
+ expect(raw.checksum).toBeInstanceOf(Bytes);
+ expect(Array.isArray(raw.shards)).toBe(true);
+ for (const s of raw.shards as unknown[]) {
+ expect(s).toBeInstanceOf(Bytes);
+ }
+
+ const secretOut = snapshot.data() as Secret;
+
+ expect(secretOut.label).toBe(secretIn.label);
+ expect(secretOut.payload.isEqual(payloadBytes)).toBe(true);
+ expect(secretOut.checksum.isEqual(checksumBytes)).toBe(true);
+ expect(secretOut.shards.length).toBe(shardsBytes.length);
+ secretOut.shards.forEach((shard, i) => {
+ const expected = shardsBytes[i];
+ expect(expected).toBeDefined();
+ expect(shard.isEqual(expected!)).toBe(true);
+ // And the underlying byte contents should match the source fixture.
+ expect(bytesToHex(shard.toUint8Array())).toBe(bytesToHex(expected!.toUint8Array()));
+ });
+ expect(secretOut.created_at.toMillis()).toBe(secretIn.created_at.toMillis());
+ });
+});
diff --git a/tests/security/definition.yml b/tests/security/definition.yml
index f0257087..df34b207 100644
--- a/tests/security/definition.yml
+++ b/tests/security/definition.yml
@@ -94,6 +94,19 @@ WorkspaceInfo:
description:
type: string
+Secret:
+ model: document
+ path: secrets/{secretId}
+ type:
+ type: object
+ fields:
+ label:
+ type: string
+ payload:
+ type: bytes
+ checksum:
+ type: bytes
+
Workspace:
model: document
path: workspaces/{workspaceId}
diff --git a/tests/security/firestore.rules b/tests/security/firestore.rules
index 758505d1..01b9e5f5 100644
--- a/tests/security/firestore.rules
+++ b/tests/security/firestore.rules
@@ -44,6 +44,16 @@ service cloud.firestore {
);
}
+ function isValidSecret(data) {
+ return (
+ (data is map) &&
+ (data.keys().hasOnly(['label', 'payload', 'checksum'])) &&
+ (data.label is string) &&
+ (data.payload is bytes) &&
+ (data.checksum is bytes)
+ );
+ }
+
function isValidUser(data) {
return (
(data is map) &&
@@ -93,5 +103,12 @@ service cloud.firestore {
match /projects/{projectId} {
allow read, create, update, delete;
}
+
+ match /secrets/{secretId} {
+ allow read;
+ allow create: if isValidSecret(request.resource.data);
+ allow update: if isValidSecret(request.resource.data);
+ allow delete: if false;
+ }
}
}
\ No newline at end of file
diff --git a/tests/security/rules.test.ts b/tests/security/rules.test.ts
index 22e2df88..63619b02 100644
--- a/tests/security/rules.test.ts
+++ b/tests/security/rules.test.ts
@@ -4,7 +4,7 @@ import {
assertSucceeds,
initializeTestEnvironment,
} from '@firebase/rules-unit-testing';
-import { doc, setDoc, updateDoc } from 'firebase/firestore';
+import { Bytes, doc, getDoc, setDoc, updateDoc } from 'firebase/firestore';
import { readFileSync } from 'fs';
import { resolve } from 'path';
@@ -87,6 +87,80 @@ describe('Security Rules', () => {
).resolves.toBeDefined();
});
+ // The /secrets/* collection exercises the `bytes` primitive end-to-end:
+ // the rules generator should emit `data. is bytes`, and the
+ // emulator should evaluate that predicate against `firestore.Bytes`
+ // values written from the web SDK. These tests prove both halves at
+ // runtime and not just via snapshot-of-generator-output.
+ describe('bytes', () => {
+ const secretId = 'secret123';
+ const secretDocPath = `/secrets/${secretId}`;
+
+ it('allows create when bytes-typed fields are sent as firestore.Bytes', async () => {
+ const ctx = testEnv.unauthenticatedContext();
+ const ref = doc(ctx.firestore(), secretDocPath);
+ await expect(
+ assertSucceeds(
+ setDoc(ref, {
+ label: 'primary',
+ payload: Bytes.fromUint8Array(new Uint8Array([0x01, 0x02, 0x03, 0x04])),
+ checksum: Bytes.fromBase64String('MV9b23bQeMQ7isAGTkoBZGErH853yGk0W/yUx1iU7dM='),
+ })
+ )
+ ).resolves.toBeUndefined();
+
+ // And the document is readable back as bytes (sanity-check that
+ // the emulator did not reject silently or coerce to a string).
+ const snapshot = await getDoc(ref);
+ expect(snapshot.exists()).toBe(true);
+ const raw = snapshot.data() as Record;
+ expect(raw.payload).toBeInstanceOf(Bytes);
+ expect(raw.checksum).toBeInstanceOf(Bytes);
+ });
+
+ it('blocks create when a bytes-typed field is sent as a string', async () => {
+ const ctx = testEnv.unauthenticatedContext();
+ const ref = doc(ctx.firestore(), secretDocPath);
+ await expect(
+ assertFails(
+ setDoc(ref, {
+ label: 'primary',
+ payload: 'not-bytes-just-a-string',
+ checksum: Bytes.fromUint8Array(new Uint8Array(32)),
+ })
+ )
+ ).resolves.toBeDefined();
+ });
+
+ it('blocks create when a bytes-typed field is sent as a number', async () => {
+ const ctx = testEnv.unauthenticatedContext();
+ const ref = doc(ctx.firestore(), secretDocPath);
+ await expect(
+ assertFails(
+ setDoc(ref, {
+ label: 'primary',
+ payload: Bytes.fromUint8Array(new Uint8Array([0xff])),
+ checksum: 12345,
+ })
+ )
+ ).resolves.toBeDefined();
+ });
+
+ it('blocks create when a bytes-typed field is missing entirely', async () => {
+ const ctx = testEnv.unauthenticatedContext();
+ const ref = doc(ctx.firestore(), secretDocPath);
+ await expect(
+ assertFails(
+ setDoc(ref, {
+ label: 'primary',
+ payload: Bytes.fromUint8Array(new Uint8Array([0xff])),
+ // checksum: missing
+ })
+ )
+ ).resolves.toBeDefined();
+ });
+ });
+
afterEach(async () => {
await testEnv.clearFirestore();
});