@tanstack/db-sqlite-persistence-core 0.4.4 → 0.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  IR,
3
+ compareTemporalValues,
3
4
  compileSingleRowExpression,
4
5
  toBooleanPredicate,
5
6
  } from '@tanstack/db'
@@ -17,7 +18,11 @@ import {
17
18
  PERSISTED_TYPE_TAG,
18
19
  PERSISTED_VALUE_TAG,
19
20
  assertSQLiteBigIntInRange,
21
+ reviveSQLiteTemporal,
20
22
  serializeSQLiteBigInt,
23
+ serializeSQLiteTemporal,
24
+ sqliteTemporalIdentity,
25
+ sqliteTemporalKind,
21
26
  } from './sqlite-value'
22
27
  import type { LoadSubsetOptions } from '@tanstack/db'
23
28
  import type {
@@ -47,6 +52,7 @@ type CompiledSqlFragment = {
47
52
  supported: boolean
48
53
  sql: string
49
54
  params: Array<SqliteSupportedValue>
55
+ identitySql?: string
50
56
  valueKind?: CompiledValueKind
51
57
  }
52
58
 
@@ -259,7 +265,14 @@ const SAFE_IDENTIFIER_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/
259
265
  const FORBIDDEN_SQL_FRAGMENT_PATTERN = /(;|--|\/\*)/
260
266
  type CompiledValueKind = `unknown` | `bigint` | `date` | `datetime`
261
267
  type PersistedTaggedValueType =
262
- `bigint` | `date` | `nan` | `infinity` | `-infinity`
268
+ | `bigint`
269
+ | `date`
270
+ | `nan`
271
+ | `infinity`
272
+ | `-infinity`
273
+ | `string`
274
+ | `Temporal.Instant`
275
+ | `Temporal.PlainDate`
263
276
  type PersistedTaggedValue = {
264
277
  [PERSISTED_TYPE_TAG]: PersistedTaggedValueType
265
278
  [PERSISTED_VALUE_TAG]: string
@@ -271,6 +284,9 @@ const persistedTaggedValueTypes = new Set<PersistedTaggedValueType>([
271
284
  `nan`,
272
285
  `infinity`,
273
286
  `-infinity`,
287
+ `string`,
288
+ `Temporal.Instant`,
289
+ `Temporal.PlainDate`,
274
290
  ])
275
291
 
276
292
  const orderByObjectIds = new WeakMap<object, number>()
@@ -333,6 +349,9 @@ function encodePersistedJsonValue(value: unknown): unknown {
333
349
  return serializeSQLiteBigInt(value) satisfies PersistedTaggedValue
334
350
  }
335
351
 
352
+ const temporal = serializeSQLiteTemporal(value)
353
+ if (temporal) return temporal
354
+
336
355
  if (value instanceof Date) {
337
356
  return {
338
357
  [PERSISTED_TYPE_TAG]: `date`,
@@ -375,6 +394,13 @@ function encodePersistedJsonValue(value: unknown): unknown {
375
394
  encodedRecord[key] = encodedValue
376
395
  }
377
396
  }
397
+ // Escape the marker field itself, leaving ordinary nested JSON paths intact.
398
+ if (typeof recordValue[PERSISTED_TYPE_TAG] === `string`) {
399
+ encodedRecord[PERSISTED_TYPE_TAG] = {
400
+ [PERSISTED_TYPE_TAG]: `string`,
401
+ [PERSISTED_VALUE_TAG]: recordValue[PERSISTED_TYPE_TAG],
402
+ }
403
+ }
378
404
  return encodedRecord
379
405
  }
380
406
 
@@ -388,6 +414,14 @@ function decodePersistedJsonValue(value: unknown): unknown {
388
414
 
389
415
  if (isPersistedTaggedValue(value)) {
390
416
  switch (value[PERSISTED_TYPE_TAG]) {
417
+ case `string`:
418
+ return value[PERSISTED_VALUE_TAG]
419
+ case `Temporal.Instant`:
420
+ case `Temporal.PlainDate`:
421
+ return reviveSQLiteTemporal(
422
+ value[PERSISTED_TYPE_TAG],
423
+ value[PERSISTED_VALUE_TAG],
424
+ )
391
425
  case `bigint`:
392
426
  return BigInt(value[PERSISTED_VALUE_TAG])
393
427
  case `date`: {
@@ -459,7 +493,8 @@ function toSqliteParameterValue(value: unknown): SqliteSupportedValue {
459
493
  return value.toISOString()
460
494
  }
461
495
 
462
- return serializePersistedRowValue(value)
496
+ const temporal = serializeSQLiteTemporal(value)
497
+ return temporal?.order ?? serializePersistedRowValue(value)
463
498
  }
464
499
 
465
500
  function toSqliteLiteral(value: SqliteSupportedValue): string {
@@ -471,6 +506,10 @@ function toSqliteLiteral(value: SqliteSupportedValue): string {
471
506
  return Number.isFinite(value) ? String(value) : `NULL`
472
507
  }
473
508
 
509
+ if (value.includes(`\u0000`)) {
510
+ // JSON escapes NUL before SQL parsing and matches stored-string extraction.
511
+ return `json_extract(${toSqliteLiteral(JSON.stringify(value))}, '$')`
512
+ }
474
513
  return `'${value.replace(/'/g, `''`)}'`
475
514
  }
476
515
 
@@ -563,6 +602,10 @@ function compareOrderByValues(
563
602
  return left.getTime() - right.getTime()
564
603
  }
565
604
 
605
+ if (sqliteTemporalKind(left) && sqliteTemporalKind(right)) {
606
+ return compareTemporalValues(left, right)
607
+ }
608
+
566
609
  const leftIsObject = typeof left === `object`
567
610
  const rightIsObject = typeof right === `object`
568
611
  if (leftIsObject || rightIsObject) {
@@ -720,16 +763,23 @@ function compileRefExpressionSql(jsonPath: string): CompiledSqlFragment {
720
763
  const taggedValuePathSql = toSqliteLiteral(taggedValuePath)
721
764
  const jsonPathSql = toSqliteLiteral(jsonPath)
722
765
 
723
- return {
724
- supported: true,
725
- sql: `(CASE json_extract(value, ${typePathSql})
766
+ const typeSql = `json_extract(value, ${typePathSql})`
767
+ const orderPathSql = toSqliteLiteral(`${jsonPath}.order`)
768
+ const sql = `(CASE ${typeSql}
726
769
  WHEN 'bigint' THEN CAST(json_extract(value, ${taggedValuePathSql}) AS NUMERIC)
727
770
  WHEN 'date' THEN json_extract(value, ${taggedValuePathSql})
771
+ WHEN 'string' THEN json_extract(value, ${taggedValuePathSql})
772
+ WHEN 'Temporal.Instant' THEN json_extract(value, ${orderPathSql})
773
+ WHEN 'Temporal.PlainDate' THEN json_extract(value, ${orderPathSql})
728
774
  WHEN 'nan' THEN NULL
729
775
  WHEN 'infinity' THEN NULL
730
776
  WHEN '-infinity' THEN NULL
731
777
  ELSE json_extract(value, ${jsonPathSql})
732
- END)`,
778
+ END)`
779
+ return {
780
+ supported: true,
781
+ sql,
782
+ identitySql: `(CASE WHEN ${typeSql} IN ('Temporal.Instant', 'Temporal.PlainDate') THEN ${typeSql} || ':' || json_extract(value, ${taggedValuePathSql}) ELSE ${sql} END)`,
733
783
  params: [],
734
784
  valueKind: `unknown`,
735
785
  }
@@ -781,6 +831,13 @@ function stableStringify(value: unknown): string {
781
831
  return serializePersistedRowValue(value)
782
832
  }
783
833
 
834
+ /** Balance disjunctions so a large membership index stays below SQL depth limits. */
835
+ function joinSqlDisjunction(parts: ReadonlyArray<string>): string {
836
+ if (parts.length === 1) return parts[0]!
837
+ const middle = Math.floor(parts.length / 2)
838
+ return `(${joinSqlDisjunction(parts.slice(0, middle))} OR ${joinSqlDisjunction(parts.slice(middle))})`
839
+ }
840
+
784
841
  function argumentCompilationContext(
785
842
  parentName: string,
786
843
  argumentIndex: number,
@@ -824,7 +881,8 @@ function isSafeEqualityLiteral(value: unknown): boolean {
824
881
  return (
825
882
  typeof value === `string` ||
826
883
  typeof value === `boolean` ||
827
- typeof value === `bigint`
884
+ typeof value === `bigint` ||
885
+ sqliteTemporalKind(value) !== undefined
828
886
  )
829
887
  }
830
888
 
@@ -863,18 +921,21 @@ function compileSqlExpression(
863
921
  params: [JSON.stringify(expression.value)],
864
922
  }
865
923
  }
866
- const valueKind = getLiteralValueKind(expression.value)
924
+ const temporal = serializeSQLiteTemporal(expression.value)
925
+ const value = temporal?.order ?? toSqliteParameterValue(expression.value)
867
926
  return {
927
+ identitySql: temporal
928
+ ? toSqliteLiteral(sqliteTemporalIdentity(temporal))
929
+ : undefined,
868
930
  supported: true,
869
931
  sql:
870
932
  context === `index-expression`
871
- ? toSqliteExpressionLiteral(expression.value)
933
+ ? toSqliteExpressionLiteral(
934
+ typeof expression.value === `bigint` ? expression.value : value,
935
+ )
872
936
  : `?`,
873
- params:
874
- context === `predicate`
875
- ? [toSqliteParameterValue(expression.value)]
876
- : [],
877
- valueKind,
937
+ params: context === `predicate` ? [value] : [],
938
+ valueKind: getLiteralValueKind(expression.value),
878
939
  }
879
940
  }
880
941
 
@@ -891,15 +952,20 @@ function compileSqlExpression(
891
952
  }
892
953
 
893
954
  if (jsonPaths.length === 1) return compileRefExpressionSql(jsonPaths[0]!)
955
+ const variants = jsonPaths.map(compileRefExpressionSql)
894
956
  return {
895
957
  supported: true,
896
- sql: `COALESCE(${jsonPaths.map((path) => compileRefExpressionSql(path).sql).join(`, `)})`,
958
+ identitySql: `COALESCE(${variants.map((variant) => variant.identitySql).join(`, `)})`,
959
+ sql: `COALESCE(${variants.map((variant) => variant.sql).join(`, `)})`,
897
960
  params: [],
898
961
  valueKind: `unknown`,
899
962
  }
900
963
  }
901
964
 
902
- const compiledArgs = expression.args.map((arg, index) =>
965
+ // IN owns its list encoding; compiling that list as a scalar discards work.
966
+ const args =
967
+ expression.name === `in` ? expression.args.slice(0, 1) : expression.args
968
+ const compiledArgs = args.map((arg, index) =>
903
969
  compileSqlExpression(
904
970
  arg,
905
971
  argumentCompilationContext(expression.name, index, arg, context),
@@ -949,18 +1015,32 @@ function compileSqlExpression(
949
1015
  lte: `<=`,
950
1016
  }
951
1017
 
1018
+ const comparison = compileComparisonSql(
1019
+ operatorByName[expression.name],
1020
+ expression.args[0]!,
1021
+ expression.args[1]!,
1022
+ argSql[0],
1023
+ argSql[1],
1024
+ valueKind,
1025
+ getCompiledValueKind(compiledArgs[0]),
1026
+ getCompiledValueKind(compiledArgs[1]),
1027
+ )
1028
+ // Native order ties need canonical identity, including in persisted
1029
+ // expressions. Ordinary string predicates keep one binding; residual
1030
+ // filtering removes native order-key collisions from their candidate set.
1031
+ const needsIdentity =
1032
+ expression.name === `eq` &&
1033
+ expression.args.every(
1034
+ (arg) =>
1035
+ arg.type !== `val` ||
1036
+ sqliteTemporalKind(arg.value) ||
1037
+ (context === `index-expression` && typeof arg.value === `string`),
1038
+ )
952
1039
  return {
953
1040
  supported: true,
954
- sql: compileComparisonSql(
955
- operatorByName[expression.name],
956
- expression.args[0]!,
957
- expression.args[1]!,
958
- argSql[0],
959
- argSql[1],
960
- valueKind,
961
- getCompiledValueKind(compiledArgs[0]),
962
- getCompiledValueKind(compiledArgs[1]),
963
- ),
1041
+ sql: needsIdentity
1042
+ ? `(${comparison} AND (${compiledArgs[0].identitySql ?? argSql[0]} = ${compiledArgs[1].identitySql ?? argSql[1]}))`
1043
+ : comparison,
964
1044
  params,
965
1045
  }
966
1046
  }
@@ -1011,26 +1091,53 @@ function compileSqlExpression(
1011
1091
  return { supported: false, sql: ``, params: [] }
1012
1092
  }
1013
1093
 
1094
+ // Order and identity are two projections of one validated native encoding.
1095
+ const values = listValue.map((value) => {
1096
+ const temporal = serializeSQLiteTemporal(value)
1097
+ return {
1098
+ value:
1099
+ typeof value === `bigint`
1100
+ ? assertSQLiteBigIntInRange(value)
1101
+ : (temporal?.order ?? toSqliteParameterValue(value)),
1102
+ identity: temporal ? sqliteTemporalIdentity(temporal) : undefined,
1103
+ }
1104
+ })
1105
+ let identity = ``
1106
+ if (
1107
+ listValue.some(
1108
+ (value) =>
1109
+ sqliteTemporalKind(value) ||
1110
+ (context === `index-expression` && typeof value === `string`),
1111
+ )
1112
+ ) {
1113
+ const leftIdentity = compiledArgs[0]?.identitySql ?? leftSql
1114
+ // Index expressions cannot contain subqueries. Both forms preserve
1115
+ // correlated membership and SQL's three-valued Boolean semantics.
1116
+ identity =
1117
+ context === `index-expression`
1118
+ ? ` AND (${joinSqlDisjunction(values.map(({ value, identity: nativeIdentity }) => `(${leftSql} = ${toSqliteExpressionLiteral(value)} AND ${leftIdentity} = ${toSqliteExpressionLiteral(nativeIdentity ?? value)})`))})`
1119
+ : ` AND ((${leftSql}, ${leftIdentity}) IN (VALUES ${[...new Set(values.map(({ value, identity: nativeIdentity }) => `(${toSqliteExpressionLiteral(value)}, ${toSqliteExpressionLiteral(nativeIdentity ?? value)})`))].join(`, `)}))`
1120
+ }
1014
1121
  if (context === `index-expression`) {
1015
1122
  return {
1016
1123
  supported: true,
1017
- sql: `(${leftSql} IN (${listValue
1018
- .map((value) => toSqliteExpressionLiteral(value))
1019
- .join(`, `)}))`,
1124
+ sql: `(${leftSql} IN (${values
1125
+ .map(({ value }) => toSqliteExpressionLiteral(value))
1126
+ .join(`, `)})${identity})`,
1020
1127
  params: leftParams,
1021
1128
  }
1022
1129
  }
1023
1130
 
1024
- const jsonList = `[${listValue
1025
- .map((value) =>
1131
+ const jsonList = `[${values
1132
+ .map(({ value }) =>
1026
1133
  typeof value === `bigint`
1027
1134
  ? assertSQLiteBigIntInRange(value).toString()
1028
- : JSON.stringify(toSqliteParameterValue(value)),
1135
+ : JSON.stringify(value),
1029
1136
  )
1030
1137
  .join(`,`)}]`
1031
1138
  return {
1032
1139
  supported: true,
1033
- sql: `(${leftSql} IN (SELECT value FROM json_each(?)))`,
1140
+ sql: `(${leftSql} IN (SELECT value FROM json_each(?))${identity})`,
1034
1141
  params: [...leftParams, jsonList],
1035
1142
  }
1036
1143
  }
@@ -1058,7 +1165,12 @@ function compileSqlExpression(
1058
1165
  case `concat`:
1059
1166
  return { supported: true, sql: `(${argSql.join(` || `)})`, params }
1060
1167
  case `coalesce`:
1061
- return { supported: true, sql: `COALESCE(${argSql.join(`, `)})`, params }
1168
+ return {
1169
+ supported: true,
1170
+ sql: `COALESCE(${argSql.join(`, `)})`,
1171
+ identitySql: `COALESCE(${compiledArgs.map((arg) => arg.identitySql ?? arg.sql).join(`, `)})`,
1172
+ params,
1173
+ }
1062
1174
  case `add`:
1063
1175
  return { supported: true, sql: `(${argSql[0]} + ${argSql[1]})`, params }
1064
1176
  case `subtract`:
@@ -1096,6 +1208,51 @@ function compileSqlExpression(
1096
1208
  }
1097
1209
  }
1098
1210
 
1211
+ /** A raw ref/native-literal leaf has exact native truth before prefilter broadening. */
1212
+ function nativeComparisonRef(
1213
+ expression: IR.BasicExpression,
1214
+ ): IR.PropRef | undefined {
1215
+ if (expression.type !== `func` || expression.args.length !== 2)
1216
+ return undefined
1217
+ const [left, right] = expression.args
1218
+ if (expression.name === `in`) {
1219
+ return left?.type === `ref` &&
1220
+ right?.type === `val` &&
1221
+ Array.isArray(right.value) &&
1222
+ right.value.length > 0 &&
1223
+ right.value.every((value) => sqliteTemporalKind(value))
1224
+ ? left
1225
+ : undefined
1226
+ }
1227
+ if (![`eq`, `gt`, `gte`, `lt`, `lte`].includes(expression.name))
1228
+ return undefined
1229
+ if (
1230
+ left?.type === `ref` &&
1231
+ right?.type === `val` &&
1232
+ sqliteTemporalKind(right.value)
1233
+ )
1234
+ return left
1235
+ if (
1236
+ right?.type === `ref` &&
1237
+ left?.type === `val` &&
1238
+ sqliteTemporalKind(left.value)
1239
+ )
1240
+ return right
1241
+ return undefined
1242
+ }
1243
+
1244
+ function compileNativeRefGuard(ref: IR.PropRef): string {
1245
+ const paths = createJsonPathVariants(
1246
+ IR.getPropRefPropertyPath(ref).map(String),
1247
+ )!
1248
+ const kinds = paths.map(
1249
+ (path) =>
1250
+ `json_extract(value, ${toSqliteLiteral(`${path}.${PERSISTED_TYPE_TAG}`)})`,
1251
+ )
1252
+ const kind = kinds.length === 1 ? kinds[0] : `COALESCE(${kinds.join(`, `)})`
1253
+ return `${kind} IN ('Temporal.Instant', 'Temporal.PlainDate')`
1254
+ }
1255
+
1099
1256
  function compileSafeSqlPrefilter(
1100
1257
  expression: IR.BasicExpression,
1101
1258
  compiled?: CompiledSqlFragment,
@@ -1139,7 +1296,37 @@ function compileSafeSqlPrefilter(
1139
1296
  compiled ??= compileSqlExpression(expression)
1140
1297
  if (!compiled.supported) return undefined
1141
1298
  const [left, right] = expression.args
1299
+ if (
1300
+ expression.name === `not` &&
1301
+ expression.args.length === 1 &&
1302
+ left &&
1303
+ nativeComparisonRef(left)
1304
+ ) {
1305
+ // Negate the original leaf, never a broadened prefilter. Nonfinite stored
1306
+ // scalars project to NULL; retaining them also retains valid NOT matches.
1307
+ return { ...compiled, sql: `COALESCE(${compiled.sql}, 1)` }
1308
+ }
1142
1309
  if (expression.args.length === 2) {
1310
+ const nativeRef = nativeComparisonRef(expression)
1311
+ if (nativeRef) {
1312
+ if (expression.name === `eq` || expression.name === `in`) return compiled
1313
+ // NaN orders above native values in the core evaluator. NULL candidates
1314
+ // also preserve that law with either operand direction.
1315
+ const fieldSql = compileSqlExpression(nativeRef, `index-expression`).sql
1316
+ return { ...compiled, sql: `(${compiled.sql} OR ${fieldSql} IS NULL)` }
1317
+ }
1318
+ if (
1319
+ expression.name === `eq` &&
1320
+ left?.type === `ref` &&
1321
+ right?.type === `ref`
1322
+ ) {
1323
+ // Ref/ref scalar coercions are not generally SQL-exact. Restrict only
1324
+ // native pairs; leave every other pair to the authoritative evaluator.
1325
+ return {
1326
+ ...compiled,
1327
+ sql: `(CASE WHEN ${compileNativeRefGuard(left)} AND ${compileNativeRefGuard(right)} THEN ${compiled.sql} ELSE 1 END)`,
1328
+ }
1329
+ }
1143
1330
  if (
1144
1331
  [`gt`, `gte`, `lt`, `lte`].includes(expression.name) &&
1145
1332
  (left?.type === `ref` || right?.type === `ref`)
@@ -1,4 +1,7 @@
1
- import { SQLiteBigIntOutOfRangeError } from './errors'
1
+ import {
2
+ InvalidPersistedCollectionConfigError,
3
+ SQLiteBigIntOutOfRangeError,
4
+ } from './errors'
2
5
 
3
6
  export const SQLITE_BIGINT_MIN = -9_223_372_036_854_775_808n
4
7
  export const SQLITE_BIGINT_MAX = 9_223_372_036_854_775_807n
@@ -28,3 +31,97 @@ export function serializeSQLiteBigInt(value: bigint): SerializedSQLiteBigInt {
28
31
  [PERSISTED_VALUE_TAG]: value.toString(),
29
32
  }
30
33
  }
34
+
35
+ type SQLiteTemporalKind = `Temporal.Instant` | `Temporal.PlainDate`
36
+ type SQLiteTemporal = {
37
+ epochNanoseconds: bigint
38
+ year: number
39
+ month: number
40
+ day: number
41
+ withCalendar: (calendar: string) => SQLiteTemporal
42
+ }
43
+ type TemporalConstructor = {
44
+ from: (value: string) => SQLiteTemporal
45
+ prototype: { toString: () => string }
46
+ }
47
+
48
+ export type SerializedSQLiteTemporal = {
49
+ [PERSISTED_TYPE_TAG]: SQLiteTemporalKind
50
+ [PERSISTED_VALUE_TAG]: string
51
+ order: string
52
+ }
53
+
54
+ export function sqliteTemporalKind(
55
+ value: unknown,
56
+ ): SQLiteTemporalKind | undefined {
57
+ if (value === null || typeof value !== `object`) return
58
+ const tag = (value as { [Symbol.toStringTag]?: unknown })[Symbol.toStringTag]
59
+ if (tag === `Temporal.Instant` || tag === `Temporal.PlainDate`) return tag
60
+ return undefined
61
+ }
62
+
63
+ function requireSQLiteTemporalConstructor(
64
+ kind: SQLiteTemporalKind,
65
+ ): TemporalConstructor {
66
+ const name = kind.slice(`Temporal.`.length)
67
+ const constructor = (
68
+ globalThis as { Temporal?: Record<string, TemporalConstructor> }
69
+ ).Temporal?.[name]
70
+ if (
71
+ typeof constructor?.from !== `function` ||
72
+ typeof constructor.prototype.toString !== `function`
73
+ ) {
74
+ throw new InvalidPersistedCollectionConfigError(
75
+ `Missing global ${kind} constructor for SQLite persistence`,
76
+ )
77
+ }
78
+ return constructor
79
+ }
80
+
81
+ export function reviveSQLiteTemporal(
82
+ kind: SQLiteTemporalKind,
83
+ value: string,
84
+ ): unknown {
85
+ return requireSQLiteTemporalConstructor(kind).from(value)
86
+ }
87
+
88
+ /** Registered prototype methods validate the brand; user toString is ignored. */
89
+ export function serializeSQLiteTemporal(
90
+ value: unknown,
91
+ ): SerializedSQLiteTemporal | undefined {
92
+ if (value === null || typeof value !== `object`) return
93
+ const prototype = Object.getPrototypeOf(value)
94
+ if (prototype === null || prototype === Object.prototype) return
95
+ const tag = (value as { [Symbol.toStringTag]?: unknown })[Symbol.toStringTag]
96
+ if (typeof tag !== `string` || !tag.startsWith(`Temporal.`)) return
97
+ const kind = sqliteTemporalKind(value)
98
+ if (!kind)
99
+ throw new InvalidPersistedCollectionConfigError(
100
+ `Unsupported ${tag} value for SQLite persistence`,
101
+ )
102
+ const constructor = requireSQLiteTemporalConstructor(kind)
103
+ let text: string
104
+ try {
105
+ text = constructor.prototype.toString.call(value)
106
+ } catch {
107
+ throw new InvalidPersistedCollectionConfigError(
108
+ `Invalid ${kind} value for the registered global constructor`,
109
+ )
110
+ }
111
+ const native = constructor.from(text)
112
+ const iso =
113
+ kind === `Temporal.PlainDate` ? native.withCalendar(`iso8601`) : undefined
114
+ // Text keys avoid both floating-point precision loss and SQLite's int64 cap.
115
+ const order = iso
116
+ ? `${String(iso.year + 1_000_000).padStart(7, `0`)}${String(iso.month).padStart(2, `0`)}${String(iso.day).padStart(2, `0`)}`
117
+ : (native.epochNanoseconds + 10_000_000_000_000_000_000_000n)
118
+ .toString()
119
+ .padStart(23, `0`)
120
+ return { [PERSISTED_TYPE_TAG]: kind, [PERSISTED_VALUE_TAG]: text, order }
121
+ }
122
+
123
+ export function sqliteTemporalIdentity(
124
+ value: SerializedSQLiteTemporal,
125
+ ): string {
126
+ return `${value[PERSISTED_TYPE_TAG]}:${value[PERSISTED_VALUE_TAG]}`
127
+ }