Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions .changeset/driver-options-bypass-tenant-audit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
---
"@objectstack/spec": patch
"@objectstack/driver-sql": patch
"@objectstack/driver-sqlite-wasm": patch
"@objectstack/driver-memory": patch
---

fix(spec,drivers): `bypassTenantAudit` becomes a declared driver option, and `findOne` stops accepting a bare id (#4311)

Three drivers built with `tsup` and tested with `vitest`, so no `tsc` had ever
read them. Onboarding them to the #4311 type-check ratchet surfaced 292 errors,
and most of what looked like sloppy test fixtures was the types being wrong.

**`DriverOptions.bypassTenantAudit` is now declared.** It has been live for a
long time without being on the schema: `SqlDriver.auditMissingTenant` reads it
to suppress the "tenant-scoped write without `tenantId`" warning, the driver's
own warning text tells callers to set it, `ObjectQLEngine` sets it for
system-context calls, and `service-settings` / `service-datasource` pass it on
every global-scope write. Because the schema never had it, the driver read it
through `(options as any)` and no caller was type-checked. The declaration
states the limit as well: it silences a diagnostic and MUST NOT change which
rows a write touches — suppressing an audit warning is not a permission.

The same cast covered `timezone`, `tenantId`, `tenantIds` and `preserveAudit`,
all long since declared. Those reads now go through `DriverOptions`, so the next
undeclared option fails the build instead of hiding behind an existing cast.

**`SqlDriver.findOne(object, id)` is removed.** An undeclared
`typeof query === 'string' | 'number'` branch accepted a bare id. It was on no
contract, nothing outside that package's own tests used it, and the other two
drivers answered the identical call differently — `MemoryDriver` spreads the
string into `{0:'t',1:'1'}`, `MongoDBDriver` reads `query.where` as `undefined`
and returns an arbitrary row. It also bypassed the shared `findRows()` path, so
it skipped field selection, temporal coercion, unknown-column recovery and the
`singleRowLookup` ORDER BY decision. Spell an id lookup as the query it is:

```ts
- await driver.findOne('task', 't1');
+ await driver.findOne('task', { object: 'task', where: { id: 't1' } });
```

**`SqlDriver.initObjects` declares the `tenancy` it consumes.** Each object is
fed to `computeAndRecordTenantField`, which reads `obj.tenancy` to pick the
tenant column and to set or clear the sticky explicit-opt-out — but the
parameter type listed only `{ name, fields }`, so a caller that spelled the key
correctly was rejected while the driver read it anyway.
`registerExternalObject` already had it.

**`AnalyticsQueryInput` joins `AnalyticsQuery`.** `timezone` is
`.default('UTC')`, so the parsed type requires it and an authored literal does
not have it — the same two-tier split `QueryInput`/`QueryAST` already names on
the query side. `InMemoryDriver.create`/`bulkCreate` also declare their
`IDataDriver` return types; without them TS inferred the literal the method
builds and every other column of the created row disappeared from the caller's
view.

One silent runtime bug fell out of the same pass: a driver test asked for
`orderBy: [['id', 'asc']]`, the driver reads `item.field`, a tuple has none, and
the sort never reached SQL. The tuple spelling appears nowhere else.
1 change: 1 addition & 0 deletions content/docs/references/data/driver.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ const result = DriverCapabilities.parse(data);
| **tenantIds** | `string[]` | optional | Union tenant access set (group posture): native read scoping widens to organization_id IN (...); inserts still stamp from tenantId |
| **timezone** | `string` | optional | Business reference timezone (IANA) for date-dependent generation, e.g. autonumber date tokens |
| **preserveAudit** | `boolean` | optional | Historical import: keep a supplied updated_at instead of force-stamping now (from ExecutionContext.preserveAudit) |
| **bypassTenantAudit** | `boolean` | optional | Suppress the driver tenant-audit warning for a deliberately global write on a tenant-scoped object (diagnostics only — never changes what the write touches) |


---
Expand Down
3 changes: 2 additions & 1 deletion packages/plugins/driver-memory/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
"scripts": {
"build": "tsup --config ../../../tsup.config.ts",
"dev": "tsc -w",
"test": "vitest run"
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@objectstack/core": "workspace:*",
Expand Down
99 changes: 58 additions & 41 deletions packages/plugins/driver-memory/src/memory-analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,21 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { InMemoryDriver } from './memory-driver.js';
import { MemoryAnalyticsService } from './memory-analytics.js';
import type { Cube } from '@objectstack/spec/data';
import { AnalyticsQuerySchema, defineCube } from '@objectstack/spec/data';
import type { AnalyticsQuery, AnalyticsQueryInput, Cube } from '@objectstack/spec/data';

/**
* Author-tier literal → the parsed `AnalyticsQuery` the service contract takes.
*
* `timezone` is `.default('UTC')` on the schema, so it is optional to write and
* required on the parsed type — the two tiers are genuinely different types. A
* real query reaches `query()` through the schema (the REST layer parses the
* request body), so these tests take the same route rather than hand-writing
* the filled-in default: the parse IS the proof that the default lands. Until
* #4311 no tsc read this file, so 19 author-tier literals sat unnoticed in a
* parameter that had required `timezone` all along.
*/
const asQuery = (input: AnalyticsQueryInput): AnalyticsQuery => AnalyticsQuerySchema.parse(input);

describe('MemoryAnalyticsService', () => {
let driver: InMemoryDriver;
Expand Down Expand Up @@ -160,10 +174,10 @@ describe('MemoryAnalyticsService', () => {

describe('query', () => {
it('should execute a simple count query', async () => {
const result = await service.query({
const result = await service.query(asQuery({
cube: 'orders',
measures: ['orders.count']
});
}));

expect(result.rows).toHaveLength(1);
expect(result.rows[0]['orders.count']).toBe(5);
Expand All @@ -173,11 +187,11 @@ describe('MemoryAnalyticsService', () => {
});

it('should group by a dimension', async () => {
const result = await service.query({
const result = await service.query(asQuery({
cube: 'orders',
measures: ['orders.count'],
dimensions: ['orders.status']
});
}));

expect(result.rows).toHaveLength(3); // completed, pending, cancelled

Expand All @@ -187,34 +201,34 @@ describe('MemoryAnalyticsService', () => {
});

it('should calculate sum aggregation', async () => {
const result = await service.query({
const result = await service.query(asQuery({
cube: 'orders',
measures: ['orders.totalAmount'],
dimensions: ['orders.customer']
});
}));

const aliceRow = result.rows.find(r => r['orders.customer'] === 'Alice');
expect(aliceRow).toBeDefined();
expect(aliceRow!['orders.totalAmount']).toBe(250); // 100 + 150
});

it('should calculate average aggregation', async () => {
const result = await service.query({
const result = await service.query(asQuery({
cube: 'products',
measures: ['products.avgPrice'],
dimensions: ['products.category']
});
}));

const electronicsRow = result.rows.find(r => r['products.category'] === 'electronics');
expect(electronicsRow).toBeDefined();
expect(electronicsRow!['products.avgPrice']).toBe(512); // (999 + 25) / 2
});

it('should support multiple measures', async () => {
const result = await service.query({
const result = await service.query(asQuery({
cube: 'orders',
measures: ['orders.count', 'orders.totalAmount', 'orders.avgAmount']
});
}));

expect(result.rows).toHaveLength(1);
expect(result.rows[0]['orders.count']).toBe(5);
Expand All @@ -223,71 +237,71 @@ describe('MemoryAnalyticsService', () => {
});

it('should apply filters via canonical `where`', async () => {
const result = await service.query({
const result = await service.query(asQuery({
cube: 'orders',
measures: ['orders.count', 'orders.totalAmount'],
where: { 'orders.status': 'completed' },
});
}));

expect(result.rows).toHaveLength(1);
expect(result.rows[0]['orders.count']).toBe(3);
expect(result.rows[0]['orders.totalAmount']).toBe(600); // 100+200+300
});

it('should accept FilterCondition (short-form equality)', async () => {
const result = await service.query({
const result = await service.query(asQuery({
cube: 'orders',
measures: ['orders.count', 'orders.totalAmount'],
where: { status: 'completed' },
});
}));

expect(result.rows).toHaveLength(1);
expect(result.rows[0]['orders.count']).toBe(3);
expect(result.rows[0]['orders.totalAmount']).toBe(600);
});

it('should accept FilterCondition with $in operator', async () => {
const result = await service.query({
const result = await service.query(asQuery({
cube: 'orders',
measures: ['orders.count'],
where: { status: { $in: ['completed', 'pending'] } },
});
}));

expect(result.rows[0]['orders.count']).toBe(4); // 3 completed + 1 pending
});

it('should accept FilterCondition with $gte operator', async () => {
const result = await service.query({
const result = await service.query(asQuery({
cube: 'orders',
measures: ['orders.count'],
where: { amount: { $gte: 200 } },
});
}));

expect(result.rows[0]['orders.count']).toBe(2); // 200, 300
});

it('should support sorting', async () => {
const result = await service.query({
const result = await service.query(asQuery({
cube: 'orders',
measures: ['orders.totalAmount'],
dimensions: ['orders.customer'],
order: { 'orders.totalAmount': 'desc' }
});
}));

expect(result.rows[0]['orders.customer']).toBe('Charlie'); // 300
expect(result.rows[1]['orders.customer']).toBe('Alice'); // 250
expect(result.rows[2]['orders.customer']).toBe('Bob'); // 250
});

it('should support limit and offset', async () => {
const result = await service.query({
const result = await service.query(asQuery({
cube: 'orders',
measures: ['orders.count'],
dimensions: ['orders.customer'],
order: { 'orders.customer': 'asc' },
limit: 2,
offset: 1
});
}));

expect(result.rows).toHaveLength(2);
expect(result.rows[0]['orders.customer']).toBe('Bob');
Expand All @@ -299,7 +313,10 @@ describe('MemoryAnalyticsService', () => {
// 'amount' of type 'sum'). Clients that build measure names from
// (field, function) pairs send 'amount_sum' — the resolver should
// accept that alias and produce the same aggregate value.
const aliasCube: Cube = {
// `defineCube` rather than a bare `: Cube` literal: `public` is
// `.default(false)`, so it is required on the parsed `Cube` type and
// absent here — the factory is the spec's own answer to that split.
const aliasCube = defineCube({
name: 'opps',
title: 'Opps',
sql: 'orders',
Expand All @@ -309,14 +326,14 @@ describe('MemoryAnalyticsService', () => {
dimensions: {
status: { name: 'status', label: 'Status', type: 'string', sql: 'status' },
},
};
});
const aliasService = new MemoryAnalyticsService({ driver, cubes: [aliasCube] });

const aliased = await aliasService.query({
const aliased = await aliasService.query(asQuery({
cube: 'opps',
measures: ['amount_sum'],
dimensions: ['status'],
});
}));

const completed = aliased.rows.find(r => r.status === 'completed');
expect(completed).toBeDefined();
Expand All @@ -325,18 +342,18 @@ describe('MemoryAnalyticsService', () => {

it('should throw error for unknown cube', async () => {
await expect(async () => {
await service.query({
await service.query(asQuery({
cube: 'unknown',
measures: ['unknown.count']
});
}));
}).rejects.toThrow('Cube not found: unknown');
});

it('should include SQL in result for debugging', async () => {
const result = await service.query({
const result = await service.query(asQuery({
cube: 'orders',
measures: ['orders.count']
});
}));

expect(result.sql).toBeDefined();
expect(result.sql).toContain('orders');
Expand All @@ -345,56 +362,56 @@ describe('MemoryAnalyticsService', () => {

describe('generateSql', () => {
it('should generate SQL for a simple query', async () => {
const result = await service.generateSql({
const result = await service.generateSql(asQuery({
cube: 'orders',
measures: ['orders.count']
});
}));

expect(result.sql).toContain('SELECT');
expect(result.sql).toContain('COUNT(*)');
expect(result.sql).toContain('FROM orders');
});

it('should generate SQL with GROUP BY', async () => {
const result = await service.generateSql({
const result = await service.generateSql(asQuery({
cube: 'orders',
measures: ['orders.count'],
dimensions: ['orders.status']
});
}));

expect(result.sql).toContain('GROUP BY status');
});

it('should generate SQL with WHERE clause', async () => {
const result = await service.generateSql({
const result = await service.generateSql(asQuery({
cube: 'orders',
measures: ['orders.count'],
where: { 'orders.status': 'completed' },
});
}));

expect(result.sql).toContain('WHERE');
expect(result.sql).toContain('status');
});

it('should generate SQL with ORDER BY', async () => {
const result = await service.generateSql({
const result = await service.generateSql(asQuery({
cube: 'orders',
measures: ['orders.count'],
dimensions: ['orders.status'],
order: { 'orders.status': 'asc' }
});
}));

expect(result.sql).toContain('ORDER BY');
expect(result.sql).toContain('ASC');
});

it('should generate SQL with LIMIT and OFFSET', async () => {
const result = await service.generateSql({
const result = await service.generateSql(asQuery({
cube: 'orders',
measures: ['orders.count'],
limit: 10,
offset: 5
});
}));

expect(result.sql).toContain('LIMIT 10');
expect(result.sql).toContain('OFFSET 5');
Expand Down
Loading
Loading