@@ -35,7 +35,6 @@ type QueryDialect struct {
3535 dateStartExpr func (func (string ) string ) string
3636 dateEndExpr func (func (string ) string ) string
3737 dateParam func (string ) string
38- activityExpr string
3938 activityParam func (string ) string
4039 cursorActivityExpr string
4140 cursorParam func (string ) string
@@ -71,18 +70,18 @@ func SQLiteQueryDialect() QueryDialect {
7170 trueLiteral : "1" ,
7271 falseLiteral : "0" ,
7372 dateStartExpr : func (q func (string ) string ) string {
74- return "date (COALESCE(NULLIF(" + q ("started_at" ) +
73+ return "julianday (COALESCE(NULLIF(" + q ("started_at" ) +
7574 ", ''), " + q ("created_at" ) + "))"
7675 },
7776 dateEndExpr : func (q func (string ) string ) string {
78- return "date (COALESCE(NULLIF(" + q ("ended_at" ) +
79- ", ''), (SELECT MAX( m.timestamp) FROM messages m" +
77+ return "julianday (COALESCE(NULLIF(" + q ("ended_at" ) +
78+ ", ''), (SELECT m.timestamp FROM messages m" +
8079 " WHERE m.session_id = " + outerSessionID (q ) +
81- " AND m.timestamp != ''), NULLIF(" + q ("started_at" ) +
80+ " AND m.timestamp != '' ORDER BY julianday(m.timestamp)" +
81+ " DESC, m.timestamp DESC LIMIT 1), NULLIF(" + q ("started_at" ) +
8282 ", ''), " + q ("created_at" ) + "))"
8383 },
84- dateParam : func (ph string ) string { return ph },
85- activityExpr : "COALESCE(NULLIF(ended_at, ''), NULLIF(started_at, ''), created_at)" ,
84+ dateParam : func (ph string ) string { return "julianday(" + ph + ")" },
8685 activityParam : func (ph string ) string { return ph },
8786 cursorActivityExpr : "COALESCE(NULLIF(ended_at, ''), NULLIF(started_at, ''), created_at)" ,
8887 cursorParam : func (ph string ) string { return ph },
@@ -109,18 +108,17 @@ func PostgresQueryDialect() QueryDialect {
109108 trueLiteral : "TRUE" ,
110109 falseLiteral : "FALSE" ,
111110 dateStartExpr : func (q func (string ) string ) string {
112- return "DATE( COALESCE(" + q ("started_at" ) + ", " +
113- q ("created_at" ) + ") AT TIME ZONE 'UTC') "
111+ return "COALESCE(" + q ("started_at" ) + ", " +
112+ q ("created_at" ) + ")"
114113 },
115114 dateEndExpr : func (q func (string ) string ) string {
116- return "DATE( COALESCE(" + q ("ended_at" ) +
115+ return "COALESCE(" + q ("ended_at" ) +
117116 ", (SELECT MAX(m.timestamp) FROM messages m" +
118117 " WHERE m.session_id = " + outerSessionID (q ) +
119118 " AND m.timestamp IS NOT NULL), " + q ("started_at" ) +
120- ", " + q ("created_at" ) + ") AT TIME ZONE 'UTC') "
119+ ", " + q ("created_at" ) + ")"
121120 },
122- dateParam : func (ph string ) string { return ph + "::date" },
123- activityExpr : "COALESCE(ended_at, started_at, created_at)" ,
121+ dateParam : func (ph string ) string { return ph + "::timestamptz" },
124122 activityParam : func (ph string ) string {
125123 return ph + "::timestamptz"
126124 },
@@ -152,19 +150,18 @@ func DuckDBQueryDialect() QueryDialect {
152150 falseLiteral : "FALSE" ,
153151 dateStartExpr : func (q func (string ) string ) string {
154152 return "CAST(COALESCE(" + q ("started_at" ) + ", " +
155- q ("created_at" ) + ") AS DATE )"
153+ q ("created_at" ) + ") AS TIMESTAMP )"
156154 },
157155 dateEndExpr : func (q func (string ) string ) string {
158156 return "CAST(COALESCE(" + q ("ended_at" ) +
159157 ", (SELECT MAX(m.timestamp) FROM messages m" +
160158 " WHERE m.session_id = " + outerSessionID (q ) +
161159 " AND m.timestamp IS NOT NULL), " + q ("started_at" ) +
162- ", " + q ("created_at" ) + ") AS DATE )"
160+ ", " + q ("created_at" ) + ") AS TIMESTAMP )"
163161 },
164162 dateParam : func (ph string ) string {
165- return "CAST(" + ph + " AS DATE )"
163+ return "CAST(" + ph + " AS TIMESTAMP )"
166164 },
167- activityExpr : "COALESCE(ended_at, started_at, created_at)" ,
168165 activityParam : func (ph string ) string { return "CAST(" + ph + " AS TIMESTAMP)" },
169166 cursorActivityExpr : "COALESCE(ended_at, started_at, created_at)" ,
170167 cursorParam : func (ph string ) string {
@@ -210,6 +207,50 @@ func (d QueryDialect) Qualify(parts ...string) string {
210207
211208var safeIdentifierRE = regexp .MustCompile (`^[A-Za-z_][A-Za-z0-9_]*$` )
212209
210+ // NormalizeSessionTimezone validates an IANA timezone name and returns the
211+ // canonical UTC default used when callers omit it. "Local" is intentionally
212+ // rejected because it depends on the server environment rather than naming a
213+ // browser-selected zone.
214+ func NormalizeSessionTimezone (name string ) (string , error ) {
215+ name = strings .TrimSpace (name )
216+ if name == "" {
217+ return "UTC" , nil
218+ }
219+ if name == "Local" {
220+ return "" , fmt .Errorf ("invalid timezone: %s" , name )
221+ }
222+ loc , err := time .LoadLocation (name )
223+ if err != nil {
224+ return "" , fmt .Errorf ("invalid timezone: %s" , name )
225+ }
226+ return loc .String (), nil
227+ }
228+
229+ func sessionDateBoundary (date , timezone string , nextDay bool ) string {
230+ name , err := NormalizeSessionTimezone (timezone )
231+ if err != nil {
232+ // Public HTTP and service inputs validate before reaching the store.
233+ // Keep internal store callers deterministic if they violate that
234+ // contract rather than making SQL construction panic.
235+ name = "UTC"
236+ }
237+ loc , err := time .LoadLocation (name )
238+ if err != nil {
239+ loc = time .UTC
240+ }
241+ boundary , err := time .ParseInLocation (time .DateOnly , date , loc )
242+ if err != nil {
243+ // Dates are likewise validated at public boundaries. Returning the
244+ // original value preserves the historical behavior for invalid
245+ // internal filters.
246+ return date
247+ }
248+ if nextDay {
249+ boundary = boundary .AddDate (0 , 0 , 1 )
250+ }
251+ return boundary .UTC ().Format (time .RFC3339 )
252+ }
253+
213254// QueryBuilder allocates dialect placeholders and collects bind parameters.
214255type QueryBuilder struct {
215256 dialect QueryDialect
@@ -554,21 +595,29 @@ func sessionFilterPredicates(
554595 }
555596 if f .Date != "" {
556597 preds = append (preds , "(" + b .dialect .dateEndExpr (q )+ " >= " +
557- b .dialect .dateParam (b .Add (f .Date ))+ " AND " +
558- b .dialect .dateStartExpr (q )+ " <= " +
559- b .dialect .dateParam (b .Add (f .Date ))+ ")" )
598+ b .dialect .dateParam (b .Add (sessionDateBoundary (
599+ f .Date , f .Timezone , false ,
600+ )))+ " AND " +
601+ b .dialect .dateStartExpr (q )+ " < " +
602+ b .dialect .dateParam (b .Add (sessionDateBoundary (
603+ f .Date , f .Timezone , true ,
604+ )))+ ")" )
560605 }
561606 if f .DateFrom != "" {
562607 preds = append (preds , b .dialect .dateEndExpr (q )+ " >= " +
563- b .dialect .dateParam (b .Add (f .DateFrom )))
608+ b .dialect .dateParam (b .Add (sessionDateBoundary (
609+ f .DateFrom , f .Timezone , false ,
610+ ))))
564611 }
565612 if f .DateTo != "" {
566- preds = append (preds , b .dialect .dateStartExpr (q )+ " <= " +
567- b .dialect .dateParam (b .Add (f .DateTo )))
613+ preds = append (preds , b .dialect .dateStartExpr (q )+ " < " +
614+ b .dialect .dateParam (b .Add (sessionDateBoundary (
615+ f .DateTo , f .Timezone , true ,
616+ ))))
568617 }
569618 if f .ActiveSince != "" {
570- preds = append (preds , b .dialect .activityExpr + " >= " +
571- b .dialect .activityParam (b .Add (f .ActiveSince )))
619+ preds = append (preds , b .dialect .dateEndExpr ( q ) + " >= " +
620+ b .dialect .dateParam (b .Add (f .ActiveSince )))
572621 }
573622 if f .MinMessages > 0 {
574623 preds = append (preds ,
0 commit comments