## What The FNXC convention exists so a reader can place a note against the change that motivated it. A stamp dated *after* the edit landed defeats exactly that. This is program-wide drift, not one author's slip — I contributed to it in my own commits this week, which is how I noticed it. ## Measured, on this tree **104 stamps across 61 files** dated later than the day they were written, from one day ahead to **2026-10-19 (81 days)**: | count | date | count | date | count | date | |---|---|---|---|---|---| | 50 | 2026-07-31 | 6 | 2026-08-05 | 3 | 2026-08-13 | | 17 | 2026-08-01 | 1 | 2026-08-07 | 1 | 2026-08-19 | | 7 | 2026-08-02 | 1 | 2026-08-12 | 2 | 2026-08-26 | | 11 | 2026-08-03 | | | 3 | 2026-10-19 | An earlier number I circulated was ~70. That came from a narrower pathspec and was wrong; **104** is the measurement. ## How Each stamp is rewritten to the date of the commit that introduced **that line**, via per-line `git blame` — deliberately *not* stamped uniformly with today's date. A uniform stamp swaps a wrong date for a different wrong date and flattens the ordering that makes these comments navigable; blame preserves it. Times of day are untouched, and a blame date in the future is clamped rather than trusted. ## Why the verification is listed A docs sweep across 61 files is precisely where a stray edit hides, so the safety claims are mechanical rather than asserted: - every changed line begins with a comment marker — **no code touched**; - **no test asserts an FNXC date later than today**, so no `toContain` assertion on embedded source text can be silently invalidated (several such assertions do exist); - CSS files, which carry several of those assertions, are outside the pathspec. ## Verified lint clean · merge gate green (487 + 158 + 10 + 71) · `census --strict` exit 0 · tsc clean for core, engine, and dashboard (`tsconfig.app.json`). **No behavior change.** Comment text only. ## Not done here A guard preventing recurrence. A check that rejects an FNXC stamp dated after the commit would stop this returning, but it needs a decision about where it runs (lint rule vs. gate) and it is a behavior change to CI — it does not belong riding inside the sweep it would police.
537 lines
24 KiB
TypeScript
537 lines
24 KiB
TypeScript
/**
|
|
* Async Drizzle task-persistence helpers (U12).
|
|
*
|
|
* FNXC:TaskStorePersistence 2026-06-24-13:00:
|
|
* Async equivalents of the sync SQLite persistence call sites in store.ts.
|
|
* These helpers target the PostgreSQL `project.tasks` table via Drizzle and
|
|
* preserve the three load-bearing persistence invariants the migration must
|
|
* not regress:
|
|
*
|
|
* VAL-DATA-005 — Soft-delete visibility: every live reader filters
|
|
* `deleted_at IS NULL`. Soft-deleted tasks do not appear in active lists,
|
|
* kanban, or counts. Forensic reads (includeDeleted) still surface them.
|
|
* VAL-DATA-006 — Forensic reads surface soft-deleted rows when explicitly
|
|
* requested (includeDeleted: true).
|
|
* VAL-DATA-009 — Create-class inserts are non-destructive: create paths use
|
|
* a plain INSERT so PostgreSQL raises a primary-key violation on duplicate
|
|
* IDs instead of silently rewriting the existing row (the upsert path is
|
|
* update-only and must never be used for create).
|
|
*
|
|
* SQLite → PostgreSQL JSON note (VAL-SCHEMA-004):
|
|
* In SQLite the JSON columns were TEXT with `toJson()`/`fromJson()`. In
|
|
* PostgreSQL they are `jsonb`, so Drizzle returns them already-parsed as JS
|
|
* values. On write, pass the JS value directly (Drizzle serializes it).
|
|
*
|
|
* Transition context (see library/satellite-store-migration-pattern.md):
|
|
* `getDatabase()` still returns the sync `Database` until U15 flips it. The
|
|
* TaskStore facade keeps its sync persistence path (the gate depends on it).
|
|
* These helpers are the async target the migrating stores (U13/U14) and the
|
|
* PostgreSQL integration tests consume. They program against the stable
|
|
* `AsyncDataLayer` interface (U4), not the underlying driver.
|
|
*/
|
|
import { and, Column, eq, is, isNull, sql, type SQL } from "drizzle-orm";
|
|
import type { PgColumn } from "drizzle-orm/pg-core";
|
|
import * as schema from "../postgres/schema/index.js";
|
|
import type { AsyncDataLayer, DbTransaction } from "../postgres/data-layer.js";
|
|
import { isPostgresUniqueError } from "../postgres-errors.js";
|
|
import { taskProjectScope } from "../postgres/data-layer.js";
|
|
import {
|
|
TASK_COLUMN_DESCRIPTORS,
|
|
TASK_JSONB_COLUMNS,
|
|
type TaskPersistSerializationContext,
|
|
} from "./persistence.js";
|
|
|
|
/**
|
|
*FNXC:TaskStorePersistence 2026-06-24-13:05:
|
|
* The async-persistence live-reader filter. Every live reader applies this so
|
|
* soft-deleted rows (deleted_at IS NOT NULL) are hidden (VAL-DATA-005).
|
|
* Forensic readers omit this filter (VAL-DATA-006).
|
|
*/
|
|
export const ACTIVE_TASK_FILTER: SQL = isNull(schema.project.tasks.deletedAt);
|
|
|
|
/**
|
|
* FNXC:TaskStoreReads 2026-06-26-11:45:
|
|
* Projection of every task-table column EXCEPT `log`, built from Drizzle
|
|
* Column objects. This is the slim-read column set for `readLiveTaskRows`
|
|
* (excludeLog mode), which drops the heavy `log` jsonb payload (~99% of row
|
|
* bytes on busy boards) so board-list hydration stays bounded.
|
|
*
|
|
* WHY Column objects, not Object.keys(): a Drizzle table object's enumerable
|
|
* own-keys are the camelCase TypeScript property names (e.g. `lineageId`), but
|
|
* the underlying PostgreSQL columns are snake_case (e.g. `lineage_id`). Earlier
|
|
* code built a raw `SELECT` via `sql.identifier(Object.keys(...))`, which
|
|
* quotes the camelCase key verbatim and produces invalid SQL like
|
|
* `SELECT "lineageId"` against a `lineage_id` column. Iterating the Column
|
|
* objects and passing them to Drizzle's `.select({...})` lets Drizzle emit the
|
|
* correct quoted snake_case identifiers (and skip non-column own-properties
|
|
* such as `enableRLS`). The returned rows are keyed by the TS property name, so
|
|
* `pgRowToTaskRow` / `rowToTask` continue to read `row.column`,
|
|
* `row.deletedAt`, etc. unchanged.
|
|
*
|
|
* Computed once at module load (the schema is static); `log` is restored to
|
|
* `[]` by `pgRowToTaskRow` / `rowToTask` when a single task is fetched in full.
|
|
*/
|
|
const TASK_SLIM_PROJECTION: Record<string, PgColumn> = Object.fromEntries(
|
|
Object.entries(schema.project.tasks)
|
|
.filter(([, value]) => is(value, Column))
|
|
.filter(([key]) => key !== "log")
|
|
.map(([key, value]) => [key, value as PgColumn]),
|
|
);
|
|
|
|
/**
|
|
* FNXC:TaskStorePersistence 2026-06-24-13:07:
|
|
* The task-table columns that are `jsonb` in PostgreSQL (VAL-SCHEMA-004). In
|
|
* SQLite these were TEXT with `toJson()`/`toJsonNullable()`. The shared column
|
|
* descriptors serialize these to JSON *strings* (the SQLite binding shape), but
|
|
* a PostgreSQL jsonb column expects a JS value so Drizzle can bind it as jsonb.
|
|
* `buildTaskInsertValues` parses the descriptor-produced JSON strings for these
|
|
* columns back into JS values so the round-trip through jsonb preserves shape.
|
|
*/
|
|
/**
|
|
* Build a Drizzle `values` object for a task from the shared column
|
|
* descriptors. This is the async equivalent of `getTaskPersistValues()` —
|
|
* instead of producing positional SQL placeholders, it produces a column-keyed
|
|
* object suitable for `db.insert(tasks).values(...)`.
|
|
*
|
|
* The descriptor serialization functions are reused verbatim from the sync
|
|
* path so the persisted shape is identical across backends. For jsonb columns,
|
|
* the descriptor-produced JSON string is parsed back into a JS value so Drizzle
|
|
* binds it as jsonb (not a double-encoded text string).
|
|
*/
|
|
export function buildTaskInsertValues(
|
|
taskRecord: Record<string, unknown>,
|
|
context: TaskPersistSerializationContext,
|
|
projectId?: string,
|
|
): Record<string, unknown> {
|
|
const values: Record<string, unknown> = {};
|
|
for (const descriptor of TASK_COLUMN_DESCRIPTORS) {
|
|
// The descriptors are written against the Task type; they only read fields,
|
|
// so a loose record is safe here.
|
|
let value = descriptor.serialize(taskRecord as never, context);
|
|
if (TASK_JSONB_COLUMNS.has(descriptor.column) && typeof value === "string") {
|
|
// PostgreSQL jsonb: parse the descriptor's JSON string back to a JS value
|
|
// so Drizzle binds it as jsonb (round-trip shape parity, VAL-SCHEMA-004).
|
|
// "[]" (the toJson empty-array sentinel) maps to an empty array; "" maps
|
|
// to null (absent optional column).
|
|
value = value === "" ? null : JSON.parse(value);
|
|
}
|
|
values[descriptor.column] = value;
|
|
}
|
|
// FNXC:MultiProjectIsolation 2026-07-10:
|
|
// Stamp the per-project partition key so every task row is attributed to the
|
|
// project whose store wrote it. The task-store descriptors don't include
|
|
// project_id (it isn't a Task field), so it is set here from the bound layer
|
|
// projectId. When undefined (single-project store / SQLite path), the column
|
|
// stays NULL and the scope filter is a no-op — behavior-preserving.
|
|
if (projectId !== undefined) {
|
|
values.projectId = projectId;
|
|
}
|
|
return values;
|
|
}
|
|
|
|
/**
|
|
* FNXC:TaskStorePersistence 2026-06-24-13:10:
|
|
* Non-destructive task insert (VAL-DATA-009). Create-class operations MUST use
|
|
* this, not the upsert. A plain `INSERT` against a primary-key column raises a
|
|
* `unique_violation` (PostgreSQL error code 23505) on a duplicate id instead of
|
|
* silently overwriting the existing row. Callers catch that error and surface
|
|
* "Task ID already exists".
|
|
*
|
|
* @param layer The async data layer.
|
|
* @param taskRecord A record carrying the Task fields to persist.
|
|
* @param context Serialization context (lineageId).
|
|
*/
|
|
export async function insertTaskRow(
|
|
layer: AsyncDataLayer,
|
|
taskRecord: Record<string, unknown>,
|
|
context: TaskPersistSerializationContext,
|
|
): Promise<void> {
|
|
const values = buildTaskInsertValues(taskRecord, context, layer.projectId);
|
|
await layer.db.insert(schema.project.tasks).values(values as never);
|
|
}
|
|
|
|
/**
|
|
* Non-destructive task insert inside a shared transaction handle. Use this when
|
|
* the create must commit/rollback atomically with sibling writes (e.g. an audit
|
|
* row or a mergeQueue insert in the same transaction).
|
|
*/
|
|
export async function insertTaskRowInTransaction(
|
|
tx: DbTransaction,
|
|
taskRecord: Record<string, unknown>,
|
|
context: TaskPersistSerializationContext,
|
|
projectId?: string,
|
|
): Promise<void> {
|
|
const values = buildTaskInsertValues(taskRecord, context, projectId);
|
|
await tx.insert(schema.project.tasks).values(values as never);
|
|
}
|
|
|
|
/**
|
|
* FNXC:TaskStorePersistence 2026-06-24-13:15:
|
|
* Soft-delete a task (the deleteTask path). Sets `deleted_at`, moves the column
|
|
* to 'archived', and stamps `updated_at`. This is non-destructive: the row is
|
|
* retained for forensic reads and the task ID stays reserved (VAL-DATA-008 —
|
|
* soft-deleted IDs are never reassigned because the allocator reconciliation
|
|
* scans soft-deleted rows when bumping sequences).
|
|
*
|
|
* @param layer The async data layer.
|
|
* @param id The task id to soft-delete.
|
|
* @param deletedAt The deletion timestamp (ISO-8601).
|
|
* @param allowResurrection Whether the task may be resurrected (1/0).
|
|
*/
|
|
export async function softDeleteTaskRow(
|
|
layer: AsyncDataLayer,
|
|
id: string,
|
|
deletedAt: string,
|
|
allowResurrection = false,
|
|
): Promise<void> {
|
|
await layer.db
|
|
.update(schema.project.tasks)
|
|
.set({
|
|
column: "archived",
|
|
deletedAt,
|
|
allowResurrection: allowResurrection ? 1 : 0,
|
|
updatedAt: deletedAt,
|
|
})
|
|
.where(and(
|
|
eq(schema.project.tasks.projectId, layer.projectId?.trim() || "__legacy_unscoped__"),
|
|
eq(schema.project.tasks.id, id),
|
|
));
|
|
}
|
|
|
|
/**
|
|
* FNXC:TaskStateReconciliation 2026-07-29-16:10:
|
|
* Resolve only the active wedge episode the caller observed. The PostgreSQL predicate is the cross-process compare-and-set authority, so an update waiting behind a replacement episode rechecks the durable row and cannot clear the replacement.
|
|
*/
|
|
export async function resolveActiveTaskWedgeEpisodeRow(
|
|
layer: AsyncDataLayer,
|
|
id: string,
|
|
episodeId: string,
|
|
transitionedAt: string,
|
|
): Promise<Record<string, unknown> | undefined> {
|
|
const rows = await layer.db
|
|
.update(schema.project.tasks)
|
|
.set({
|
|
/*
|
|
FNXC:WedgeNotification 2026-07-30-12:00:
|
|
`${transitionedAt}::text` — the CAST is load-bearing, not decoration.
|
|
|
|
PostgreSQL cannot infer the type of a bare bind parameter used as a `jsonb_build_object` value:
|
|
the function is variadic `"any"`, so there is no signature to resolve $1 against and the planner
|
|
rejects the statement outright with `42P18: could not determine data type of parameter $1`.
|
|
That is a PARSE-time failure, so it fires on every call rather than on unusual data — wedge
|
|
notifications could never be resolved in PostgreSQL mode at all.
|
|
|
|
Surfaced by `store-wedge-resolution.pg.test.ts`, which has been failing on main; the failure is
|
|
the product query, not the test.
|
|
*/
|
|
wedgeNotification: sql`(${schema.project.tasks.wedgeNotification}::jsonb || jsonb_build_object('status', 'resolved', 'transitionedAt', ${transitionedAt}::text))::text`,
|
|
updatedAt: transitionedAt,
|
|
})
|
|
.where(and(
|
|
taskProjectScope(layer),
|
|
eq(schema.project.tasks.id, id),
|
|
isNull(schema.project.tasks.deletedAt),
|
|
sql`${schema.project.tasks.wedgeNotification}::jsonb ->> 'episodeId' = ${episodeId}`,
|
|
sql`${schema.project.tasks.wedgeNotification}::jsonb ->> 'status' = 'active'`,
|
|
))
|
|
.returning();
|
|
return rows[0] as Record<string, unknown> | undefined;
|
|
}
|
|
|
|
/**
|
|
* FNXC:TaskStoreArchiveLineage 2026-06-24-15:00:
|
|
* Soft-delete a task INSIDE a shared transaction handle. This is the
|
|
* transaction-aware variant of {@link softDeleteTaskRow} for composite
|
|
* operations (archiveParentTaskWithLineageGate, restoreTaskFromArchive)
|
|
* that must commit the soft-delete atomically with sibling writes.
|
|
*
|
|
* HAZARD FIX (runtime-workflow-async): the previous composite functions
|
|
* called `softDeleteTaskRow(layer, ...)` inside a `layer.transactionImmediate`
|
|
* block, but that helper used `layer.db` (the runtime connection) — the
|
|
* UPDATE ran OUTSIDE the transaction, so a later rollback left the
|
|
* soft-delete persisted while reverting its siblings. This variant takes
|
|
* the `tx` handle so the UPDATE participates in the surrounding transaction
|
|
* (VAL-DATA-002/003 — atomic commit/rollback).
|
|
*
|
|
* @param tx The transaction handle (from layer.transactionImmediate).
|
|
* @param id The task id to soft-delete.
|
|
* @param deletedAt The deletion timestamp (ISO-8601).
|
|
* @param allowResurrection Whether the task may be resurrected (1/0).
|
|
*/
|
|
export async function softDeleteTaskRowInTransaction(
|
|
tx: DbTransaction,
|
|
id: string,
|
|
deletedAt: string,
|
|
allowResurrection = false,
|
|
projectId?: string,
|
|
): Promise<void> {
|
|
/*
|
|
FNXC:ArchiveProjectIsolation 2026-07-14-16:20:
|
|
Transactional archive/delete helpers receive the owning project explicitly because task IDs repeat across projects. The composite predicate is required for atomicity to protect the intended row instead of whichever same-ID row PostgreSQL returns first.
|
|
*/
|
|
await tx
|
|
.update(schema.project.tasks)
|
|
.set({
|
|
column: "archived",
|
|
deletedAt,
|
|
allowResurrection: allowResurrection ? 1 : 0,
|
|
updatedAt: deletedAt,
|
|
})
|
|
.where(and(
|
|
eq(schema.project.tasks.projectId, projectId?.trim() || "__legacy_unscoped__"),
|
|
eq(schema.project.tasks.id, id),
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Read a single task row by id. By default applies the soft-delete visibility
|
|
* filter (VAL-DATA-005 — live readers hide deletedAt rows). Pass
|
|
* `includeDeleted: true` for a forensic read that surfaces soft-deleted rows
|
|
* (VAL-DATA-006).
|
|
*
|
|
* Returns the raw Drizzle row. JSON columns come back already-parsed (jsonb).
|
|
*/
|
|
export async function readTaskRow(
|
|
layer: AsyncDataLayer,
|
|
id: string,
|
|
options?: { includeDeleted?: boolean },
|
|
): Promise<Record<string, unknown> | undefined> {
|
|
const conditions = [eq(schema.project.tasks.id, id)];
|
|
if (!options?.includeDeleted) {
|
|
conditions.push(ACTIVE_TASK_FILTER);
|
|
}
|
|
// FNXC:MultiProjectIsolation 2026-07-10: scope the by-id read to the bound
|
|
// project so one project's store can never resolve another project's task
|
|
// (defence-in-depth; also protects the merger, which loads each merge-queue
|
|
// entry's task via getTask -> readTaskRow and skips when not found).
|
|
const projectScope = taskProjectScope(layer);
|
|
if (projectScope) conditions.push(projectScope);
|
|
const rows = await layer.db
|
|
.select()
|
|
.from(schema.project.tasks)
|
|
.where(and(...conditions));
|
|
return rows[0];
|
|
}
|
|
|
|
/**
|
|
* FNXC:TaskStoreArchiveLineage 2026-06-24-15:05:
|
|
* Read a single task row by id INSIDE a shared transaction handle. This is
|
|
* the transaction-aware variant of {@link readTaskRow} for composite
|
|
* operations (restoreTaskFromArchive) that must read within the same
|
|
* transaction as their sibling writes for a consistent snapshot.
|
|
*
|
|
* HAZARD FIX (runtime-workflow-async): the previous restoreTaskFromArchive
|
|
* called `readTaskRow(layer, ...)` inside its transactionImmediate block, but
|
|
* that helper used `layer.db` — the read ran outside the transaction,
|
|
* returning a non-transactional snapshot that could observe concurrent
|
|
* writes. This variant takes the `tx` handle so the read participates in the
|
|
* surrounding transaction (read-committed snapshot inside the txn).
|
|
*
|
|
* @param tx The transaction handle (from layer.transactionImmediate).
|
|
* @param id The task id to read.
|
|
* @param options Optional: includeDeleted surfaces soft-deleted rows.
|
|
*/
|
|
export async function readTaskRowInTransaction(
|
|
tx: DbTransaction,
|
|
id: string,
|
|
options?: { includeDeleted?: boolean },
|
|
projectId?: string,
|
|
): Promise<Record<string, unknown> | undefined> {
|
|
const conditions = [
|
|
eq(schema.project.tasks.projectId, projectId?.trim() || "__legacy_unscoped__"),
|
|
eq(schema.project.tasks.id, id),
|
|
];
|
|
if (!options?.includeDeleted) {
|
|
conditions.push(ACTIVE_TASK_FILTER);
|
|
}
|
|
const rows = await tx
|
|
.select()
|
|
.from(schema.project.tasks)
|
|
.where(and(...conditions));
|
|
return rows[0];
|
|
}
|
|
|
|
/**
|
|
* Read all live (non-soft-deleted) task rows. This is the live-reader scan that
|
|
* backs active task lists, kanban, and counts. The soft-delete visibility
|
|
* filter (deleted_at IS NULL) is always applied (VAL-DATA-005).
|
|
*
|
|
* FNXC:TaskStoreReads 2026-06-26-10:20:
|
|
* The `excludeLog` option omits the heavy `log` jsonb column (~99% of row
|
|
* payload on busy boards per the slim-read analysis) from the SELECT so the
|
|
* wire transfer is bounded. Callers that need the activity log fetch the
|
|
* individual task via `readTaskRow` (full row). This mirrors the SQLite path's
|
|
* `getTaskSelectClause(slim)` projection.
|
|
*
|
|
* @param layer The async data layer.
|
|
* @param options Optional: excludeLog drops the `log` jsonb column;
|
|
* includeDeleted surfaces soft-deleted rows for forensic reads (VAL-DATA-006);
|
|
* column/excludeColumn filter by board column in SQL; limit/offset paginate
|
|
* in SQL (ordered by createdAt then numeric id suffix).
|
|
*/
|
|
export async function readLiveTaskRows(
|
|
layer: AsyncDataLayer,
|
|
options?: { excludeLog?: boolean; includeDeleted?: boolean; column?: string; excludeColumn?: string; limit?: number; offset?: number },
|
|
): Promise<Record<string, unknown>[]> {
|
|
// FNXC:TaskStoreForensicRead 2026-06-26-15:20:
|
|
// VAL-DATA-006 — Forensic reads surface soft-deleted rows when explicitly
|
|
// requested. By default the live-reader filter (deletedAt IS NULL) is
|
|
// applied so soft-deleted tasks never appear on the board (VAL-DATA-005).
|
|
// When includeDeleted is true the filter is dropped entirely, exposing
|
|
// tombstoned rows for admin/forensic surfaces (e.g. GET /api/tasks?includeDeleted=true).
|
|
// FNXC:MultiProjectIsolation 2026-07-10:
|
|
// THE load-bearing isolation filter. readLiveTaskRows backs store.listTasks(),
|
|
// which the engine scheduler/executor uses to decide what to run, plus the
|
|
// board/kanban/count reads and the /api/tasks list. Scoping it to the bound
|
|
// project is what stops a per-project engine from ever seeing — and therefore
|
|
// claiming/executing in the wrong repo — another project's tasks on the shared
|
|
// embedded-PG cluster. `and(...)` drops undefined operands, so the scope
|
|
// collapses to just the live filter when the layer is project-agnostic.
|
|
const projectScope = taskProjectScope(layer);
|
|
/*
|
|
FNXC:TaskStoreReadsPerf 2026-07-11 (PR #1793 review):
|
|
Push the board filters and pagination into SQL. The previous shape read the
|
|
ENTIRE live task table on every listTasks call and filtered/sorted/sliced in
|
|
JS — every out-of-page row still paid wire transfer plus per-task hydration.
|
|
`column`/`excludeColumn` become WHERE operands, and when the caller paginates
|
|
(limit/offset) the query orders by (created_at, numeric id suffix) — the same
|
|
comparator the JS sort uses — so the SQL page is exactly the JS page.
|
|
*/
|
|
const columnScope = options?.column !== undefined
|
|
? eq(schema.project.tasks.column, options.column)
|
|
: options?.excludeColumn !== undefined
|
|
? sql`${schema.project.tasks.column} IS DISTINCT FROM ${options.excludeColumn}`
|
|
: undefined;
|
|
const liveFilter = options?.includeDeleted
|
|
? and(projectScope, columnScope)
|
|
: and(ACTIVE_TASK_FILTER, projectScope, columnScope);
|
|
const paginate = options?.limit !== undefined || (options?.offset ?? 0) > 0;
|
|
// Mirrors the JS comparator: createdAt ASC, then the numeric suffix of the
|
|
// task id ("FN-12" → 12; no trailing digits → 0). substring() returns NULL
|
|
// (→ 0) instead of throwing on ids without a numeric suffix.
|
|
const createdAtIdOrder = [
|
|
sql`${schema.project.tasks.createdAt} ASC`,
|
|
sql`COALESCE(substring(${schema.project.tasks.id} from '-([0-9]+)$')::int, 0) ASC`,
|
|
];
|
|
const applyPagination = <Q extends { orderBy: (...o: SQL[]) => Q; limit: (n: number) => Q; offset: (n: number) => Q }>(query: Q): Q => {
|
|
if (!paginate) return query;
|
|
let q = query.orderBy(...createdAtIdOrder);
|
|
if (options?.limit !== undefined) q = q.limit(Math.max(0, options.limit));
|
|
const offset = options?.offset ?? 0;
|
|
if (offset > 0) q = q.offset(offset);
|
|
return q;
|
|
};
|
|
if (options?.excludeLog) {
|
|
// FNXC:TaskStoreReads 2026-06-26-11:45:
|
|
// Select every column except `log` via a Drizzle `.select({...projection})`
|
|
// query. Drizzle emits correct snake_case SQL identifiers for each Column
|
|
// chunk (avoiding the earlier camelCase-vs-snake_case bug) and returns rows
|
|
// keyed by the TS property name so the downstream `pgRowToTaskRow` /
|
|
// `rowToTask` deserializers work unchanged. `log` is restored to `[]` when
|
|
// a single task is fetched in full via `readTaskRow`.
|
|
let query = layer.db.select(TASK_SLIM_PROJECTION).from(schema.project.tasks).$dynamic();
|
|
if (liveFilter) query = query.where(liveFilter);
|
|
const rows = await applyPagination(query);
|
|
return rows as unknown as Record<string, unknown>[];
|
|
}
|
|
let query = layer.db.select().from(schema.project.tasks).$dynamic();
|
|
if (liveFilter) query = query.where(liveFilter);
|
|
return applyPagination(query);
|
|
}
|
|
|
|
/**
|
|
* FNXC:TaskStorePersistence 2026-06-24-13:20:
|
|
* Count live (non-soft-deleted) tasks. Soft-deleted rows are excluded so the
|
|
* board count never includes tombstoned tasks (VAL-DATA-005).
|
|
*/
|
|
export async function countLiveTasks(layer: AsyncDataLayer): Promise<number> {
|
|
// FNXC:MultiProjectIsolation 2026-07-10: scope live counts to the bound project.
|
|
const rows = await layer.db
|
|
.select({ count: sql<number>`count(*)::int` })
|
|
.from(schema.project.tasks)
|
|
.where(and(ACTIVE_TASK_FILTER, taskProjectScope(layer)));
|
|
return rows[0]?.count ?? 0;
|
|
}
|
|
|
|
/**
|
|
* FNXC:RuntimeTaskOrchestrationAsync 2026-06-24-13:00:
|
|
* Upsert a task row inside a transaction (the updateTask backend-mode path).
|
|
* This is the async equivalent of the sync `upsertTask` — it performs an
|
|
* INSERT ... ON CONFLICT (id) DO UPDATE so an existing task row is updated in
|
|
* place and a new row is inserted if it does not exist.
|
|
*
|
|
* The upsert is used by `updateTask` (which always reads the task first and
|
|
* then writes the updated fields). Create-class operations MUST use
|
|
* `insertTaskRow` (non-destructive plain insert) instead, never this upsert.
|
|
*
|
|
* @param tx The transaction handle from layer.transactionImmediate.
|
|
* @param taskRecord A record carrying the Task fields to persist.
|
|
* @param context Serialization context (lineageId).
|
|
*/
|
|
export async function upsertTaskRowInTransaction(
|
|
tx: DbTransaction,
|
|
taskRecord: Record<string, unknown>,
|
|
context: TaskPersistSerializationContext,
|
|
projectId?: string,
|
|
): Promise<void> {
|
|
const values = buildTaskInsertValues(taskRecord, context, projectId);
|
|
const updateValues: Record<string, unknown> = {};
|
|
for (const [key, value] of Object.entries(values)) {
|
|
// Never rewrite the primary key or the per-project partition key on update
|
|
// (FNXC:MultiProjectIsolation — project_id is stable for a task's lifetime).
|
|
if (key === "id" || key === "projectId") continue;
|
|
updateValues[key] = value;
|
|
}
|
|
await tx
|
|
.insert(schema.project.tasks)
|
|
.values(values as never)
|
|
.onConflictDoUpdate({
|
|
target: [schema.project.tasks.projectId, schema.project.tasks.id],
|
|
set: updateValues as never,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* FNXC:RuntimeTaskOrchestrationAsync 2026-06-24-13:05:
|
|
* Update a subset of task columns by id (the updateTask backend-mode path
|
|
* alternative when only specific columns changed). This builds a targeted
|
|
* UPDATE statement rather than a full row upsert.
|
|
*
|
|
* @param layer The async data layer.
|
|
* @param id The task id to update.
|
|
* @param updates A record of column → value to SET.
|
|
*/
|
|
export async function updateTaskColumns(
|
|
layer: AsyncDataLayer,
|
|
id: string,
|
|
updates: Record<string, unknown>,
|
|
): Promise<void> {
|
|
if (Object.keys(updates).length === 0) return;
|
|
await layer.db
|
|
.update(schema.project.tasks)
|
|
.set(updates as never)
|
|
.where(and(
|
|
eq(schema.project.tasks.projectId, layer.projectId?.trim() || "__legacy_unscoped__"),
|
|
eq(schema.project.tasks.id, id),
|
|
));
|
|
}
|
|
|
|
/**
|
|
* FNXC:TaskStorePersistence 2026-06-24-13:25:
|
|
* Detect whether a primary-key violation is a PostgreSQL unique_violation
|
|
* (error code 23505). The sync path used a regex against the SQLite message
|
|
* (`SQLITE_CONSTRAINT|UNIQUE constraint failed: tasks.id`); PostgreSQL reports
|
|
* a structured `code` field. Drizzle wraps postgres.js errors in a
|
|
* "Failed query: ..." Error whose `cause` is the original `PostgresError`
|
|
* carrying the `code`, so we inspect both the error and its `cause`. Both
|
|
* SQLite and PostgreSQL checks are kept so the helper is robust across backends
|
|
* during the transition.
|
|
*/
|
|
export function isTaskIdConflictError(error: unknown): boolean {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
if (/SQLITE_CONSTRAINT|UNIQUE constraint failed: tasks\.(id|proposalClaimId)|PRIMARY KEY constraint failed: tasks\.id/i.test(message)) {
|
|
return true;
|
|
}
|
|
return isPostgresUniqueError(error);
|
|
}
|