diff --git a/handwritten/firestore/api-report/firestore.api.md b/handwritten/firestore/api-report/firestore.api.md index f2b29d88f992..aea5fb00ba1a 100644 --- a/handwritten/firestore/api-report/firestore.api.md +++ b/handwritten/firestore/api-report/firestore.api.md @@ -10,7 +10,8 @@ import { google } from '../protos/firestore_v1_proto_api'; import { google as google_2 } from '../../protos/firestore_v1_proto_api'; import { GoogleError } from 'google-gax'; import * as proto from '../protos/firestore_v1_proto_api'; -import * as protos from '../../protos/firestore_v1_proto_api'; +import { protos } from '@google-cloud/firestore-api'; +import * as protos_2 from '../../protos/firestore_v1_proto_api'; import { Readable } from 'stream'; import { Span as Span_2 } from '@opentelemetry/api'; @@ -1414,6 +1415,16 @@ export class FieldValue implements firestore.FieldValue { // Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag // Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" isEqual(other: firestore.FieldValue): boolean; + // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' + // Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag + // Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" + // Warning: (tsdoc-undefined-tag) The TSDoc tag "@return" is not defined in this configuration + static maximum(n: number): FieldValue; + // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' + // Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag + // Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" + // Warning: (tsdoc-undefined-tag) The TSDoc tag "@return" is not defined in this configuration + static minimum(n: number): FieldValue; // Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag // Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" static serverTimestamp(): FieldValue; @@ -2333,6 +2344,8 @@ function pow(base: string, exponent: Expression): FunctionExpression; // @public function pow(base: string, exponent: number): FunctionExpression; +export { protos } + // Warning: (tsdoc-undefined-tag) The TSDoc tag "@class" is not defined in this configuration // // @public @@ -2492,7 +2505,7 @@ export class Query).Instant === 'function' + ) { + const instantCtor = (Temporal as Record).Instant as new ( + ...args: unknown[] + ) => unknown; + if (value instanceof instantCtor) { + return true; + } + } + const instant = value as Partial; + return ( + instant[Symbol.toStringTag] === 'Temporal.Instant' && + typeof instant.epochNanoseconds === 'bigint' + ); +} + /** * Returns true if value is a MomentJs date object. * @private diff --git a/handwritten/firestore/dev/src/temporal.d.ts b/handwritten/firestore/dev/src/temporal.d.ts new file mode 100644 index 000000000000..a8491dcca9aa --- /dev/null +++ b/handwritten/firestore/dev/src/temporal.d.ts @@ -0,0 +1,35 @@ +/*! + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Type definitions for ECMAScript Temporal API + +declare global { + namespace Temporal { + interface Instant { + readonly [Symbol.toStringTag]?: string; + readonly epochMilliseconds: number; + readonly epochNanoseconds: bigint; + toString(): string; + } + + const Instant: { + fromEpochNanoseconds(epochNanoseconds: bigint): Instant; + fromEpochMilliseconds(epochMilliseconds: number): Instant; + }; + } +} + +export {}; diff --git a/handwritten/firestore/dev/src/timestamp.ts b/handwritten/firestore/dev/src/timestamp.ts index 09ff1d086be2..8de6cf27b337 100644 --- a/handwritten/firestore/dev/src/timestamp.ts +++ b/handwritten/firestore/dev/src/timestamp.ts @@ -117,6 +117,47 @@ export class Timestamp implements firestore.Timestamp { return new Timestamp(seconds, nanos); } + /** + * Creates a new timestamp from the given `Temporal.Instant`. + * + * @example + * ``` + * let documentRef = firestore.doc('col/doc'); + * + * let instant = Temporal.Now.instant(); + * documentRef.set({ startTime:Firestore.Timestamp.fromInstant(instant) }); + * + * ``` + * @param {Temporal.Instant} instant The `Temporal.Instant` to initialize the `Timestamp` from. + * @returns {Timestamp} A new `Timestamp` representing the same point in time + * as the given instant. + */ + static fromInstant(instant: Temporal.Instant): Timestamp { + if (!instant || typeof instant.epochNanoseconds !== 'bigint') { + throw new Error('Invalid Temporal.Instant object provided.'); + } + return Timestamp._fromEpochNanoseconds(instant.epochNanoseconds); + } + + private static _fromEpochNanoseconds(nanos: bigint): Timestamp { + let seconds: number; + let nanoseconds: number; + if (nanos >= 0n) { + seconds = Number(nanos / 1000000000n); + nanoseconds = Number(nanos % 1000000000n); + } else { + const rem = nanos % 1000000000n; + if (rem === 0n) { + seconds = Number(nanos / 1000000000n); + nanoseconds = 0; + } else { + seconds = Number(nanos / 1000000000n - 1n); + nanoseconds = Number(rem + 1000000000n); + } + } + return new Timestamp(seconds, nanoseconds); + } + /** * Generates a `Timestamp` object from a Timestamp proto. * @@ -198,6 +239,32 @@ export class Timestamp implements firestore.Timestamp { return this._nanoseconds; } + /** + * Converts a `Timestamp` to a `Temporal.Instant` object. + * + * @example + * ``` + * let documentRef = firestore.doc('col/doc'); + * + * documentRef.get().then(snap => { + * console.log(`Document updated at: ${snap.updateTime.toInstant()}`); + * }); + * + * ``` + * @returns {Temporal.Instant} `Temporal.Instant` object representing the same point in time + * as this `Timestamp`, with nanosecond precision. + */ + toInstant(): Temporal.Instant { + if (typeof Temporal === 'undefined' || !Temporal.Instant) { + throw new Error( + 'The Temporal object is not available in the current environment.', + ); + } + const nanos = + BigInt(this._seconds) * 1000000000n + BigInt(this._nanoseconds); + return Temporal.Instant.fromEpochNanoseconds(nanos); + } + /** * Returns a new `Date` corresponding to this timestamp. This may lose * precision. diff --git a/handwritten/firestore/dev/test/document.ts b/handwritten/firestore/dev/test/document.ts index 32bb38dcec13..cbef0a53c967 100644 --- a/handwritten/firestore/dev/test/document.ts +++ b/handwritten/firestore/dev/test/document.ts @@ -300,6 +300,36 @@ describe('serialize document', () => { }); }); + it('supports Temporal.Instant', async () => { + const Temporal = + (globalThis as Record).Temporal || + // eslint-disable-next-line @typescript-eslint/no-require-imports + require('@js-temporal/polyfill').Temporal; + const instant = Temporal.Instant.fromEpochNanoseconds(1488872578916123456n); + + const overrides: ApiOverride = { + commit: request => { + requestEquals( + request, + set({ + document: document('documentId', 'temporalInstant', { + timestampValue: { + nanos: 916123456, + seconds: '1488872578', + }, + }), + }), + ); + return response(writeResult(1)); + }, + }; + + const firestore = await createInstance(overrides); + await firestore.doc('collectionId/documentId').set({ + temporalInstant: instant, + }); + }); + it('supports BigInt', async () => { const overrides: ApiOverride = { commit: request => { diff --git a/handwritten/firestore/dev/test/serializer.ts b/handwritten/firestore/dev/test/serializer.ts index 46ed3639ba0c..deead988019b 100644 --- a/handwritten/firestore/dev/test/serializer.ts +++ b/handwritten/firestore/dev/test/serializer.ts @@ -15,7 +15,11 @@ import {it} from 'mocha'; import {expect} from 'chai'; import * as sinon from 'sinon'; -import {validateUserInput, Serializer} from '../src/serializer'; +import { + validateUserInput, + Serializer, + isTemporalInstant, +} from '../src/serializer'; import {DocumentReference, Firestore} from '../src'; import {SinonStubbedInstance} from 'sinon'; @@ -246,6 +250,29 @@ describe('validateUserInput', () => { }), ).to.throw(/Input object is deeper than 20 levels/i); }); + + it('accepts Temporal.Instant', () => { + const Temporal = + (globalThis as Record).Temporal || + // eslint-disable-next-line @typescript-eslint/no-require-imports + require('@js-temporal/polyfill').Temporal; + const instant = Temporal.Instant.fromEpochNanoseconds(1488872578916123456n); + validateUserInput('instant', instant, 'Firestore Value', { + allowDeletes: 'none', + allowTransforms: false, + allowUndefined: false, + }); + validateUserInput( + 'nested', + {createdAt: instant, list: [instant]}, + 'Firestore Value', + { + allowDeletes: 'none', + allowTransforms: false, + allowUndefined: false, + }, + ); + }); }); describe('serializer', () => { @@ -269,6 +296,66 @@ describe('serializer', () => { serializer = new Serializer(firestoreStub); }); + describe('encodeValue', () => { + it('encodes Temporal.Instant with nanosecond precision', () => { + const Temporal = + (globalThis as Record).Temporal || + // eslint-disable-next-line @typescript-eslint/no-require-imports + require('@js-temporal/polyfill').Temporal; + const instant = + Temporal.Instant.fromEpochNanoseconds(1488872578916123456n); + const encoded = serializer!.encodeValue(instant); + expect(encoded).to.deep.equal({ + timestampValue: { + seconds: '1488872578', + nanos: 916123456, + }, + }); + }); + + it('encodes negative Temporal.Instant', () => { + const Temporal = + (globalThis as Record).Temporal || + // eslint-disable-next-line @typescript-eslint/no-require-imports + require('@js-temporal/polyfill').Temporal; + // -1.25 seconds with 123 nanoseconds: -1249999877n + // seconds: -2, nanos: 750000123 + const instant = Temporal.Instant.fromEpochNanoseconds(-1249999877n); + const encoded = serializer!.encodeValue(instant); + expect(encoded).to.deep.equal({ + timestampValue: { + seconds: '-2', + nanos: 750000123, + }, + }); + }); + }); + + describe('isTemporalInstant', () => { + it('identifies Temporal.Instant objects', () => { + const Temporal = + (globalThis as Record).Temporal || + // eslint-disable-next-line @typescript-eslint/no-require-imports + require('@js-temporal/polyfill').Temporal; + const instant = Temporal.Instant.fromEpochNanoseconds(1000n); + expect(isTemporalInstant(instant)).to.be.true; + + // duck typed object + const duckInstant = { + [Symbol.toStringTag]: 'Temporal.Instant', + epochNanoseconds: 1000n, + }; + expect(isTemporalInstant(duckInstant)).to.be.true; + + expect(isTemporalInstant(null)).to.be.false; + expect(isTemporalInstant(undefined)).to.be.false; + expect(isTemporalInstant({})).to.be.false; + expect(isTemporalInstant(new Date())).to.be.false; + expect(isTemporalInstant('string')).to.be.false; + expect(isTemporalInstant(123)).to.be.false; + }); + }); + describe('decodeValue', () => { it('decodes reference to document', () => { const result = serializer!.decodeValue({ diff --git a/handwritten/firestore/dev/test/timestamp.ts b/handwritten/firestore/dev/test/timestamp.ts index abef3b64d59e..6388d0f0ef4f 100644 --- a/handwritten/firestore/dev/test/timestamp.ts +++ b/handwritten/firestore/dev/test/timestamp.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {describe, it} from 'mocha'; +import {describe, it, before, after} from 'mocha'; import {expect} from 'chai'; import * as through2 from 'through2'; @@ -221,4 +221,117 @@ describe('timestamps', () => { expect(t1 > t2).to.be.false; expect(t1 >= t2).to.be.false; }); + + describe('Temporal Instant conversions', () => { + let didPolyfill = false; + + before(() => { + if ( + typeof (globalThis as Record).Temporal === 'undefined' + ) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const {Temporal} = require('@js-temporal/polyfill'); + (globalThis as Record).Temporal = Temporal; + didPolyfill = true; + } + }); + + after(() => { + if (didPolyfill) { + (globalThis as Record).Temporal = undefined; + } + }); + + it('fromInstant creates Timestamp correctly', () => { + const instant = + Temporal.Instant.fromEpochNanoseconds(1488872578916000000n); + const ts = Firestore.Timestamp.fromInstant(instant); + expect(ts.seconds).to.equal(1488872578); + expect(ts.nanoseconds).to.equal(916000000); + + const instantWithNanos = + Temporal.Instant.fromEpochNanoseconds(1488872578916123456n); + const ts2 = Firestore.Timestamp.fromInstant(instantWithNanos); + expect(ts2.seconds).to.equal(1488872578); + expect(ts2.nanoseconds).to.equal(916123456); + }); + + it('fromInstant handles negative epoch nanoseconds', () => { + // -1.25 seconds: seconds = -2, nanoseconds = 750000000 + const instant = Temporal.Instant.fromEpochNanoseconds(-1250000000n); + const ts = Firestore.Timestamp.fromInstant(instant); + expect(ts.seconds).to.equal(-2); + expect(ts.nanoseconds).to.equal(750000000); + + // -1 nanosecond: seconds = -1, nanoseconds = 999999999 + const instant2 = Temporal.Instant.fromEpochNanoseconds(-1n); + const ts2 = Firestore.Timestamp.fromInstant(instant2); + expect(ts2.seconds).to.equal(-1); + expect(ts2.nanoseconds).to.equal(999999999); + + // -1 second exact: seconds = -1, nanoseconds = 0 + const instant3 = Temporal.Instant.fromEpochNanoseconds(-1000000000n); + const ts3 = Firestore.Timestamp.fromInstant(instant3); + expect(ts3.seconds).to.equal(-1); + expect(ts3.nanoseconds).to.equal(0); + }); + + it('fromInstant throws for invalid input', () => { + expect(() => + Firestore.Timestamp.fromInstant(null as unknown as Temporal.Instant), + ).to.throw('Invalid Temporal.Instant object provided.'); + + expect(() => + Firestore.Timestamp.fromInstant( + undefined as unknown as Temporal.Instant, + ), + ).to.throw('Invalid Temporal.Instant object provided.'); + + expect(() => + Firestore.Timestamp.fromInstant({} as unknown as Temporal.Instant), + ).to.throw('Invalid Temporal.Instant object provided.'); + }); + + it('toInstant returns Temporal.Instant with nanosecond precision', () => { + const ts = new Firestore.Timestamp(1488872578, 916123456); + const instant = ts.toInstant(); + expect(instant.epochNanoseconds).to.equal(1488872578916123456n); + expect(instant.epochMilliseconds).to.equal(1488872578916); + }); + + it('toInstant handles negative timestamps', () => { + const ts = new Firestore.Timestamp(-2, 750000000); + const instant = ts.toInstant(); + expect(instant.epochNanoseconds).to.equal(-1250000000n); + + const ts2 = new Firestore.Timestamp(-1, 999999999); + const instant2 = ts2.toInstant(); + expect(instant2.epochNanoseconds).to.equal(-1n); + }); + + it('toInstant throws when Temporal is unavailable', () => { + const saved = (globalThis as Record).Temporal; + delete (globalThis as Record).Temporal; + try { + const ts = new Firestore.Timestamp(100, 200); + expect(() => ts.toInstant()).to.throw( + 'The Temporal object is not available in the current environment.', + ); + } finally { + (globalThis as Record).Temporal = saved; + } + }); + + it('roundtrip conversions preserve nanosecond precision', () => { + const original = new Firestore.Timestamp(123456789, 987654321); + const instant = original.toInstant(); + const fromInst = Firestore.Timestamp.fromInstant(instant); + expect(fromInst.isEqual(original)).to.be.true; + + const negativeOriginal = new Firestore.Timestamp(-62135596800, 123456789); + const negativeInstant = negativeOriginal.toInstant(); + const fromNegativeInst = Firestore.Timestamp.fromInstant(negativeInstant); + expect(fromNegativeInst.isEqual(negativeOriginal)).to.be.true; + }); + }); }); diff --git a/handwritten/firestore/package.json b/handwritten/firestore/package.json index fb08366175f4..fac5b5653e38 100644 --- a/handwritten/firestore/package.json +++ b/handwritten/firestore/package.json @@ -77,6 +77,7 @@ "@google-cloud/promisify": "^6.0.1", "@google-cloud/trace-agent": "^8.0.0", "@googleapis/cloudtrace": "^3.0.1", + "@js-temporal/polyfill": "^0.5.1", "@opentelemetry/context-async-hooks": "^2.0.1", "@opentelemetry/sdk-trace-node": "^2.0.1", "@types/assert": "^1.5.11", diff --git a/handwritten/firestore/types/firestore.d.ts b/handwritten/firestore/types/firestore.d.ts index d30565bdb154..05346b8adc4a 100644 --- a/handwritten/firestore/types/firestore.d.ts +++ b/handwritten/firestore/types/firestore.d.ts @@ -16,6 +16,17 @@ // We deliberately use `any` in the external API to not impose type-checking // on end users. /* eslint-disable @typescript-eslint/no-explicit-any */ + +// Declare ambient Temporal namespace for ECMAScript Temporal API +declare namespace Temporal { + interface Instant { + readonly [Symbol.toStringTag]?: string; + readonly epochMilliseconds: number; + readonly epochNanoseconds: bigint; + toString(): string; + } +} + // Declare a global (ambient) namespace // (used when not using import statement, but just script include). declare namespace FirebaseFirestore { @@ -2906,6 +2917,14 @@ declare namespace FirebaseFirestore { * given number of milliseconds. */ static fromMillis(milliseconds: number): Timestamp; + /** + * Creates a new timestamp from the given `Temporal.Instant`. + * + * @param instant The `Temporal.Instant` to initialize the `Timestamp` from. + * @returns A new `Timestamp` representing the same point in time as the + * given instant. + */ + static fromInstant(instant: Temporal.Instant): Timestamp; /** * Creates a new timestamp. * @@ -2924,6 +2943,13 @@ declare namespace FirebaseFirestore { readonly seconds: number; /** The non-negative fractions of a second at nanosecond resolution. */ readonly nanoseconds: number; + /** + * Converts a `Timestamp` to a `Temporal.Instant` object. + * + * @returns `Temporal.Instant` object representing the same point in time as + * this `Timestamp`, with nanosecond precision. + */ + toInstant(): Temporal.Instant; /** * Returns a new `Date` corresponding to this timestamp. This may lose * precision.