fix(core): read usage events across projects when the layer is unbound
An unbound (project-agnostic) data layer read zero usage events it had just
written. AsyncDataLayer.projectId is optional by design -- undefined means a
project-agnostic layer for single-project / global / analytics reads -- but
helpers taking `projectId: string` are called as `layer.projectId ?? ""`, which
turns "no scope" into a literal '' scope.
'' never matches: the fusion_assign_project_id BEFORE INSERT trigger (migration
0006) rewrites a written '' to the session's fusion.project_id or
'__legacy_unscoped__', so a read filtering on '' looks for a value the database
never stores. Writes normalize, reads did not. Proven by probe: the row is
present with project_id '__legacy_unscoped__', emitUsageEvent returns true, and
queryUsageEvents returns [] even with no other filters.
Treat blank as unbound and drop the scope predicate, matching the contract
taskProjectScope already documents ("when undefined the scope filter is a
no-op"). Restricting an unbound reader to '__legacy_unscoped__' rows instead
would make an unscoped analytics read silently partial.
Adds projectScopeFor() next to taskProjectScope so the convention has one home
rather than a third open-coded variant.
Note the write path is already live: remaining-ops-7.ts emits with
`layer.projectId ?? ""` under backendMode, so unscoped events are accumulating
under the sentinel today. The async reader has no production caller yet, which
is why nothing user-facing broke.
Fixes taskstore-remaining.test.ts (24/24). The remaining failures in that suite
share this root cause but not this resolution -- see the follow-up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -411,3 +411,28 @@ export function archivedTaskProjectScope(
|
||||
? eq(schema.project.archivedTasks.projectId, layer.projectId)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:MultiProjectIsolation 2026-07-15-21:40:
|
||||
* As {@link taskProjectScope}, but for any project-schema column and a raw project id rather
|
||||
* than a bound layer. Same contract: a bound id scopes the read, an unbound one (undefined or
|
||||
* blank) is a no-op that reads across projects.
|
||||
*
|
||||
* Exists because helpers that take `projectId: string` are called as `layer.projectId ?? ""`,
|
||||
* which turns "no scope" into a literal `''` scope. That never matches anything: the
|
||||
* `fusion_assign_project_id` BEFORE INSERT trigger (migration 0006) rewrites a written `''` to
|
||||
* the session's `fusion.project_id` or `__legacy_unscoped__`, so reads filtering on `''` look
|
||||
* for a value the database never stores. Writes normalize; reads must not invent a scope.
|
||||
*
|
||||
* Blank is treated as unbound rather than as the legacy sentinel deliberately: an unbound layer
|
||||
* is documented as a project-agnostic / analytics reader, so it reads everything, exactly as the
|
||||
* `taskProjectScope` no-op already does. Restricting it to `__legacy_unscoped__` rows would make
|
||||
* an unscoped analytics read silently partial instead.
|
||||
*/
|
||||
export function projectScopeFor(
|
||||
column: SQL | Parameters<typeof eq>[0],
|
||||
projectId: string | undefined,
|
||||
): SQL | undefined {
|
||||
const scope = projectId?.trim();
|
||||
return scope ? eq(column, scope) : undefined;
|
||||
}
|
||||
|
||||
@@ -25,8 +25,9 @@
|
||||
* These helpers are the async target the migrating store and the PostgreSQL
|
||||
* integration tests consume.
|
||||
*/
|
||||
import { and, desc, eq, gte, lte } from "drizzle-orm";
|
||||
import { and, desc, eq, gte, lte, type SQL } from "drizzle-orm";
|
||||
import * as schema from "../postgres/schema/index.js";
|
||||
import { projectScopeFor } from "../postgres/data-layer.js";
|
||||
import type { AsyncDataLayer, DbTransaction } from "../postgres/data-layer.js";
|
||||
import type {
|
||||
GoalCitation,
|
||||
@@ -265,7 +266,17 @@ export async function queryUsageEvents(
|
||||
projectId: string,
|
||||
query: UsageEventRangeQuery = {},
|
||||
): Promise<UsageEvent[]> {
|
||||
const conditions = [eq(schema.project.usageEvents.projectId, projectId)];
|
||||
/*
|
||||
FNXC:MultiProjectIsolation 2026-07-15-21:40:
|
||||
An unbound (project-agnostic / analytics) layer reaches here as `layer.projectId ?? ""`. Scoping
|
||||
on that literal `''` returned nothing at all: the write side never stores it — the
|
||||
fusion_assign_project_id trigger rewrites `''` to the session project or `__legacy_unscoped__` —
|
||||
so every unscoped read silently found zero events it had just written. Blank means unscoped, so
|
||||
read across projects, matching taskProjectScope's no-op. See projectScopeFor.
|
||||
*/
|
||||
const conditions: SQL[] = [];
|
||||
const scope = projectScopeFor(schema.project.usageEvents.projectId, projectId);
|
||||
if (scope) conditions.push(scope);
|
||||
if (query.from) {
|
||||
conditions.push(gte(schema.project.usageEvents.ts, query.from));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user