Skip to content
Closed
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
13 changes: 12 additions & 1 deletion .agents/skills/test-t3-app/references/sqlite-fixtures.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Load this reference only when inspecting or seeding local T3 state directly.

## Select the correct database

When `--base-dir` or `--home-dir` is explicit, runtime state lives under `<base-dir>/userdata` and the database path is `<base-dir>/userdata/state.sqlite`. The `<base-dir>/dev` state directory is only the fallback for an implicit development home, preventing an ordinary `vp run dev` from touching production state.
When `--base-dir` or `--home-dir` is explicit, runtime state lives under `<base-dir>/userdata`. Hot projections and runtime state are stored in `state.sqlite`; cold archive manifests and compressed chunks are stored in `archive.sqlite`. The `<base-dir>/dev` state directory is only the fallback for an implicit development home, preventing an ordinary `vp run dev` from touching production state.

Start the target runtime once before seeding so all migrations have run. Use an isolated base directory. Stop the server before writes to avoid racing application state or an active projection.

Expand All @@ -18,6 +18,15 @@ node apps/server/scripts/t3-sqlite-state.ts query \
--sql "SELECT name FROM sqlite_schema WHERE type = 'table' ORDER BY name"
```

The helper targets `state.sqlite` by default. Select the cold archive database explicitly for read-only inspection:

```bash
node apps/server/scripts/t3-sqlite-state.ts query \
--base-dir <base-dir> \
--database archive \
--sql "SELECT t.thread_id, t.archive_version, COUNT(c.chunk_index) AS chunk_count FROM archive_threads AS t LEFT JOIN archive_thread_chunks AS c ON c.thread_id = t.thread_id GROUP BY t.thread_id, t.archive_version ORDER BY t.thread_id"
```

Inspect current columns before writing a fixture:

```bash
Expand All @@ -36,6 +45,8 @@ node apps/server/scripts/t3-sqlite-state.ts exec \

Use one statement per invocation for both `query` and `exec`; the helper wraps writes in a transaction and prints the backup path after a successful mutation. Use a single insert with multiple value rows when a fixture needs several records.

Cold archive manifests and chunks are authoritative recovery data. Prefer `--database archive` for queries only. Exercise archive and restore behavior through application commands and APIs instead of constructing or changing archive bundles directly; only use archive writes for a deliberately isolated corruption or compatibility fixture.

## Seed projection data carefully

The web UI primarily reads these projection tables:
Expand Down
30 changes: 28 additions & 2 deletions apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { EnvironmentId } from "@t3tools/contracts";
import * as Arr from "effect/Array";
import * as Order from "effect/Order";
import { useFocusEffect } from "@react-navigation/native";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";

import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
import { useArchivedThreadListActions } from "../home/useThreadListActions";
Expand All @@ -15,12 +15,18 @@ import {
refreshArchivedThreadsForEnvironment,
useArchivedThreadSnapshots,
} from "./useArchivedThreadSnapshots";
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import { scopedThreadKey } from "../../lib/scopedEntities";

export function ArchivedThreadsRouteScreen() {
const { savedConnectionsById } = useSavedRemoteConnections();
const [searchQuery, setSearchQuery] = useState("");
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<EnvironmentId | null>(null);
const [sortOrder, setSortOrder] = useState<ArchivedThreadSortOrder>("newest");
const [unarchivingThreadKeys, setUnarchivingThreadKeys] = useState<ReadonlySet<string>>(
() => new Set(),
);
const unarchivingThreadKeysRef = useRef(new Set<string>());
const environments = useMemo<ReadonlyArray<ArchivedThreadsHeaderEnvironment>>(
() =>
Arr.sort(
Expand Down Expand Up @@ -65,6 +71,25 @@ export function ArchivedThreadsRouteScreen() {
);
const { unarchiveThread, confirmDeleteThread } =
useArchivedThreadListActions(refreshChangedEnvironment);
const handleUnarchiveThread = useCallback(
async (thread: EnvironmentThreadShell) => {
const threadKey = scopedThreadKey(thread.environmentId, thread.id);
if (unarchivingThreadKeysRef.current.has(threadKey)) return;
unarchivingThreadKeysRef.current.add(threadKey);
setUnarchivingThreadKeys((current) => new Set(current).add(threadKey));
try {
await unarchiveThread(thread);
} finally {
unarchivingThreadKeysRef.current.delete(threadKey);
setUnarchivingThreadKeys((current) => {
const next = new Set(current);
next.delete(threadKey);
return next;
});
}
},
[unarchiveThread],
);

useFocusEffect(
useCallback(() => {
Expand All @@ -83,10 +108,11 @@ export function ArchivedThreadsRouteScreen() {
onRefresh={refresh}
onSearchQueryChange={setSearchQuery}
onSortOrderChange={setSortOrder}
onUnarchiveThread={unarchiveThread}
onUnarchiveThread={(thread) => void handleUnarchiveThread(thread)}
searchQuery={searchQuery}
selectedEnvironmentId={selectedEnvironmentId}
sortOrder={sortOrder}
unarchivingThreadKeys={unarchivingThreadKeys}
/>
);
}
48 changes: 34 additions & 14 deletions apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,15 @@ import { AppText as Text } from "../../components/AppText";
import { EmptyState } from "../../components/EmptyState";
import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
import { ProjectFavicon } from "../../components/ProjectFavicon";
import { relativeTime } from "../../lib/time";
import { scopedThreadKey } from "../../lib/scopedEntities";
import { useUniwindTheme } from "../../lib/useUniwindTheme";
import { useServerConfigs } from "../../state/entities";
import { ThreadSwipeable } from "../home/thread-swipe-actions";
import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList";
import {
formatArchivedThreadRelativeTime,
type ArchivedThreadGroup,
type ArchivedThreadSortOrder,
} from "./archivedThreadList";
import { SettingsScreenContent } from "../settings/components/SettingsScreen";

export interface ArchivedThreadsHeaderEnvironment {
Expand Down Expand Up @@ -182,6 +186,7 @@ function ArchivedThreadRow(props: {
readonly environmentLabel: string | null;
readonly isFirst: boolean;
readonly isLast: boolean;
readonly isUnarchiving: boolean;
readonly onDelete: () => void;
readonly onSwipeableClose: (methods: SwipeableMethods) => void;
readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
Expand All @@ -191,10 +196,13 @@ function ArchivedThreadRow(props: {
}) {
const { width: windowWidth } = useWindowDimensions();
const cardColor = useUniwindTheme()["--color-card"];
const timestamp = relativeTime(props.thread.archivedAt ?? props.thread.updatedAt);
const timestamp = formatArchivedThreadRelativeTime(
props.thread.archivedAt ?? props.thread.updatedAt,
);
const subtitle = [props.environmentLabel, props.thread.branch].filter((part): part is string =>
Boolean(part),
);
const onDelete = props.isUnarchiving ? () => undefined : props.onDelete;
return (
<ThreadSwipeable
resetKey={`${props.thread.environmentId}:${props.thread.id}`}
Expand All @@ -209,15 +217,16 @@ function ArchivedThreadRow(props: {
borderBottomRightRadius: props.isLast ? 20 : 0,
overflow: "hidden",
}}
enabled={!props.isUnarchiving}
fullSwipeWidth={windowWidth - 32}
onDelete={props.onDelete}
onDelete={onDelete}
onSwipeableClose={props.onSwipeableClose}
onSwipeableWillOpen={props.onSwipeableWillOpen}
primaryAction={{
accessibilityLabel: `Unarchive ${props.thread.title}`,
icon: "arrow.uturn.backward",
label: "Unarchive",
onPress: props.onUnarchive,
onPress: props.isUnarchiving ? () => undefined : props.onUnarchive,
}}
simultaneousWith={props.simultaneousSwipeGesture}
threadTitle={props.thread.title}
Expand All @@ -227,12 +236,16 @@ function ArchivedThreadRow(props: {
className={`flex-row items-center gap-3 bg-card px-4 py-3 ${props.isLast ? "" : "border-b border-separator"}`}
>
<View className="h-[34px] w-[34px] items-center justify-center rounded-[11px] bg-subtle">
<SymbolView
name="archivebox.fill"
size={15}
tintColorClassName="accent-icon-subtle"
type="monochrome"
/>
{props.isUnarchiving ? (
<ActivityIndicator colorClassName="accent-icon-subtle" size="small" />
) : (
<SymbolView
name="archivebox.fill"
size={15}
tintColorClassName="accent-icon-subtle"
type="monochrome"
/>
)}
</View>

<View className="min-w-0 flex-1 gap-1">
Expand All @@ -243,9 +256,11 @@ function ArchivedThreadRow(props: {
>
{props.thread.title}
</Text>
<Text className="min-w-[30px] text-right text-xs tabular-nums text-foreground-tertiary">
{timestamp}
</Text>
{timestamp ? (
<Text className="min-w-[30px] text-right text-xs tabular-nums text-foreground-tertiary">
{timestamp}
</Text>
) : null}
</View>
{subtitle.length > 0 ? (
<View className="flex-row items-center gap-1.5">
Expand Down Expand Up @@ -298,6 +313,7 @@ export function ArchivedThreadsScreen(props: {
readonly onSearchQueryChange: (query: string) => void;
readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void;
readonly onUnarchiveThread: (thread: EnvironmentThreadShell) => void;
readonly unarchivingThreadKeys: ReadonlySet<string>;
}) {
const { onDeleteThread, onUnarchiveThread } = props;
const openSwipeableRef = useRef<SwipeableMethods | null>(null);
Expand Down Expand Up @@ -369,6 +385,9 @@ export function ArchivedThreadsScreen(props: {
environmentLabel={item.environmentLabel}
isFirst={item.isFirst}
isLast={item.isLast}
isUnarchiving={props.unarchivingThreadKeys.has(
scopedThreadKey(item.thread.environmentId, item.thread.id),
)}
onDelete={() => onDeleteThread(item.thread)}
onSwipeableClose={handleSwipeableClose}
onSwipeableWillOpen={handleSwipeableWillOpen}
Expand All @@ -384,6 +403,7 @@ export function ArchivedThreadsScreen(props: {
handleSwipeableWillOpen,
onDeleteThread,
onUnarchiveThread,
props.unarchivingThreadKeys,
],
);
const listEmptyComponent = useMemo(() => {
Expand Down
76 changes: 75 additions & 1 deletion apps/mobile/src/features/archive/archivedThreadList.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { OrchestrationProjectShell, OrchestrationThreadShell } from "@t3too
import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";

import { buildArchivedThreadGroups } from "./archivedThreadList";
import { buildArchivedThreadGroups, formatArchivedThreadRelativeTime } from "./archivedThreadList";

const environmentId = EnvironmentId.make("environment-1");

Expand Down Expand Up @@ -144,4 +144,78 @@ describe("buildArchivedThreadGroups", () => {

expect(result).toEqual([]);
});

it("sorts invalid archive timestamps after valid timestamps in either direction", () => {
const project = makeProject({ id: ProjectId.make("project-1"), title: "T3 Code" });
const invalid = makeThread({
archivedAt: "not-a-timestamp",
id: ThreadId.make("thread-invalid"),
projectId: project.id,
title: "Invalid",
});
const anotherInvalid = makeThread({
archivedAt: "2026-02-30T00:00:00.000Z",
id: ThreadId.make("thread-another-invalid"),
projectId: project.id,
title: "Another invalid",
});
const older = makeThread({
id: ThreadId.make("thread-older"),
projectId: project.id,
title: "Older",
});
const newer = makeThread({
archivedAt: "2026-06-03T00:00:00.000Z",
id: ThreadId.make("thread-newer"),
projectId: project.id,
title: "Newer",
});
const snapshots = [makeSnapshot([project], [invalid, anotherInvalid, older, newer])];

const newest = buildArchivedThreadGroups({
snapshots,
environmentLabels: {},
environmentId: null,
searchQuery: "",
sortOrder: "newest",
});
const oldest = buildArchivedThreadGroups({
snapshots,
environmentLabels: {},
environmentId: null,
searchQuery: "",
sortOrder: "oldest",
});

expect(newest[0]?.threads.map((thread) => thread.id)).toEqual([
"thread-newer",
"thread-older",
"thread-another-invalid",
"thread-invalid",
]);
expect(oldest[0]?.threads.map((thread) => thread.id)).toEqual([
"thread-older",
"thread-newer",
"thread-another-invalid",
"thread-invalid",
]);
});
});

describe("formatArchivedThreadRelativeTime", () => {
it.each(["not-a-timestamp", "2026-02-30T00:00:00.000Z", "2025-02-29T12:00:00+02:00"])(
"omits invalid archive timestamp %s",
(timestamp) => {
expect(formatArchivedThreadRelativeTime(timestamp)).toBeNull();
},
);

it.each([
"2024-02-29T12:00:00+02:00",
"2026-06-01T23:30:00-05:00",
"0099-01-01T00:00:00Z",
"+010000-01-01T00:00:00Z",
])("keeps the label for valid archive timestamp %s", (timestamp) => {
expect(formatArchivedThreadRelativeTime(timestamp)).not.toBeNull();
});
});
38 changes: 31 additions & 7 deletions apps/mobile/src/features/archive/archivedThreadList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import {
type EnvironmentThreadShell,
} from "@t3tools/client-runtime/state/shell";
import type { EnvironmentId } from "@t3tools/contracts";
import { parseTimestamp as parseZonedTimestamp } from "@t3tools/shared/dateTime";
import * as Arr from "effect/Array";
import * as Order from "effect/Order";

import { scopedProjectKey } from "../../lib/scopedEntities";
import { relativeTime } from "../../lib/time";

export type ArchivedThreadSortOrder = "newest" | "oldest";

Expand All @@ -19,15 +21,33 @@ export interface ArchivedThreadGroup {
readonly threads: ReadonlyArray<EnvironmentThreadShell>;
}

function archiveTimestamp(thread: EnvironmentThreadShell): number {
const timestamp = Date.parse(thread.archivedAt ?? thread.updatedAt);
return Number.isNaN(timestamp) ? 0 : timestamp;
function parseTimestamp(input: string): number | null {
const timestamp = parseZonedTimestamp(input);
return Number.isNaN(timestamp) ? null : timestamp;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

function archiveTimestamp(thread: EnvironmentThreadShell): number | null {
return parseTimestamp(thread.archivedAt ?? thread.updatedAt);
}

export function formatArchivedThreadRelativeTime(input: string): string | null {
return parseTimestamp(input) === null ? null : relativeTime(input);
}

function matchesQuery(value: string | null, query: string): boolean {
return value?.toLocaleLowerCase().includes(query) ?? false;
}

function makeArchiveTimestampOrder(sortOrder: ArchivedThreadSortOrder): Order.Order<number | null> {
const timestampOrder = sortOrder === "newest" ? Order.flip(Order.Number) : Order.Number;
return Order.make((left, right) => {
if (left === null && right === null) return 0;
if (left === null) return 1;
if (right === null) return -1;
return timestampOrder(left, right);
});
}

export function buildArchivedThreadGroups(input: {
readonly snapshots: ReadonlyArray<ArchivedSnapshotEntry>;
readonly environmentLabels: Readonly<Record<string, string>>;
Expand Down Expand Up @@ -72,14 +92,18 @@ export function buildArchivedThreadGroups(input: {
continue;
}

const timestampOrder = input.sortOrder === "newest" ? Order.flip(Order.Number) : Order.Number;
const timestampOrder = makeArchiveTimestampOrder(input.sortOrder);
groups.push({
key: scopedProjectKey(project.environmentId, project.id),
project,
threads: Arr.sort(
matchingThreads,
Order.mapInput(
Order.Struct({ timestamp: timestampOrder, title: Order.String, id: Order.String }),
Order.Struct({
timestamp: timestampOrder,
title: Order.String,
id: Order.String,
}),
(thread: EnvironmentThreadShell) => ({
timestamp: archiveTimestamp(thread),
title: thread.title,
Expand All @@ -91,13 +115,13 @@ export function buildArchivedThreadGroups(input: {
}
}

const timestampOrder = input.sortOrder === "newest" ? Order.flip(Order.Number) : Order.Number;
const timestampOrder = makeArchiveTimestampOrder(input.sortOrder);
return Arr.sort(
groups,
Order.mapInput(
Order.Struct({ timestamp: timestampOrder, title: Order.String, key: Order.String }),
(group: ArchivedThreadGroup) => ({
timestamp: group.threads[0] ? archiveTimestamp(group.threads[0]) : 0,
timestamp: group.threads[0] ? archiveTimestamp(group.threads[0]) : null,
title: group.project.title,
key: group.key,
}),
Expand Down
Loading
Loading