From 65f3269ae205ae511c6fe007cc9851ad8ee5ee87 Mon Sep 17 00:00:00 2001 From: Dan Hudlow Date: Tue, 23 Dec 2025 10:09:13 -0600 Subject: [PATCH] Simplify, fix, and add tests for timestamp and duration handling --- packages/cel/src/duration.test.ts | 176 ++++++++++++++++++++++++-- packages/cel/src/duration.ts | 132 ++++++++++++-------- packages/cel/src/std/math.ts | 34 ++---- packages/cel/src/timestamp.test.ts | 190 +++++++++++++++++++++++++++++ packages/cel/src/timestamp.ts | 32 +++-- 5 files changed, 461 insertions(+), 103 deletions(-) create mode 100644 packages/cel/src/timestamp.test.ts diff --git a/packages/cel/src/duration.test.ts b/packages/cel/src/duration.test.ts index d73847e9..f2ded364 100644 --- a/packages/cel/src/duration.test.ts +++ b/packages/cel/src/duration.test.ts @@ -21,20 +21,170 @@ import { DurationSchema } from "@bufbuild/protobuf/wkt"; import { createDuration } from "./duration.js"; void suite("duration", () => { - void test("createDuration()", () => { - let actual = createDuration(0n, -1); - assert.ok(isMessage(actual, DurationSchema)); - assert.equal(actual.seconds, -1n); - assert.equal(actual.nanos, 999999999); + void suite("createDuration()", () => { + void test("0s, 0ns", () => { + let actual = createDuration(0n, 0); + assert.ok(isMessage(actual, DurationSchema)); + assert.equal(actual.seconds, 0n); + assert.equal(actual.nanos, 0); + }); + void test("0s, 1ns", () => { + let actual = createDuration(0n, 1); + assert.ok(isMessage(actual, DurationSchema)); + assert.equal(actual.seconds, 0n); + assert.equal(actual.nanos, 1); + }); + void test("0s, -1ns", () => { + let actual = createDuration(0n, -1); + assert.ok(isMessage(actual, DurationSchema)); + assert.equal(actual.seconds, 0n); + assert.equal(actual.nanos, -1); + }); + void test("0s, 999,999,999ns", () => { + let actual = createDuration(0n, 999999999); + assert.ok(isMessage(actual, DurationSchema)); + assert.equal(actual.seconds, 0n); + assert.equal(actual.nanos, 999999999); + }); + void test("0s, -999,999,999ns", () => { + let actual = createDuration(0n, -999999999); + assert.ok(isMessage(actual, DurationSchema)); + assert.equal(actual.seconds, 0n); + assert.equal(actual.nanos, -999999999); + }); + void test("0s, 1,000,000,000ns", () => { + let actual = createDuration(0n, 1000000000); + assert.ok(isMessage(actual, DurationSchema)); + assert.equal(actual.seconds, 1n); + assert.equal(actual.nanos, 0); + }); + void test("0s, -1,000,000,000ns", () => { + let actual = createDuration(0n, -1000000000); + assert.ok(isMessage(actual, DurationSchema)); + assert.equal(actual.seconds, -1n); + assert.equal(actual.nanos, 0); + }); + void test("0s, 1,000,000,001ns", () => { + let actual = createDuration(0n, 1000000001); + assert.ok(isMessage(actual, DurationSchema)); + assert.equal(actual.seconds, 1n); + assert.equal(actual.nanos, 1); + }); + void test("0s, -1,000,000,001ns", () => { + let actual = createDuration(0n, -1000000001); + assert.ok(isMessage(actual, DurationSchema)); + assert.equal(actual.seconds, -1n); + assert.equal(actual.nanos, -1); + }); - actual = createDuration(0n, -999999999); - assert.ok(isMessage(actual, DurationSchema)); - assert.equal(actual.seconds, -1n); - assert.equal(actual.nanos, 1); + void test("1s, 0ns", () => { + let actual = createDuration(1n, 0); + assert.ok(isMessage(actual, DurationSchema)); + assert.equal(actual.seconds, 1n); + assert.equal(actual.nanos, 0); + }); + void test("1s, 1ns", () => { + let actual = createDuration(1n, 1); + assert.ok(isMessage(actual, DurationSchema)); + assert.equal(actual.seconds, 1n); + assert.equal(actual.nanos, 1); + }); + void test("1s, -1ns", () => { + let actual = createDuration(1n, -1); + assert.ok(isMessage(actual, DurationSchema)); + assert.equal(actual.seconds, 0n); + assert.equal(actual.nanos, 999999999); + }); + void test("1s, 999,999,999ns", () => { + let actual = createDuration(1n, 999999999); + assert.ok(isMessage(actual, DurationSchema)); + assert.equal(actual.seconds, 1n); + assert.equal(actual.nanos, 999999999); + }); + void test("1s, -999,999,999ns", () => { + let actual = createDuration(1n, -999999999); + assert.ok(isMessage(actual, DurationSchema)); + assert.equal(actual.seconds, 0n); + assert.equal(actual.nanos, 1); + }); + void test("1s, 1,000,000,000ns", () => { + let actual = createDuration(1n, 1000000000); + assert.ok(isMessage(actual, DurationSchema)); + assert.equal(actual.seconds, 2n); + assert.equal(actual.nanos, 0); + }); + void test("1s, -1,000,000,000ns", () => { + let actual = createDuration(1n, -1000000000); + assert.ok(isMessage(actual, DurationSchema)); + assert.equal(actual.seconds, 0n); + assert.equal(actual.nanos, 0); + }); + void test("1s, 1,000,000,001ns", () => { + let actual = createDuration(1n, 1000000001); + assert.ok(isMessage(actual, DurationSchema)); + assert.equal(actual.seconds, 2n); + assert.equal(actual.nanos, 1); + }); + void test("1s, -1,000,000,001ns", () => { + let actual = createDuration(1n, -1000000001); + assert.ok(isMessage(actual, DurationSchema)); + assert.equal(actual.seconds, 0n); + assert.equal(actual.nanos, -1); + }); - actual = createDuration(0n, -1000000000); - assert.ok(isMessage(actual, DurationSchema)); - assert.equal(actual.seconds, -1n); - assert.equal(actual.nanos, 0); + void test("-1s, 0ns", () => { + let actual = createDuration(-1n, 0); + assert.ok(isMessage(actual, DurationSchema)); + assert.equal(actual.seconds, -1n); + assert.equal(actual.nanos, 0); + }); + void test("-1s, 1ns", () => { + let actual = createDuration(-1n, 1); + assert.ok(isMessage(actual, DurationSchema)); + assert.equal(actual.seconds, 0n); + assert.equal(actual.nanos, -999999999); + }); + void test("-1s, -1ns", () => { + let actual = createDuration(-1n, -1); + assert.ok(isMessage(actual, DurationSchema)); + assert.equal(actual.seconds, -1n); + assert.equal(actual.nanos, -1); + }); + void test("-1s, 999,999,999ns", () => { + let actual = createDuration(-1n, 999999999); + assert.ok(isMessage(actual, DurationSchema)); + assert.equal(actual.seconds, 0n); + assert.equal(actual.nanos, -1); + }); + void test("-1s, -999,999,999ns", () => { + let actual = createDuration(-1n, -999999999); + assert.ok(isMessage(actual, DurationSchema)); + assert.equal(actual.seconds, -1n); + assert.equal(actual.nanos, -999999999); + }); + void test("-1s, 1,000,000,000ns", () => { + let actual = createDuration(-1n, 1000000000); + assert.ok(isMessage(actual, DurationSchema)); + assert.equal(actual.seconds, 0n); + assert.equal(actual.nanos, 0); + }); + void test("-1s, -1,000,000,000ns", () => { + let actual = createDuration(-1n, -1000000000); + assert.ok(isMessage(actual, DurationSchema)); + assert.equal(actual.seconds, -2n); + assert.equal(actual.nanos, 0); + }); + void test("-1s, 1,000,000,001ns", () => { + let actual = createDuration(-1n, 1000000001); + assert.ok(isMessage(actual, DurationSchema)); + assert.equal(actual.seconds, 0n); + assert.equal(actual.nanos, 1); + }); + void test("-1s, -1,000,000,001ns", () => { + let actual = createDuration(-1n, -1000000001); + assert.ok(isMessage(actual, DurationSchema)); + assert.equal(actual.seconds, -2n); + assert.equal(actual.nanos, -1); + }); }); }); diff --git a/packages/cel/src/duration.ts b/packages/cel/src/duration.ts index 7a7a78e2..1732cb47 100644 --- a/packages/cel/src/duration.ts +++ b/packages/cel/src/duration.ts @@ -15,26 +15,48 @@ import { type Duration, DurationSchema } from "@bufbuild/protobuf/wkt"; import { create } from "@bufbuild/protobuf"; +const ONE_SECOND = 1000000000n; +const MAX_DURATION_NANOS = 9223372036854775807n; +const MIN_DURATION_NANOS = -9223372036854775808n; + /** - * Create a new Duration, validating the fields are in range. + * Create a new Duration, canonicalizing the representation */ -export function createDuration(seconds: bigint, nanos: number): Duration { - if (nanos >= 1000000000) { - seconds += BigInt(nanos / 1000000000); - nanos = nanos % 1000000000; - } else if (nanos < 0) { - const negSeconds = Math.ceil(-nanos / 1000000000); - seconds -= BigInt(negSeconds); - nanos = nanos + negSeconds * 1000000000; - } - // Must fit in 64 bits of nanoseconds for compatibility with golang - const totalNanos = seconds * 1000000000n + BigInt(nanos); - if (totalNanos > 9223372036854775807n || totalNanos < -9223372036854775808n) { +export function createDuration( + seconds = 0n, + ns: bigint | number = 0n, +): Duration { + // resolves differing signs + const fullNanos = seconds * ONE_SECOND + BigInt(ns); + + // `fullNanos` must fit in a 64-bit signed integer per the spec + if (fullNanos > MAX_DURATION_NANOS || fullNanos < MIN_DURATION_NANOS) { throw new Error("duration out of range"); } - return create(DurationSchema, { seconds: seconds, nanos: nanos }); + + return create(DurationSchema, { + seconds: fullNanos / ONE_SECOND, + nanos: Number(fullNanos % ONE_SECOND), // preserves sign + }); } +// Not in the spec, but for safety, we want to make sure this is not insane. +const DURATION_STRING_LENGTH_LIMIT = 128; +const UNITS = Object.freeze({ + ns: 1n, + us: 1000n, + µs: 1000n, + ms: 1000n * 1000n, + s: 1000n * 1000n * 1000n, + m: 1000n * 1000n * 1000n * 60n, + h: 1000n * 1000n * 1000n * 60n * 60n, +}); + +const INT_REGEXP = /^\d+/; +const UNIT_REGEXP = new RegExp(`^(${Object.keys(UNITS).join("|")})`) as { + exec(target: string): [keyof typeof UNITS] | null; +}; + /** * Parses a CEL duration string. * @@ -44,48 +66,52 @@ export function createDuration(seconds: bigint, nanos: number): Duration { * Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h". */ export function parseDuration(str: string): Duration { - // The regex grouping the number and the unit is: - const re = /([-+]?(?:\d+|\d+\.\d*|\d*\.\d+))(ns|us|µs|ms|s|m|h)/; - // Loop over the string matching the regex. - let seconds = 0n; - let nanos = 0; - let remaining = str; - while (remaining.length > 0) { - const match = re.exec(remaining); - if (match === null) { - throw badDurationStr("invalid syntax"); - } - const [, numStr, unit] = match; - const num = Number(numStr); - if (Number.isNaN(num)) { + if (str.length > DURATION_STRING_LENGTH_LIMIT) { + throw badDurationStr( + `duration string exceeds ${DURATION_STRING_LENGTH_LIMIT} characters`, + ); + } + + // handle unitless-zero (which might have a sign) + if (/^[-+]?0$/.test(str)) return createDuration(); + + // extract the sign + const [sign, values] = /^[+-]/.test(str) + ? [str[0] === "+" ? 1n : -1n, str.slice(1)] + : [1n, str]; + + let fullNanos = 0n; + let remainder = values; + while (remainder.length > 0) { + // consume integer part + const int = INT_REGEXP.exec(remainder)?.[0]; + remainder = remainder.slice(int?.length ?? 0); + + // if it exists, consume decimal point + if (remainder[0] === ".") remainder = remainder.slice(1); + + // consume fractional part — if we didn't consume a decimal point, this will + // not consume anything, since all digits will have already been consumed + const fraction = INT_REGEXP.exec(remainder)?.[0]; + remainder = remainder.slice(fraction?.length ?? 0); + + // consume unit + const unit = UNIT_REGEXP.exec(remainder)?.[0]; + remainder = remainder.slice(unit?.length ?? 0); + + // we must get a unit and either an integer part or fractional part + if ((int ?? fraction) === undefined || unit === undefined) { throw badDurationStr("invalid syntax"); } - switch (unit) { - case "ns": - nanos += num; - break; - case "us": - case "µs": - nanos += num * 1000; - break; - case "ms": - nanos += num * 1000000; - break; - case "s": - seconds += BigInt(num); - break; - case "m": - seconds += BigInt(num * 60); - break; - case "h": - seconds += BigInt(num * 3600); - break; - default: - throw badDurationStr("invalid syntax"); - } - remaining = remaining.slice(match[0].length); + + const factor = UNITS[unit]; + + fullNanos += BigInt(int ?? 0) * factor; + fullNanos += + (BigInt(fraction ?? 0) * factor) / 10n ** BigInt(fraction?.length ?? 0); } - return createDuration(seconds, nanos); + + return createDuration(0n, sign * fullNanos); } function badDurationStr(e: string) { diff --git a/packages/cel/src/std/math.ts b/packages/cel/src/std/math.ts index 9d401489..90fd3fc3 100644 --- a/packages/cel/src/std/math.ts +++ b/packages/cel/src/std/math.ts @@ -12,9 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { create } from "@bufbuild/protobuf"; -import { DurationSchema, TimestampSchema } from "@bufbuild/protobuf/wkt"; - import { type FuncRegistry, celOverload, celFunc } from "../func.js"; import * as opc from "../gen/dev/cel/expr/operator_const.js"; import { @@ -67,34 +64,22 @@ export function addMath(funcs: FuncRegistry) { function addTimestamp( lhs: CelValue, - rhs: CelValue | CelValue, + rhs: CelValue, ) { - let seconds = lhs.message.seconds + rhs.message.seconds; - let nanos = lhs.message.nanos + rhs.message.nanos; - if (nanos > 999999999) { - seconds += BigInt(Math.floor(nanos / 1000000000)); - nanos = nanos % 1000000000; - } - if (seconds > 253402300799 || seconds < -62135596800) { - throw overflow(opc.ADD, TIMESTAMP); - } - return create(TimestampSchema, { seconds: seconds, nanos: nanos }); + return createTimestamp( + lhs.message.seconds + rhs.message.seconds, + lhs.message.nanos + rhs.message.nanos, + ); } function addDuration( lhs: CelValue, rhs: CelValue, ) { - let seconds = lhs.message.seconds + rhs.message.seconds; - let nanos = lhs.message.nanos + rhs.message.nanos; - if (nanos > 999999999) { - seconds += BigInt(Math.floor(nanos / 1000000000)); - nanos = nanos % 1000000000; - } - if (seconds > 315576000000 || seconds < -315576000000) { - throw overflow(opc.ADD, DURATION); - } - return create(DurationSchema, { seconds: seconds, nanos: nanos }); + return createDuration( + lhs.message.seconds + rhs.message.seconds, + lhs.message.nanos + rhs.message.nanos, + ); } function subtractDurationOrTimestamp< @@ -141,7 +126,6 @@ const add = celFunc(opc.ADD, [ return val; }, ), - celOverload([TIMESTAMP, TIMESTAMP], TIMESTAMP, addTimestamp), celOverload([TIMESTAMP, DURATION], TIMESTAMP, addTimestamp), celOverload([DURATION, TIMESTAMP], TIMESTAMP, (lhs, rhs) => addTimestamp(rhs, lhs), diff --git a/packages/cel/src/timestamp.test.ts b/packages/cel/src/timestamp.test.ts new file mode 100644 index 00000000..c122a9ba --- /dev/null +++ b/packages/cel/src/timestamp.test.ts @@ -0,0 +1,190 @@ +// Copyright 2024-2025 Buf Technologies, Inc. +// +// 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. + +import { suite, test } from "node:test"; +import * as assert from "node:assert/strict"; + +import { isMessage } from "@bufbuild/protobuf"; +import { TimestampSchema } from "@bufbuild/protobuf/wkt"; + +import { createTimestamp } from "./timestamp.js"; + +void suite("timestamp", () => { + void suite("createTimestamp()", () => { + void test("0s, 0ns", () => { + let actual = createTimestamp(0n, 0); + assert.ok(isMessage(actual, TimestampSchema)); + assert.equal(actual.seconds, 0n); + assert.equal(actual.nanos, 0); + }); + void test("0s, 1ns", () => { + let actual = createTimestamp(0n, 1); + assert.ok(isMessage(actual, TimestampSchema)); + assert.equal(actual.seconds, 0n); + assert.equal(actual.nanos, 1); + }); + void test("0s, -1ns", () => { + let actual = createTimestamp(0n, -1); + assert.ok(isMessage(actual, TimestampSchema)); + assert.equal(actual.seconds, -1n); + assert.equal(actual.nanos, 999999999); + }); + void test("0s, 999,999,999ns", () => { + let actual = createTimestamp(0n, 999999999); + assert.ok(isMessage(actual, TimestampSchema)); + assert.equal(actual.seconds, 0n); + assert.equal(actual.nanos, 999999999); + }); + void test("0s, -999,999,999ns", () => { + let actual = createTimestamp(0n, -999999999); + assert.ok(isMessage(actual, TimestampSchema)); + assert.equal(actual.seconds, -1n); + assert.equal(actual.nanos, 1); + }); + void test("0s, 1,000,000,000ns", () => { + let actual = createTimestamp(0n, 1000000000); + assert.ok(isMessage(actual, TimestampSchema)); + assert.equal(actual.seconds, 1n); + assert.equal(actual.nanos, 0); + }); + void test("0s, -1,000,000,000ns", () => { + let actual = createTimestamp(0n, -1000000000); + assert.ok(isMessage(actual, TimestampSchema)); + assert.equal(actual.seconds, -1n); + assert.equal(actual.nanos, 0); + }); + void test("0s, 1,000,000,001ns", () => { + let actual = createTimestamp(0n, 1000000001); + assert.ok(isMessage(actual, TimestampSchema)); + assert.equal(actual.seconds, 1n); + assert.equal(actual.nanos, 1); + }); + void test("0s, -1,000,000,001ns", () => { + let actual = createTimestamp(0n, -1000000001); + assert.ok(isMessage(actual, TimestampSchema)); + assert.equal(actual.seconds, -2n); + assert.equal(actual.nanos, 999999999); + }); + + void test("1s, 0ns", () => { + let actual = createTimestamp(1n, 0); + assert.ok(isMessage(actual, TimestampSchema)); + assert.equal(actual.seconds, 1n); + assert.equal(actual.nanos, 0); + }); + void test("1s, 1ns", () => { + let actual = createTimestamp(1n, 1); + assert.ok(isMessage(actual, TimestampSchema)); + assert.equal(actual.seconds, 1n); + assert.equal(actual.nanos, 1); + }); + void test("1s, -1ns", () => { + let actual = createTimestamp(1n, -1); + assert.ok(isMessage(actual, TimestampSchema)); + assert.equal(actual.seconds, 0n); + assert.equal(actual.nanos, 999999999); + }); + void test("1s, 999,999,999ns", () => { + let actual = createTimestamp(1n, 999999999); + assert.ok(isMessage(actual, TimestampSchema)); + assert.equal(actual.seconds, 1n); + assert.equal(actual.nanos, 999999999); + }); + void test("1s, -999,999,999ns", () => { + let actual = createTimestamp(1n, -999999999); + assert.ok(isMessage(actual, TimestampSchema)); + assert.equal(actual.seconds, 0n); + assert.equal(actual.nanos, 1); + }); + void test("1s, 1,000,000,000ns", () => { + let actual = createTimestamp(1n, 1000000000); + assert.ok(isMessage(actual, TimestampSchema)); + assert.equal(actual.seconds, 2n); + assert.equal(actual.nanos, 0); + }); + void test("1s, -1,000,000,000ns", () => { + let actual = createTimestamp(1n, -1000000000); + assert.ok(isMessage(actual, TimestampSchema)); + assert.equal(actual.seconds, 0n); + assert.equal(actual.nanos, 0); + }); + void test("1s, 1,000,000,001ns", () => { + let actual = createTimestamp(1n, 1000000001); + assert.ok(isMessage(actual, TimestampSchema)); + assert.equal(actual.seconds, 2n); + assert.equal(actual.nanos, 1); + }); + void test("1s, -1,000,000,001ns", () => { + let actual = createTimestamp(1n, -1000000001); + assert.ok(isMessage(actual, TimestampSchema)); + assert.equal(actual.seconds, -1n); + assert.equal(actual.nanos, 999999999); + }); + + void test("-1s, 0ns", () => { + let actual = createTimestamp(-1n, 0); + assert.ok(isMessage(actual, TimestampSchema)); + assert.equal(actual.seconds, -1n); + assert.equal(actual.nanos, 0); + }); + void test("-1s, 1ns", () => { + let actual = createTimestamp(-1n, 1); + assert.ok(isMessage(actual, TimestampSchema)); + assert.equal(actual.seconds, -1n); + assert.equal(actual.nanos, 1); + }); + void test("-1s, -1ns", () => { + let actual = createTimestamp(-1n, -1); + assert.ok(isMessage(actual, TimestampSchema)); + assert.equal(actual.seconds, -2n); + assert.equal(actual.nanos, 999999999); + }); + void test("-1s, 999,999,999ns", () => { + let actual = createTimestamp(-1n, 999999999); + assert.ok(isMessage(actual, TimestampSchema)); + assert.equal(actual.seconds, -1n); + assert.equal(actual.nanos, 999999999); + }); + void test("-1s, -999,999,999ns", () => { + let actual = createTimestamp(-1n, -999999999); + assert.ok(isMessage(actual, TimestampSchema)); + assert.equal(actual.seconds, -2n); + assert.equal(actual.nanos, 1); + }); + void test("-1s, 1,000,000,000ns", () => { + let actual = createTimestamp(-1n, 1000000000); + assert.ok(isMessage(actual, TimestampSchema)); + assert.equal(actual.seconds, 0n); + assert.equal(actual.nanos, 0); + }); + void test("-1s, -1,000,000,000ns", () => { + let actual = createTimestamp(-1n, -1000000000); + assert.ok(isMessage(actual, TimestampSchema)); + assert.equal(actual.seconds, -2n); + assert.equal(actual.nanos, 0); + }); + void test("-1s, 1,000,000,001ns", () => { + let actual = createTimestamp(-1n, 1000000001); + assert.ok(isMessage(actual, TimestampSchema)); + assert.equal(actual.seconds, 0n); + assert.equal(actual.nanos, 1); + }); + void test("-1s, -1,000,000,001ns", () => { + let actual = createTimestamp(-1n, -1000000001); + assert.ok(isMessage(actual, TimestampSchema)); + assert.equal(actual.seconds, -3n); + assert.equal(actual.nanos, 999999999); + }); + }); +}); diff --git a/packages/cel/src/timestamp.ts b/packages/cel/src/timestamp.ts index 8e0e07a3..e8be281d 100644 --- a/packages/cel/src/timestamp.ts +++ b/packages/cel/src/timestamp.ts @@ -15,20 +15,28 @@ import { create } from "@bufbuild/protobuf"; import { TimestampSchema, type Timestamp } from "@bufbuild/protobuf/wkt"; +const MAX_TIMESTAMP_SECONDS = 253402300799n; +const MIN_TIMESTAMP_SECONDS = -62135596800n; +const ONE_SECOND = 1000000000n; + /** - * Creates a new Timestamp, validating the fields are in range. + * Create a new Timestamp, canonicalizing the representation */ -export function createTimestamp(seconds: bigint, nanos: number): Timestamp { - if (nanos >= 1000000000) { - seconds += BigInt(nanos / 1000000000); - nanos = nanos % 1000000000; - } else if (nanos < 0) { - const negSeconds = Math.floor(-nanos / 1000000000); - seconds -= BigInt(negSeconds); - nanos = nanos + negSeconds * 1000000000; - } - if (seconds > 253402300799n || seconds < -62135596800n) { +export function createTimestamp(s = 0n, ns: bigint | number = 0n): Timestamp { + // fully express timestamp in nanoseconds + const fullNanos = s * ONE_SECOND + BigInt(ns); + + // would `nanos` end up non-zero negative? + const shift = fullNanos % ONE_SECOND < 0n ? 1n : 0n; + // if so, subtract a second when computing `seconds`... + const seconds = fullNanos / ONE_SECOND - shift; + /// ...and add a second when computing `nanos`, so it will be positive + const nanos = Number((fullNanos % ONE_SECOND) + shift * ONE_SECOND); + + // refer to https://buf.build/protocolbuffers/wellknowntypes/file/main:google/protobuf/timestamp.proto + if (seconds > MAX_TIMESTAMP_SECONDS || seconds < MIN_TIMESTAMP_SECONDS) { throw new Error("timestamp out of range"); } - return create(TimestampSchema, { seconds: seconds, nanos: nanos }); + + return create(TimestampSchema, { seconds, nanos }); }