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
5 changes: 4 additions & 1 deletion .claude/skills/ship/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@ Follow `CLAUDE.md` throughout (hard rules 1–5 + the Working-discipline section
- Report the **PR URL**.

## 7 — 🛑 Merge gate — STOP
Do **not** merge. Summarise what shipped and the PR link, start serving the just-built app locally (for manual testing), and wait. If there is another serving process on the same machine, kill the process to retake the port. Merging to `main` is a human call.
Do **not** merge. Summarise what shipped and the PR link, run `npm run local`
to serve the just-built app for manual testing, and wait. If there is another
serving process on the same machine, kill the process to retake the port.
Merging to `main` is a human call.

## After — friction → memory
If anything needed retries or surprised you (test / env / scope), save a memory so the next `/ship` doesn't repeat it. This session does the saving, not a subagent it spawned.
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ auto-generated per-PR notes; this file is the curated, human-readable history.

## [Unreleased]

### Added
- **Multi-workspace local persistence foundation** (#406). Stored workspaces
now use the V2 contract with separate immutable opaque `id`, immutable
human-readable URL `key`, and mutable display `name`. IndexedDB stores one
validated aggregate per workspace behind an ID key path and unique key
index; the repository can list summaries, load by ID/key, create, replace,
and delete individual records without affecting neighbors. Last-used key and
injected-clock `lastOpenedAt` metadata select the most recently opened valid
workspace for implicit startup, while explicit `?ws=` lookup never falls
back. Workspace import is additive with reminted local identity, Dashboard
links resolve stable keys, and active edits reload/commit by immutable ID.

## [0.6.2] - 2026-07-23

### Fixed
Expand Down
28 changes: 16 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -436,22 +436,26 @@ remembered result view and chart config. Saving or editing a query opens a small
form with both a name and a description field; the description shows under the
row and is included in Markdown/SQL exports.

The queries plus the zero-or-one editable Dashboard form one **current workspace**
(a `StoredWorkspaceV1` aggregate). The workspace is persisted **atomically** in the
browser (IndexedDB) — a reload restores queries, Dashboard, layout, and workspace
name together; every saved-query edit commits the whole workspace and only
publishes once persistence succeeds. The header **File ▾** menu carries a name, an
unsaved-changes dot (changes since the last export or import), and these
resource-oriented operations (#287):
The queries plus the zero-or-one editable Dashboard form a workspace
(`StoredWorkspaceV2`). A browser profile can keep multiple workspaces in
IndexedDB; each has an immutable opaque id, a stable human-readable URL key,
and a mutable display name. Each record is persisted **atomically** — a reload
restores its queries, Dashboard, layout, and name together; every saved-query
edit commits the whole active workspace and only publishes once persistence
succeeds. Implicit startup reopens the last successfully used workspace.

The header **File ▾** menu carries the active name, an unsaved-changes dot
(changes since the last export or import), and these resource-oriented
operations:

The first four rows are one unlabeled primary-workspace-action group, in this
order (#342):

- **New workspace…** — clears to an empty, default-named workspace (confirms first
when non-empty). Open editor tabs are unaffected.
- **Open workspace…** — atomically replace **both** the query collection and
the Dashboard from a file (confirms first). A multi-Dashboard file asks which
Dashboard to adopt (or none).
- **New workspace…** — creates and activates a new empty, default-named
workspace without deleting the previous one. Open editor tabs are unaffected.
- **Import workspace…** — creates and activates a new local workspace from a
portable bundle; imported identity is reminted and made unique. A
multi-Dashboard file asks which Dashboard to adopt (or none).
- **Export workspace…** (`.json`) — write the one canonical
**`altinity-sql-browser/portable-bundle`** interchange format: every saved
query (catalog order) plus the zero-or-one Dashboard. Uses the deterministic
Expand Down
8 changes: 4 additions & 4 deletions build/schema-manifest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,10 @@ export const SCHEMA_MANIFEST = [
typeExport: 'DashboardDocumentV1',
},
{
path: 'schemas/stored-workspace-v1.schema.json',
schemaExport: 'storedWorkspaceV1Schema',
validatorExport: 'validateStoredWorkspaceV1',
typeExport: 'StoredWorkspaceV1',
path: 'schemas/stored-workspace-v2.schema.json',
schemaExport: 'storedWorkspaceV2Schema',
validatorExport: 'validateStoredWorkspaceV2',
typeExport: 'StoredWorkspaceV2',
},
{
path: 'schemas/portable-bundle-v1.schema.json',
Expand Down
4 changes: 4 additions & 0 deletions docs/ADR-0001-reactivity.md
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,10 @@ as the legacy-migration source). This is a state-flow change worth recording
against this ADR because it re-shapes how `state.savedQueries` relates to
reactivity:

`StoredWorkspaceV2` and the collection repository introduced by #406 preserve
this projection/commit model per workspace; they replace only the fixed-current
record and V1 identity contract.

- `state.savedQueries` is now a **projection** of the committed workspace, not a
directly-mutated array. Boot loads the aggregate and projects it (queries,
Dashboard, workspace id/name); every file operation commits and re-projects
Expand Down
39 changes: 33 additions & 6 deletions docs/ADR-0003-dashboard-viewing.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ product model, which this ADR records.
| Entry | Workbench header `Dashboard →` control | Dashboard header **File → "Open for viewing…"** |
| Tab | new tab | new tab |
| Open source | `current-workspace` (`?ws=&dash=`) | detached snapshot of the current dashboard |
| Storage | **shared** primary workspace store (`asb-workspace`) | its **own** detached store (`asb-dashboard-views`), fresh id |
| Storage | **shared** primary workspace collection (`asb-workspaces-v2`) | its **own** detached store (`asb-dashboard-views`), fresh id |
| Editable | yes — drag reorder + layout preset persist to the shared aggregate | **read-only** |
| Auth | existing postMessage credential handoff (#149) | same |
| Survives relogin / reload | yes (shared store) | yes (own persisted record) |
Expand All @@ -48,7 +48,7 @@ credential handoff.

```ts
type DashboardOpenSource =
| { kind: 'current-workspace'; workspaceId: string; dashboardId: string }
| { kind: 'current-workspace'; workspaceKey: string; dashboardId: string }
| { kind: 'session-bundle'; token: string; dashboardId: string };
```

Expand All @@ -62,8 +62,8 @@ current-workspace; `?st=&dash=` is session-bundle.
> `bootstrap` (`src/main.ts`) reads `?state` as the OAuth CSRF value; a token
> there would raise "OAuth state mismatch".

**Mode is discriminated by which store the `ws` id resolves in**, not by a
spoofable URL flag: the id present in the primary workspace store → edit mode;
**Mode is discriminated by which store the `ws` key resolves in**, not by a
spoofable URL flag: the key present in the primary workspace store → edit mode;
present in the detached views store → view mode; in neither → a not-found panel
that executes nothing (never silently opens a different dashboard).

Expand All @@ -84,7 +84,8 @@ through a one-time IndexedDB token, then materializes a durable detached copy:
transaction (`take` = get+delete, then reject if expired), and strips `st`
from the URL via `history.replaceState`.
5. It **materializes** the bundle into the detached store under
`detachedWorkspaceId`, rewrites the URL to `?ws=<detachedWorkspaceId>&dash=`,
`detachedWorkspaceId`, assigns the detached copy a stable local key, rewrites
the URL to `?ws=<detachedWorkspaceKey>&dash=`,
and renders read-only.

Auth is orthogonal and unchanged: the new tab still restores credentials via the
Expand All @@ -105,7 +106,8 @@ detached `?ws=` URL survives).
so there is no untrusted external SQL to gate. The viewer's existing safety
limits (row/byte caps, bounded concurrency, per-tile cancellation, stale-wave
protection, no Setup execution) still apply.
- **A new IndexedDB store family.** Two dedicated databases join `asb-workspace`:
- **A new IndexedDB store family.** Two dedicated databases join the primary
workspace database (now `asb-workspaces-v2`):
`asb-dashboard-handoff` (one-time tokens) and `asb-dashboard-views`
(multi-record detached snapshots, keyed by workspace id, with a small retention
cap so abandoned views don't grow unbounded). Both reuse the #284 adapter
Expand All @@ -131,3 +133,28 @@ detached `?ws=` URL survives).
suffix.
- **A `?view=1` mode flag:** rejected in favor of store-membership discrimination
(not spoofable, and naturally yields the not-found case).

## Addendum — 2026-07-23: stable workspace keys and collection persistence (#406)

`StoredWorkspaceV2` replaces the single fixed `current` aggregate with one
IndexedDB record per workspace. Workspace identity is deliberately split:

- `id` is the immutable opaque object-store key;
- `key` is the immutable, unique lowercase ASCII URL identity resolved by
`?ws=`;
- `name` is mutable display metadata and never rewrites bookmarks.

The primary store uses `id` as its key path and a unique `key` index. Create,
replace, delete, and last-opened metadata updates are transaction-scoped;
replace cannot create a missing record or alter its key. Portable workspace
import creates a fresh local ID/key instead of replacing the active workspace.

Implicit opens persist the successfully opened key in a separate preferences
store and stamp store-owned `lastOpenedAt` metadata using the injected wall
clock. If that preference is absent or invalid, resolution chooses the valid
workspace with the newest timestamp, breaking ties by key; records without a
timestamp fall back deterministically by key. Explicit `?ws=` opens never fall
back to another workspace.

The detached-view store follows the same key-based `?ws=` resolution contract,
while its generated local key remains separate from its opaque detached ID.
26 changes: 17 additions & 9 deletions schemas/generated/library-v2.bundle.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1735,39 +1735,46 @@
}
}
},
"stored-workspace-v1": {
"stored-workspace-v2": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://altinity.com/schemas/altinity-sql-browser/stored-workspace-v1.schema.json",
"title": "Altinity SQL Browser stored workspace v1",
"description": "The atomic browser-persistence aggregate: one workspace with an ordered saved-query collection and zero or one Dashboard. Internal persistence contract; portable interchange uses portable-bundle-v1 instead.",
"$id": "https://altinity.com/schemas/altinity-sql-browser/stored-workspace-v2.schema.json",
"title": "Altinity SQL Browser stored workspace v2",
"description": "One independently addressable browser-persistence aggregate with immutable application and URL identities, an ordered saved-query collection, and zero or one Dashboard. Internal persistence contract; portable interchange uses portable-bundle-v1 instead.",
"x-altinity-kind": "stored-workspace",
"x-altinity-version": 1,
"x-altinity-version": 2,
"type": "object",
"required": [
"storageVersion",
"id",
"key",
"name",
"queries",
"dashboard"
],
"properties": {
"storageVersion": {
"title": "Storage version",
"description": "Stored-workspace contract version; always 1 for this contract. Unknown future versions fail closed.",
"description": "Stored-workspace contract version; always 2 for this contract. Unknown future versions fail closed.",
"type": "integer",
"const": 1
"const": 2
},
"id": {
"title": "Workspace identifier",
"description": "Stable generated workspace identity. Two files with the same name still produce distinct workspace IDs.",
"description": "Stable generated application identity. Two workspaces with the same display name still have distinct IDs.",
"type": "string",
"minLength": 1,
"maxLength": 256,
"pattern": "\\S"
},
"key": {
"title": "Workspace URL key",
"description": "Stable lowercase ASCII identity used by workspace URLs. It is immutable after creation and unique case-insensitively within the local repository.",
"type": "string",
"pattern": "^[a-z0-9][a-z0-9_-]*$"
},
"name": {
"title": "Workspace name",
"description": "User-visible workspace name. Renaming the workspace does not rename its Dashboard.",
"description": "Mutable user-visible workspace name. Renaming it does not change the workspace ID, URL key, or Dashboard title.",
"type": "string",
"maxLength": 512
},
Expand Down Expand Up @@ -1797,6 +1804,7 @@
"x-altinity-order": [
"storageVersion",
"id",
"key",
"name",
"queries",
"dashboard"
Expand Down
6 changes: 3 additions & 3 deletions schemas/generated/schema-catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,9 @@
},
{
"kind": "stored-workspace",
"version": 1,
"id": "https://altinity.com/schemas/altinity-sql-browser/stored-workspace-v1.schema.json",
"path": "../stored-workspace-v1.schema.json"
"version": 2,
"id": "https://altinity.com/schemas/altinity-sql-browser/stored-workspace-v2.schema.json",
"path": "../stored-workspace-v2.schema.json"
},
{
"kind": "portable-bundle",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,30 +1,36 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://altinity.com/schemas/altinity-sql-browser/stored-workspace-v1.schema.json",
"title": "Altinity SQL Browser stored workspace v1",
"description": "The atomic browser-persistence aggregate: one workspace with an ordered saved-query collection and zero or one Dashboard. Internal persistence contract; portable interchange uses portable-bundle-v1 instead.",
"$id": "https://altinity.com/schemas/altinity-sql-browser/stored-workspace-v2.schema.json",
"title": "Altinity SQL Browser stored workspace v2",
"description": "One independently addressable browser-persistence aggregate with immutable application and URL identities, an ordered saved-query collection, and zero or one Dashboard. Internal persistence contract; portable interchange uses portable-bundle-v1 instead.",
"x-altinity-kind": "stored-workspace",
"x-altinity-version": 1,
"x-altinity-version": 2,
"type": "object",
"required": ["storageVersion", "id", "name", "queries", "dashboard"],
"required": ["storageVersion", "id", "key", "name", "queries", "dashboard"],
"properties": {
"storageVersion": {
"title": "Storage version",
"description": "Stored-workspace contract version; always 1 for this contract. Unknown future versions fail closed.",
"description": "Stored-workspace contract version; always 2 for this contract. Unknown future versions fail closed.",
"type": "integer",
"const": 1
"const": 2
},
"id": {
"title": "Workspace identifier",
"description": "Stable generated workspace identity. Two files with the same name still produce distinct workspace IDs.",
"description": "Stable generated application identity. Two workspaces with the same display name still have distinct IDs.",
"type": "string",
"minLength": 1,
"maxLength": 256,
"pattern": "\\S"
},
"key": {
"title": "Workspace URL key",
"description": "Stable lowercase ASCII identity used by workspace URLs. It is immutable after creation and unique case-insensitively within the local repository.",
"type": "string",
"pattern": "^[a-z0-9][a-z0-9_-]*$"
},
"name": {
"title": "Workspace name",
"description": "User-visible workspace name. Renaming the workspace does not rename its Dashboard.",
"description": "Mutable user-visible workspace name. Renaming it does not change the workspace ID, URL key, or Dashboard title.",
"type": "string",
"maxLength": 512
},
Expand All @@ -45,5 +51,5 @@
}
},
"additionalProperties": false,
"x-altinity-order": ["storageVersion", "id", "name", "queries", "dashboard"]
"x-altinity-order": ["storageVersion", "id", "key", "name", "queries", "dashboard"]
}
3 changes: 2 additions & 1 deletion src/application/saved-query-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ import type { QueryTimeRangeInferenceDiagnostic } from '../core/query-time-range
* narrowed to their own exact reads) need between them. */
export interface SavedQueryServiceDeps {
state: Pick<AppState,
'savedQueries' | 'resultView' | 'libraryDirty' | 'history' | 'libraryName' | 'workspaceId' | 'dashboard'>;
'savedQueries' | 'resultView' | 'libraryDirty' | 'history' | 'libraryName'
| 'workspaceId' | 'workspaceKey' | 'dashboard'>;
saveJSON: SaveJSON;
/** `createSavedQuery`'s minting timestamp — a genuine wall-clock read
* (production wires this to `() => Date.now()`, called at the exact
Expand Down
59 changes: 59 additions & 0 deletions src/core/workspace-key.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Pure workspace URL-key rules (#406). Persisted keys are strict canonical
// ASCII. Name derivation is deliberately lossy and deterministic; user-entered
// keys use validateWorkspaceKey and are never silently rewritten.

export const WORKSPACE_KEY_PATTERN = /^[a-z0-9][a-z0-9_-]*$/;

export type WorkspaceKeyValidation =
| { ok: true; key: string }
| { ok: false; reason: 'required' | 'invalid' };

/** Normalize a lookup without turning an invalid key into a valid one. */
export function normalizeWorkspaceKeyLookup(value: string): string {
return value.toLowerCase();
}

/** Strictly validate a user-entered or persisted canonical workspace key. */
export function validateWorkspaceKey(value: unknown): WorkspaceKeyValidation {
if (typeof value !== 'string' || value.length === 0) {
return { ok: false, reason: 'required' };
}
if (!WORKSPACE_KEY_PATTERN.test(value)) {
return { ok: false, reason: 'invalid' };
}
return { ok: true, key: value };
}

export function isValidWorkspaceKey(value: unknown): value is string {
return validateWorkspaceKey(value).ok;
}

function asciiLower(value: string): string {
return value.replace(/[A-Z]/g, (letter) => letter.toLowerCase());
}

function derivedBase(name: unknown): string {
if (typeof name !== 'string') return 'workspace';
const key = asciiLower(name)
.replace(/[^a-z0-9_-]+/g, '_')
.replace(/^[_-]+|[_-]+$/g, '');
return key || 'workspace';
}

/**
* Derive an available canonical key from a display name.
*
* Existing keys are compared case-insensitively. A collision keeps the base
* for the first workspace and appends `_2`, `_3`, ... for later workspaces.
*/
export function deriveWorkspaceKey(
name: unknown,
existingKeys: Iterable<string> = [],
): string {
const base = derivedBase(name);
const occupied = new Set(Array.from(existingKeys, normalizeWorkspaceKeyLookup));
if (!occupied.has(base)) return base;
let suffix = 2;
while (occupied.has(`${base}_${suffix}`)) suffix += 1;
return `${base}_${suffix}`;
}
Loading
Loading