@donotdev/functions 0.0.8 → 0.0.9

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@donotdev/functions",
3
- "version": "0.0.8",
3
+ "version": "0.0.9",
4
4
  "private": false,
5
5
  "description": "Backend functions for DoNotDev Framework - Firebase, Vercel, and platform-agnostic implementations for auth, billing, CRUD, and OAuth",
6
6
  "main": "./lib/firebase/index.js",
@@ -42,15 +42,15 @@
42
42
  "serve": "firebase emulators:start --only functions"
43
43
  },
44
44
  "dependencies": {
45
- "@donotdev/core": "^0.0.18",
46
- "@donotdev/firebase": "^0.0.7"
45
+ "@donotdev/core": "^0.0.19",
46
+ "@donotdev/firebase": "^0.0.8"
47
47
  },
48
48
  "peerDependencies": {
49
- "@sentry/node": "^10.33.0",
49
+ "@sentry/node": "^10.38.0",
50
50
  "firebase-admin": "^13.6.0",
51
- "firebase-functions": "^7.0.1",
52
- "next": "^16.1.4",
53
- "stripe": "^20.1.0",
51
+ "firebase-functions": "^7.0.5",
52
+ "next": "^16.1.6",
53
+ "stripe": "^20.3.0",
54
54
  "valibot": "^1.2.0"
55
55
  },
56
56
  "repository": {
@@ -97,6 +97,34 @@ function canAggregateField(
97
97
  return isFieldVisible(fieldName, visibility as any, isAdmin);
98
98
  }
99
99
 
100
+ /**
101
+ * Returns the effective (discounted) amount for a price field.
102
+ * Matches display/formula: amount * (1 - discountPercent/100). Plain numbers unchanged.
103
+ */
104
+ function getPriceOrNumberValue(raw: unknown): number {
105
+ if (raw != null && typeof raw === 'object' && 'amount' in raw) {
106
+ const obj = raw as { amount?: unknown; discountPercent?: unknown };
107
+ const amount = Number(obj.amount);
108
+ if (!Number.isFinite(amount)) return NaN;
109
+ const discountPercent = Number(obj.discountPercent);
110
+ const pct = Number.isFinite(discountPercent)
111
+ ? Math.min(100, Math.max(0, discountPercent))
112
+ : 0;
113
+ return pct > 0 ? amount * (1 - pct / 100) : amount;
114
+ }
115
+ const n = Number(raw);
116
+ return Number.isFinite(n) ? n : NaN;
117
+ }
118
+
119
+ /**
120
+ * Returns the numeric value of a field for aggregation.
121
+ * For structured price fields uses effective (discounted) price (expected revenue).
122
+ * Plain numbers are used as-is.
123
+ */
124
+ function getNumericValue(doc: Record<string, any>, field: string): number {
125
+ return getPriceOrNumberValue(doc[field]);
126
+ }
127
+
100
128
  /**
101
129
  * Computes a single metric on a dataset
102
130
  */
@@ -106,19 +134,25 @@ function computeMetric(docs: any[], metric: MetricDefinition): number | null {
106
134
  if (metric.filter) {
107
135
  filtered = docs.filter((doc) => {
108
136
  const value = doc[metric.filter!.field];
137
+ const comparable =
138
+ value != null && typeof value === 'object' && 'amount' in value
139
+ ? getPriceOrNumberValue(value)
140
+ : typeof value === 'number' || value == null
141
+ ? value
142
+ : Number(value);
109
143
  switch (metric.filter!.operator) {
110
144
  case '==':
111
145
  return value === metric.filter!.value;
112
146
  case '!=':
113
147
  return value !== metric.filter!.value;
114
148
  case '>':
115
- return value > metric.filter!.value;
149
+ return comparable > metric.filter!.value;
116
150
  case '<':
117
- return value < metric.filter!.value;
151
+ return comparable < metric.filter!.value;
118
152
  case '>=':
119
- return value >= metric.filter!.value;
153
+ return comparable >= metric.filter!.value;
120
154
  case '<=':
121
- return value <= metric.filter!.value;
155
+ return comparable <= metric.filter!.value;
122
156
  default:
123
157
  return true;
124
158
  }
@@ -131,16 +165,16 @@ function computeMetric(docs: any[], metric: MetricDefinition): number | null {
131
165
 
132
166
  case 'sum': {
133
167
  return filtered.reduce((sum, doc) => {
134
- const val = Number(doc[metric.field]) || 0;
135
- return sum + val;
168
+ const val = getNumericValue(doc, metric.field);
169
+ return sum + (Number.isFinite(val) ? val : 0);
136
170
  }, 0);
137
171
  }
138
172
 
139
173
  case 'avg': {
140
174
  if (filtered.length === 0) return null;
141
175
  const sum = filtered.reduce((s, doc) => {
142
- const val = Number(doc[metric.field]) || 0;
143
- return s + val;
176
+ const val = getNumericValue(doc, metric.field);
177
+ return s + (Number.isFinite(val) ? val : 0);
144
178
  }, 0);
145
179
  return sum / filtered.length;
146
180
  }
@@ -149,8 +183,8 @@ function computeMetric(docs: any[], metric: MetricDefinition): number | null {
149
183
  if (filtered.length === 0) return null;
150
184
  return filtered.reduce(
151
185
  (min, doc) => {
152
- const val = Number(doc[metric.field]);
153
- if (isNaN(val)) return min;
186
+ const val = getNumericValue(doc, metric.field);
187
+ if (!Number.isFinite(val)) return min;
154
188
  return min === null ? val : Math.min(min, val);
155
189
  },
156
190
  null as number | null
@@ -161,8 +195,8 @@ function computeMetric(docs: any[], metric: MetricDefinition): number | null {
161
195
  if (filtered.length === 0) return null;
162
196
  return filtered.reduce(
163
197
  (max, doc) => {
164
- const val = Number(doc[metric.field]);
165
- if (isNaN(val)) return max;
198
+ const val = getNumericValue(doc, metric.field);
199
+ if (!Number.isFinite(val)) return max;
166
200
  return max === null ? val : Math.max(max, val);
167
201
  },
168
202
  null as number | null
@@ -12,7 +12,7 @@
12
12
  import * as v from 'valibot';
13
13
 
14
14
  import { DEFAULT_STATUS_VALUE } from '@donotdev/core/server';
15
- import type { UserRole } from '@donotdev/core/server';
15
+ import type { UserRole, UniqueKeyDefinition } from '@donotdev/core/server';
16
16
  import { getFirebaseAdminFirestore } from '@donotdev/firebase/server';
17
17
 
18
18
  import {
@@ -20,7 +20,7 @@ import {
20
20
  transformFirestoreData,
21
21
  } from '../../shared/index.js';
22
22
  import { createMetadata } from '../../shared/index.js';
23
- import { validateDocument } from '../../shared/utils.js';
23
+ import { DoNotDevError, validateDocument } from '../../shared/utils.js';
24
24
  import { createBaseFunction } from '../baseFunction.js';
25
25
  import { CRUD_CONFIG } from '../config/constants.js';
26
26
 
@@ -28,17 +28,123 @@ import type {
28
28
  CallableFunction,
29
29
  CallableRequest,
30
30
  } from 'firebase-functions/v2/https';
31
+ import type { Query, DocumentData } from 'firebase-admin/firestore';
31
32
 
32
33
  export type CreateEntityRequest = {
33
34
  payload: Record<string, any>;
34
35
  idempotencyKey?: string;
35
36
  };
36
37
 
38
+ /**
39
+ * Result of unique key check
40
+ */
41
+ type UniqueKeyCheckResult =
42
+ | { found: false }
43
+ | { found: true; existingDoc: Record<string, any>; findOrCreate: boolean };
44
+
45
+ /**
46
+ * Checks unique key constraints against existing documents
47
+ * Returns the first matching document if findOrCreate is enabled
48
+ *
49
+ * @param collection - Firestore collection name
50
+ * @param payload - Document data to check
51
+ * @param uniqueKeys - Unique key definitions from entity
52
+ * @param isDraft - Whether the document is a draft
53
+ * @returns Check result with existing document if found
54
+ */
55
+ /**
56
+ * Normalize a value for case-insensitive comparison
57
+ * Lowercases strings, leaves other types unchanged
58
+ */
59
+ function normalizeValue(value: unknown): unknown {
60
+ if (typeof value === 'string') {
61
+ return value.toLowerCase();
62
+ }
63
+ return value;
64
+ }
65
+
66
+ /**
67
+ * Normalize unique key fields in payload to lowercase (for strings)
68
+ * Returns a new object with normalized values for unique key fields
69
+ */
70
+ function normalizePayloadForUniqueKeys(
71
+ payload: Record<string, any>,
72
+ uniqueKeys: UniqueKeyDefinition[]
73
+ ): Record<string, any> {
74
+ const normalized = { ...payload };
75
+ for (const uniqueKey of uniqueKeys) {
76
+ for (const field of uniqueKey.fields) {
77
+ if (typeof normalized[field] === 'string') {
78
+ normalized[field] = normalized[field].toLowerCase();
79
+ }
80
+ }
81
+ }
82
+ return normalized;
83
+ }
84
+
85
+ async function checkUniqueKeys(
86
+ collection: string,
87
+ payload: Record<string, any>,
88
+ uniqueKeys: UniqueKeyDefinition[],
89
+ isDraft: boolean
90
+ ): Promise<UniqueKeyCheckResult> {
91
+ const db = getFirebaseAdminFirestore();
92
+
93
+ for (const uniqueKey of uniqueKeys) {
94
+ // Skip validation for drafts if configured (default: true)
95
+ const skipForDrafts = uniqueKey.skipForDrafts !== false;
96
+ if (isDraft && skipForDrafts) continue;
97
+
98
+ // Check if all fields in the unique key have values
99
+ const allFieldsHaveValues = uniqueKey.fields.every(
100
+ (field) => payload[field] != null && payload[field] !== ''
101
+ );
102
+ if (!allFieldsHaveValues) continue;
103
+
104
+ // Build query for all fields in this unique key
105
+ // Normalize values to lowercase for case-insensitive matching
106
+ let query: Query<DocumentData> = db.collection(collection);
107
+ for (const field of uniqueKey.fields) {
108
+ query = query.where(field, '==', normalizeValue(payload[field]));
109
+ }
110
+
111
+ const existing = await query.limit(1).get();
112
+
113
+ if (!existing.empty) {
114
+ const firstDoc = existing.docs[0]!;
115
+ const existingDoc = transformFirestoreData({
116
+ id: firstDoc.id,
117
+ ...firstDoc.data(),
118
+ });
119
+
120
+ if (uniqueKey.findOrCreate) {
121
+ // Return existing document for findOrCreate behavior
122
+ return { found: true, existingDoc, findOrCreate: true };
123
+ }
124
+
125
+ // Throw duplicate error
126
+ const fieldNames = uniqueKey.fields.join(' + ');
127
+ throw new DoNotDevError(
128
+ uniqueKey.errorMessage || `Duplicate ${fieldNames}`,
129
+ 'already-exists',
130
+ {
131
+ details: {
132
+ fields: uniqueKey.fields,
133
+ existingId: firstDoc.id,
134
+ },
135
+ }
136
+ );
137
+ }
138
+ }
139
+
140
+ return { found: false };
141
+ }
142
+
37
143
  /**
38
144
  * Generic business logic for creating entities
39
145
  * Base function handles: validation, auth, rate limiting, monitoring
40
146
  *
41
- * @version 0.0.1
147
+ * @version 0.0.2
42
148
  * @since 0.0.1
43
149
  * @author AMBROISE PARK Consulting
44
150
  */
@@ -69,16 +175,44 @@ function createEntityLogicFactory(
69
175
  const status = payload.status ?? DEFAULT_STATUS_VALUE;
70
176
  const isDraft = status === 'draft';
71
177
 
178
+ // Check unique keys if schema has metadata with uniqueKeys
179
+ // This handles findOrCreate behavior and duplicate prevention
180
+ const schemaWithMeta = documentSchema as {
181
+ metadata?: { uniqueKeys?: UniqueKeyDefinition[] };
182
+ };
183
+ const uniqueKeys = schemaWithMeta.metadata?.uniqueKeys;
184
+
185
+ if (uniqueKeys && uniqueKeys.length > 0) {
186
+ const checkResult = await checkUniqueKeys(
187
+ collection,
188
+ payload,
189
+ uniqueKeys,
190
+ isDraft
191
+ );
192
+
193
+ if (checkResult.found && checkResult.findOrCreate) {
194
+ // Return existing document instead of creating new one
195
+ return checkResult.existingDoc;
196
+ }
197
+ // If found but not findOrCreate, checkUniqueKeys already threw an error
198
+ }
199
+
200
+ // Normalize unique key fields to lowercase for case-insensitive storage
201
+ const normalizedPayload =
202
+ uniqueKeys && uniqueKeys.length > 0
203
+ ? normalizePayloadForUniqueKeys(payload, uniqueKeys)
204
+ : payload;
205
+
72
206
  // Validate the document against the schema
73
207
  // Skip validation for drafts - required fields can be incomplete
74
208
  if (!isDraft) {
75
- validateDocument(payload, documentSchema);
209
+ validateDocument(normalizedPayload, documentSchema);
76
210
  }
77
211
 
78
212
  // Prepare the document for Firestore and add metadata
79
213
  // Always ensure status is set
80
214
  const documentData = {
81
- ...prepareForFirestore(payload),
215
+ ...prepareForFirestore(normalizedPayload),
82
216
  status, // Ensure status is always present
83
217
  ...createMetadata(uid),
84
218
  };
@@ -128,10 +128,41 @@ function listEntitiesLogicFactory(
128
128
  const schemaHasEntries = !!(documentSchema as any)?.entries;
129
129
  console.log(`[listEntities] Schema has entries: ${schemaHasEntries}`);
130
130
 
131
+ // Helper: Check if value is a Picture object (has thumbUrl and fullUrl)
132
+ const isPictureObject = (value: any): boolean => {
133
+ return (
134
+ typeof value === 'object' &&
135
+ value !== null &&
136
+ 'thumbUrl' in value &&
137
+ 'fullUrl' in value
138
+ );
139
+ };
140
+
141
+ // Helper: Optimize picture fields for listCard (only return first picture's thumbUrl)
142
+ const optimizePictureField = (value: any): any => {
143
+ if (Array.isArray(value) && value.length > 0) {
144
+ // Array of pictures - return just the first picture's thumbUrl
145
+ const firstPicture = value[0];
146
+ if (isPictureObject(firstPicture)) {
147
+ return firstPicture.thumbUrl || firstPicture.fullUrl || null;
148
+ }
149
+ // If first item is a string, return it as-is
150
+ if (typeof firstPicture === 'string') {
151
+ return firstPicture;
152
+ }
153
+ } else if (isPictureObject(value)) {
154
+ // Single picture object - return thumbUrl
155
+ return value.thumbUrl || value.fullUrl || null;
156
+ }
157
+ // Not a picture, return as-is
158
+ return value;
159
+ };
160
+
131
161
  // Filter document fields based on visibility and user role
132
162
  const docs = snapshot.docs.map((doc: any) => {
163
+ const rawData = doc.data() || {};
133
164
  const visibleData = filterVisibleFields(
134
- doc.data() || {},
165
+ rawData,
135
166
  documentSchema,
136
167
  userRole
137
168
  );
@@ -141,24 +172,23 @@ function listEntitiesLogicFactory(
141
172
  const filtered: Record<string, any> = { id: doc.id };
142
173
  for (const field of listFields) {
143
174
  if (field in visibleData) {
144
- filtered[field] = visibleData[field];
175
+ const value = visibleData[field];
176
+ // Optimize picture fields for list views (only need first thumbUrl)
177
+ filtered[field] = optimizePictureField(value);
145
178
  }
146
179
  }
147
180
  return filtered;
148
181
  }
149
182
 
150
183
  // No listFields restriction, return all visible fields
151
- return {
152
- id: doc.id,
153
- ...visibleData,
154
- };
184
+ // Still optimize picture fields for all list queries (datatables only need thumbUrl)
185
+ const optimizedData: Record<string, any> = { id: doc.id };
186
+ for (const [key, value] of Object.entries(visibleData)) {
187
+ optimizedData[key] = optimizePictureField(value);
188
+ }
189
+ return optimizedData;
155
190
  });
156
191
 
157
- // DEBUG: Log filtered docs
158
- console.log(
159
- `[listEntities] Filtered docs count: ${docs.length}, first doc keys: ${docs[0] ? Object.keys(docs[0]).join(', ') : 'N/A'}`
160
- );
161
-
162
192
  // Return the paginated result with metadata
163
193
  return {
164
194
  items: transformFirestoreData(docs),
@@ -12,7 +12,7 @@
12
12
  import * as v from 'valibot';
13
13
 
14
14
  import { DEFAULT_STATUS_VALUE } from '@donotdev/core/server';
15
- import type { UserRole } from '@donotdev/core/server';
15
+ import type { UserRole, UniqueKeyDefinition } from '@donotdev/core/server';
16
16
  import { getFirebaseAdminFirestore } from '@donotdev/firebase/server';
17
17
 
18
18
  import {
@@ -28,6 +28,7 @@ import type {
28
28
  CallableFunction,
29
29
  CallableRequest,
30
30
  } from 'firebase-functions/v2/https';
31
+ import type { Query, DocumentData } from 'firebase-admin/firestore';
31
32
 
32
33
  export type UpdateEntityRequest = {
33
34
  id: string;
@@ -35,9 +36,110 @@ export type UpdateEntityRequest = {
35
36
  idempotencyKey?: string;
36
37
  };
37
38
 
39
+ /**
40
+ * Normalize a value for case-insensitive comparison
41
+ * Lowercases strings, leaves other types unchanged
42
+ */
43
+ function normalizeValue(value: unknown): unknown {
44
+ if (typeof value === 'string') {
45
+ return value.toLowerCase();
46
+ }
47
+ return value;
48
+ }
49
+
50
+ /**
51
+ * Normalize unique key fields in payload to lowercase (for strings)
52
+ * Returns a new object with normalized values for unique key fields
53
+ */
54
+ function normalizePayloadForUniqueKeys(
55
+ payload: Record<string, any>,
56
+ uniqueKeys: UniqueKeyDefinition[]
57
+ ): Record<string, any> {
58
+ const normalized = { ...payload };
59
+ for (const uniqueKey of uniqueKeys) {
60
+ for (const field of uniqueKey.fields) {
61
+ if (typeof normalized[field] === 'string') {
62
+ normalized[field] = normalized[field].toLowerCase();
63
+ }
64
+ }
65
+ }
66
+ return normalized;
67
+ }
68
+
69
+ /**
70
+ * Checks unique key constraints for updates
71
+ * Only checks if unique key fields are being modified
72
+ * Excludes the current document from conflict check
73
+ *
74
+ * @param collection - Firestore collection name
75
+ * @param currentDocId - ID of document being updated (excluded from check)
76
+ * @param mergedData - Merged document data (current + payload)
77
+ * @param payload - Update payload (only fields being changed)
78
+ * @param uniqueKeys - Unique key definitions from entity
79
+ * @param isDraft - Whether the resulting document is a draft
80
+ */
81
+ async function checkUniqueKeysForUpdate(
82
+ collection: string,
83
+ currentDocId: string,
84
+ mergedData: Record<string, any>,
85
+ payload: Record<string, any>,
86
+ uniqueKeys: UniqueKeyDefinition[],
87
+ isDraft: boolean
88
+ ): Promise<void> {
89
+ const db = getFirebaseAdminFirestore();
90
+
91
+ for (const uniqueKey of uniqueKeys) {
92
+ // Skip validation for drafts if configured (default: true)
93
+ const skipForDrafts = uniqueKey.skipForDrafts !== false;
94
+ if (isDraft && skipForDrafts) continue;
95
+
96
+ // Check if any of the unique key fields are being updated
97
+ const isUpdatingUniqueKeyField = uniqueKey.fields.some(
98
+ (field) => field in payload
99
+ );
100
+ if (!isUpdatingUniqueKeyField) continue;
101
+
102
+ // Check if all fields in the unique key have values in merged data
103
+ const allFieldsHaveValues = uniqueKey.fields.every(
104
+ (field) => mergedData[field] != null && mergedData[field] !== ''
105
+ );
106
+ if (!allFieldsHaveValues) continue;
107
+
108
+ // Build query for all fields in this unique key
109
+ // Normalize values to lowercase for case-insensitive matching
110
+ let query: Query<DocumentData> = db.collection(collection);
111
+ for (const field of uniqueKey.fields) {
112
+ query = query.where(field, '==', normalizeValue(mergedData[field]));
113
+ }
114
+
115
+ const existing = await query.limit(2).get(); // Get 2 to check if another doc exists
116
+
117
+ // Check if any matching document is NOT the current document
118
+ const conflictingDoc = existing.docs.find((doc) => doc.id !== currentDocId);
119
+
120
+ if (conflictingDoc) {
121
+ const fieldNames = uniqueKey.fields.join(' + ');
122
+ throw new DoNotDevError(
123
+ uniqueKey.errorMessage || `Duplicate ${fieldNames}`,
124
+ 'already-exists',
125
+ {
126
+ details: {
127
+ fields: uniqueKey.fields,
128
+ existingId: conflictingDoc.id,
129
+ },
130
+ }
131
+ );
132
+ }
133
+ }
134
+ }
135
+
38
136
  /**
39
137
  * Generic business logic for updating entities
40
138
  * Base function handles: validation, auth, rate limiting, monitoring
139
+ *
140
+ * @version 0.0.2
141
+ * @since 0.0.1
142
+ * @author AMBROISE PARK Consulting
41
143
  */
42
144
  function updateEntityLogicFactory(
43
145
  collection: string,
@@ -80,15 +182,39 @@ function updateEntityLogicFactory(
80
182
  const resultingStatus = mergedData.status ?? DEFAULT_STATUS_VALUE;
81
183
  const isDraft = resultingStatus === 'draft';
82
184
 
185
+ // Check unique keys if schema has metadata with uniqueKeys
186
+ // Only checks fields that are being updated
187
+ const schemaWithMeta = documentSchema as {
188
+ metadata?: { uniqueKeys?: UniqueKeyDefinition[] };
189
+ };
190
+ const uniqueKeys = schemaWithMeta.metadata?.uniqueKeys;
191
+
192
+ if (uniqueKeys && uniqueKeys.length > 0) {
193
+ await checkUniqueKeysForUpdate(
194
+ collection,
195
+ id,
196
+ mergedData,
197
+ payload,
198
+ uniqueKeys,
199
+ isDraft
200
+ );
201
+ }
202
+
83
203
  // Validate the merged document against the schema
84
204
  // Skip validation for drafts - required fields can be incomplete
85
205
  if (!isDraft) {
86
206
  validateDocument(mergedData, documentSchema);
87
207
  }
88
208
 
209
+ // Normalize unique key fields to lowercase for case-insensitive storage
210
+ const normalizedPayload =
211
+ uniqueKeys && uniqueKeys.length > 0
212
+ ? normalizePayloadForUniqueKeys(payload, uniqueKeys)
213
+ : payload;
214
+
89
215
  // Prepare the update data for Firestore and add metadata
90
216
  const updateData = {
91
- ...prepareForFirestore(payload),
217
+ ...prepareForFirestore(normalizedPayload),
92
218
  ...updateMetadata(uid),
93
219
  };
94
220
 
@@ -158,7 +158,8 @@ export function transformFirestoreData<T = any>(
158
158
  }
159
159
 
160
160
  /**
161
- * Recursively prepares data for Firestore by converting ISO strings or Date objects to Timestamps.
161
+ * Recursively prepares data for Firestore by converting Date objects to ISO strings.
162
+ * ISO strings are kept as-is (no conversion needed).
162
163
  * @param data - The data to prepare
163
164
  * @param removeFields - Optional array of field names to remove
164
165
  * @returns The prepared data
@@ -189,14 +190,11 @@ export function prepareForFirestore<T = any>(
189
190
  continue;
190
191
  }
191
192
 
192
- // Handle Date objects
193
+ // Handle Date objects - convert to ISO string
193
194
  if (value instanceof Date) {
194
- prepared[key] = toTimestamp(value);
195
- }
196
- // Handle ISO strings that look like dates
197
- else if (typeof value === 'string' && isISODateString(value)) {
198
- prepared[key] = toTimestamp(value);
195
+ prepared[key] = value.toISOString();
199
196
  }
197
+ // ISO strings stay as strings - no conversion needed
200
198
  // Handle nested objects and arrays
201
199
  else {
202
200
  prepared[key] = prepareForFirestore(value, removeFields);
@@ -205,15 +203,12 @@ export function prepareForFirestore<T = any>(
205
203
  return prepared as T;
206
204
  }
207
205
 
208
- // Handle Date objects at root level
206
+ // Handle Date objects at root level - convert to ISO string
209
207
  if (data instanceof Date) {
210
- return toTimestamp(data) as unknown as T;
208
+ return data.toISOString() as unknown as T;
211
209
  }
212
210
 
213
- // Handle ISO strings at root level
214
- if (typeof data === 'string' && isISODateString(data)) {
215
- return toTimestamp(data) as unknown as T;
216
- }
211
+ // ISO strings stay as strings - no conversion needed
217
212
 
218
213
  // Return primitive values unchanged
219
214
  return data;