Skip to content

Commit 101d5c3

Browse files
committed
Add i18n, analytics, turso aggregate, auth date normalization
1 parent 8aa66f4 commit 101d5c3

17 files changed

Lines changed: 353 additions & 36 deletions

File tree

apps/cloud/wrangler.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ compatibility_flags = ["nodejs_compat"]
4545
# rebuild + re-push to ship a new version, then run `wrangler deploy`.
4646
[[containers]]
4747
class_name = "CloudContainer"
48-
image = "registry.cloudflare.com/2846eb40a60f4738e292b90dcd8cce10/objectstack-cloud:direct-storage"
48+
image = "registry.cloudflare.com/2846eb40a60f4738e292b90dcd8cce10/objectstack-cloud:8aa66f48"
4949
max_instances = 3
5050
instance_type = "standard-1"
5151

apps/objectos/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"@objectstack/service-automation": "workspace:*",
4949
"@objectstack/service-cloud": "workspace:*",
5050
"@objectstack/service-feed": "workspace:*",
51+
"@objectstack/service-i18n": "workspace:*",
5152
"@objectstack/service-package": "workspace:*",
5253
"@objectstack/service-tenant": "workspace:*",
5354
"@objectstack/spec": "workspace:*",

apps/objectos/wrangler.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ compatibility_flags = ["nodejs_compat"]
4444
# rebuild + re-push to ship a new version, then run `wrangler deploy`.
4545
[[containers]]
4646
class_name = "ObjectOSContainer"
47-
image = "registry.cloudflare.com/2846eb40a60f4738e292b90dcd8cce10/objectos:per-project-auth-14"
47+
image = "registry.cloudflare.com/2846eb40a60f4738e292b90dcd8cce10/objectos:turso-aggregate-1"
4848
max_instances = 5
4949
instance_type = "standard-1"
5050

packages/objectql/src/plugin.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,28 @@ export class ObjectQLPlugin implements Plugin {
149149

150150
ctx.registerService('protocol', protocolShim);
151151
ctx.logger.info('Protocol service registered');
152+
153+
// Register an `analytics` service adapter that maps the dispatcher's
154+
// expected interface (query / getMeta / generateSql) onto the
155+
// protocol shim's `analyticsQuery`. Without this, HttpDispatcher's
156+
// `handleAnalytics` cannot resolve a service and `/api/v1/analytics/*`
157+
// returns ROUTE_NOT_FOUND, even though discovery advertises the route
158+
// (objectql's getDiscovery hardcodes `analytics: enabled:true`). The
159+
// adapter delegates `query` to the cube → engine.aggregate translator
160+
// already implemented in protocol.ts; getMeta/generateSql return a
161+
// structured "not implemented" payload so callers see something
162+
// useful instead of a 500.
163+
ctx.registerService('analytics', {
164+
query: (body: any) => protocolShim.analyticsQuery(body),
165+
getMeta: async () => ({
166+
cubes: [],
167+
message: 'Analytics meta endpoint not implemented by ObjectQL adapter',
168+
}),
169+
generateSql: async (_body: any) => ({
170+
sql: null,
171+
message: 'Analytics SQL generation not implemented by ObjectQL adapter',
172+
}),
173+
});
152174
}
153175

154176
start = async (ctx: PluginContext) => {

packages/plugins/driver-turso/src/remote-transport.ts

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,67 @@ export class RemoteTransport {
174174
}
175175
}
176176

177+
async aggregate(object: string, query: any): Promise<Record<string, unknown>[]> {
178+
await this.ensureConnected();
179+
this.assertSafeIdentifier(object);
180+
181+
const selectParts: string[] = [];
182+
const groupBy: string[] = Array.isArray(query?.groupBy) ? query.groupBy : [];
183+
184+
for (const field of groupBy) {
185+
this.assertSafeIdentifier(field);
186+
selectParts.push(`"${field}"`);
187+
}
188+
189+
const aggregations = query?.aggregations || query?.aggregate || [];
190+
for (const agg of aggregations) {
191+
const funcRaw = String(agg.function || agg.func || '').toLowerCase();
192+
if (!['count', 'sum', 'avg', 'min', 'max'].includes(funcRaw)) {
193+
throw new Error(`Unsupported aggregate function: ${funcRaw}`);
194+
}
195+
const field = agg.field || '*';
196+
let fieldSql: string;
197+
if (field === '*') {
198+
fieldSql = '*';
199+
} else {
200+
this.assertSafeIdentifier(field);
201+
fieldSql = `"${field}"`;
202+
}
203+
const alias = agg.alias || `${funcRaw}_${field === '*' ? 'all' : field}`;
204+
this.assertSafeIdentifier(alias);
205+
selectParts.push(`${funcRaw}(${fieldSql}) AS "${alias}"`);
206+
}
207+
208+
if (selectParts.length === 0) selectParts.push('*');
209+
210+
let sql = `SELECT ${selectParts.join(', ')} FROM "${object}"`;
211+
const args: any[] = [];
212+
213+
const { whereClauses, args: whereArgs } = this.buildWhereSQL(query?.where);
214+
if (whereClauses) {
215+
sql += ` WHERE ${whereClauses}`;
216+
args.push(...whereArgs);
217+
}
218+
219+
if (groupBy.length > 0) {
220+
sql += ` GROUP BY ${groupBy.map((f) => `"${f}"`).join(', ')}`;
221+
}
222+
223+
try {
224+
const result = await this.client!.execute({ sql, args });
225+
return this.mapRows(result);
226+
} catch (error: any) {
227+
if (
228+
error.message &&
229+
(error.message.includes('no such table') ||
230+
error.message.includes('no such column'))
231+
) {
232+
return [];
233+
}
234+
throw error;
235+
}
236+
}
237+
177238
async create(object: string, data: Record<string, unknown>): Promise<Record<string, unknown>> {
178239
await this.ensureConnected();
179240

@@ -765,11 +826,15 @@ export class RemoteTransport {
765826

766827
/**
767828
* Serialize a value for @libsql/client args.
768-
* JSON objects/arrays are stringified; booleans are kept as-is (libsql handles them).
829+
* - `Date` → ISO 8601 string (avoids libsql HTTP transport coercing the
830+
* value to a REAL column and round-tripping it as `"<epoch>.0"`).
831+
* - JSON objects/arrays are stringified.
832+
* - booleans are kept as-is (libsql handles them).
769833
*/
770834
private serializeValue(value: unknown): any {
771835
if (value === null || value === undefined) return null;
772-
if (typeof value === 'object' && !(value instanceof Date)) {
836+
if (value instanceof Date) return value.toISOString();
837+
if (typeof value === 'object') {
773838
return JSON.stringify(value);
774839
}
775840
return value;

packages/plugins/driver-turso/src/turso-driver.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,11 @@ export class TursoDriver extends SqlDriver {
491491
return super.count(object, query, options);
492492
}
493493

494+
override async aggregate(object: string, query: any, options?: any): Promise<any> {
495+
if (this.isRemote) return this.remoteTransport!.aggregate(object, query);
496+
return super.aggregate(object, query, options);
497+
}
498+
494499
// ===================================
495500
// Bulk Operations (remote mode overrides)
496501
// ===================================

packages/plugins/plugin-auth/src/objectql-adapter.ts

Lines changed: 78 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,73 @@ export function resolveProtocolName(model: string): string {
3030
// Internal helpers
3131
// ---------------------------------------------------------------------------
3232

33+
/**
34+
* better-auth datetime columns (snake_case) per model.
35+
*
36+
* When the underlying driver stored these as JavaScript `Date` objects
37+
* (legacy behaviour), the libsql HTTP transport coerces the value to a REAL
38+
* column and round-trips it as a string like `"1779497911249.0"`. That
39+
* string is not a valid Date string (it has a trailing `.0`), so
40+
* `new Date(...)` produces `Invalid Date` and better-auth's client treats
41+
* the session as expired — causing a login/redirect loop.
42+
*
43+
* We normalise these legacy values back to ISO strings on **read** so the
44+
* factory's `supportsDates: false` parser can turn them into real Date
45+
* objects. New writes always go through better-auth's own
46+
* `Date → ISO string` conversion (because we declare `supportsDates: false`
47+
* below), so no further `.0`-suffixed values will ever be created.
48+
*/
49+
const LEGACY_DATETIME_FIELDS_BY_MODEL: Record<string, string[]> = {
50+
user: ['created_at', 'updated_at'],
51+
session: ['expires_at', 'created_at', 'updated_at'],
52+
account: [
53+
'access_token_expires_at',
54+
'refresh_token_expires_at',
55+
'created_at',
56+
'updated_at',
57+
],
58+
verification: ['expires_at', 'created_at', 'updated_at'],
59+
};
60+
61+
const NUMERIC_STRING_RE = /^-?\d+(\.\d+)?$/;
62+
63+
/**
64+
* If `value` looks like a stringified epoch-ms (optionally with `.0`),
65+
* convert it to an ISO 8601 string. Otherwise return it unchanged.
66+
*/
67+
function normaliseLegacyDate(value: unknown): unknown {
68+
if (typeof value !== 'string') return value;
69+
if (!NUMERIC_STRING_RE.test(value)) return value;
70+
const n = parseFloat(value);
71+
if (!Number.isFinite(n)) return value;
72+
// Heuristic: epoch milliseconds are at least 10 digits (year 2001+).
73+
if (Math.abs(n) < 1e10) return value;
74+
const d = new Date(n);
75+
if (Number.isNaN(d.getTime())) return value;
76+
return d.toISOString();
77+
}
78+
79+
/**
80+
* Walk a record and rewrite any legacy `.0`-suffixed datetime values
81+
* into ISO strings. Mutates and returns the record.
82+
*/
83+
function normaliseLegacyDates<T extends Record<string, any> | null | undefined>(
84+
model: string,
85+
record: T,
86+
): T {
87+
if (!record) return record;
88+
const cols = LEGACY_DATETIME_FIELDS_BY_MODEL[model];
89+
if (!cols) return record;
90+
for (const col of cols) {
91+
if (col in record) {
92+
(record as Record<string, unknown>)[col] = normaliseLegacyDate(
93+
(record as Record<string, unknown>)[col],
94+
);
95+
}
96+
}
97+
return record;
98+
}
99+
33100
/**
34101
* Convert better-auth where clause to ObjectQL query format.
35102
*
@@ -88,17 +155,21 @@ export function createObjectQLAdapterFactory(dataEngine: IDataEngine) {
88155
return createAdapterFactory({
89156
config: {
90157
adapterId: 'objectql',
91-
// ObjectQL natively supports these types — no extra conversion needed
92-
supportsBooleans: true,
93-
supportsDates: true,
158+
// We let better-auth handle Date↔string and boolean↔0/1 conversion so
159+
// that values land in the underlying SQL driver as primitive strings
160+
// and integers. Some drivers (e.g. libsql over the HTTP transport)
161+
// otherwise mangle `Date` objects into `"<epoch>.0"` strings that
162+
// break the client-side session parser.
163+
supportsBooleans: false,
164+
supportsDates: false,
94165
supportsJSON: true,
95166
},
96167
adapter: () => ({
97168
create: async <T extends Record<string, any>>(
98169
{ model, data, select: _select }: { model: string; data: T; select?: string[] },
99170
): Promise<T> => {
100171
const result = await dataEngine.insert(model, data);
101-
return result as T;
172+
return normaliseLegacyDates(model, result) as T;
102173
},
103174

104175
findOne: async <T>(
@@ -108,7 +179,7 @@ export function createObjectQLAdapterFactory(dataEngine: IDataEngine) {
108179

109180
const result = await dataEngine.findOne(model, { where: filter, fields: select });
110181

111-
return result ? (result as T) : null;
182+
return result ? (normaliseLegacyDates(model, result) as T) : null;
112183
},
113184

114185
findMany: async <T>(
@@ -130,7 +201,7 @@ export function createObjectQLAdapterFactory(dataEngine: IDataEngine) {
130201
orderBy,
131202
});
132203

133-
return results as T[];
204+
return results.map((r) => normaliseLegacyDates(model, r as Record<string, any>)) as T[];
134205
},
135206

136207
count: async (
@@ -150,7 +221,7 @@ export function createObjectQLAdapterFactory(dataEngine: IDataEngine) {
150221
if (!record) return null;
151222

152223
const result = await dataEngine.update(model, { ...(update as any), id: record.id });
153-
return result ? (result as T) : null;
224+
return result ? (normaliseLegacyDates(model, result) as T) : null;
154225
},
155226

156227
updateMany: async (

packages/runtime/src/dispatcher-plugin.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -260,9 +260,12 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
260260
});
261261

262262
// ── Analytics ───────────────────────────────────────────────
263+
// Route via dispatch() (not handleAnalytics directly) so the host
264+
// dispatcher's project-aware kernel swap runs first — the per-project
265+
// kernel owns the `analytics` service (registered by ObjectQLPlugin).
263266
server.post(`${prefix}/analytics/query`, async (req: any, res: any) => {
264267
try {
265-
const result = await dispatcher.handleAnalytics('query', 'POST', req.body, { request: req });
268+
const result = await dispatcher.dispatch('POST', '/analytics/query', req.body, req.query, { request: req });
266269
sendResult(result, res);
267270
} catch (err: any) {
268271
errorResponse(err, res);
@@ -271,7 +274,7 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
271274

272275
server.get(`${prefix}/analytics/meta`, async (req: any, res: any) => {
273276
try {
274-
const result = await dispatcher.handleAnalytics('meta', 'GET', {}, { request: req });
277+
const result = await dispatcher.dispatch('GET', '/analytics/meta', undefined, req.query, { request: req });
275278
sendResult(result, res);
276279
} catch (err: any) {
277280
errorResponse(err, res);
@@ -280,7 +283,7 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
280283

281284
server.post(`${prefix}/analytics/sql`, async (req: any, res: any) => {
282285
try {
283-
const result = await dispatcher.handleAnalytics('sql', 'POST', req.body, { request: req });
286+
const result = await dispatcher.dispatch('POST', '/analytics/sql', req.body, req.query, { request: req });
284287
sendResult(result, res);
285288
} catch (err: any) {
286289
errorResponse(err, res);
@@ -609,11 +612,15 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
609612
});
610613

611614
// ── i18n ────────────────────────────────────────────────────
612-
// Bridges to HttpDispatcher.handleI18n() which resolves the i18n
613-
// service from the kernel (either I18nServicePlugin or memory fallback).
615+
// Route via dispatch() (not handleI18n directly) so the host
616+
// dispatcher's project-aware kernel swap runs first. Without this,
617+
// i18n requests hit the host kernel's in-memory fallback (which
618+
// is always empty) instead of the per-project I18nServicePlugin
619+
// populated by ArtifactKernelFactory with the artifact's
620+
// translation bundles.
614621
server.get(`${prefix}/i18n/locales`, async (req: any, res: any) => {
615622
try {
616-
const result = await dispatcher.handleI18n('/locales', 'GET', req.query, { request: req });
623+
const result = await dispatcher.dispatch('GET', '/i18n/locales', undefined, req.query, { request: req });
617624
sendResult(result, res);
618625
} catch (err: any) {
619626
errorResponse(err, res);
@@ -622,7 +629,7 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
622629

623630
server.get(`${prefix}/i18n/translations/:locale`, async (req: any, res: any) => {
624631
try {
625-
const result = await dispatcher.handleI18n(`/translations/${req.params.locale}`, 'GET', req.query, { request: req });
632+
const result = await dispatcher.dispatch('GET', `/i18n/translations/${req.params.locale}`, undefined, req.query, { request: req });
626633
sendResult(result, res);
627634
} catch (err: any) {
628635
errorResponse(err, res);
@@ -631,7 +638,7 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
631638

632639
server.get(`${prefix}/i18n/labels/:object/:locale`, async (req: any, res: any) => {
633640
try {
634-
const result = await dispatcher.handleI18n(`/labels/${req.params.object}/${req.params.locale}`, 'GET', req.query, { request: req });
641+
const result = await dispatcher.dispatch('GET', `/i18n/labels/${req.params.object}/${req.params.locale}`, undefined, req.query, { request: req });
635642
sendResult(result, res);
636643
} catch (err: any) {
637644
errorResponse(err, res);

packages/services/service-cloud/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"@objectstack/plugin-auth": "workspace:^",
3535
"@objectstack/plugin-security": "workspace:^",
3636
"@objectstack/runtime": "workspace:^",
37+
"@objectstack/service-i18n": "workspace:^",
3738
"@objectstack/service-package": "workspace:^",
3839
"@objectstack/service-storage": "workspace:*",
3940
"@objectstack/service-tenant": "workspace:^",

0 commit comments

Comments
 (0)