Github|...

Solid 2 guide

Use Sp00ky from a Solid 2.0 app: createQuery, <Loading> suspension, createSubmission, and the migration table from client-solid.

@spooky-sync/client-solid2 is the Solid 2.0 native binding. It is the counterpart of @spooky-sync/client-solid, which stays on Solid 1.x — the two packages coexist until Solid 2.0 is stable, and you pick one per app.

Tracks a Solid release candidate

Solid 2.0 is still an RC. This package pins solid-js@^2.0.0-rc.0 and @solidjs/signals@^2.0.0-rc.0 as peers, and its semantics probes (src/lib/__tests__/rc-semantics.test.ts) exist to catch RC drift. On Solid 1.x apps use the SolidJS guide instead.

What “native” means here

  • Query results are a createProjection fed by an async generator over the engine’s live subscription: keyed reconcile by id, row identity preserved across updates, <For> notified on add / remove / reorder. No manual reconcile or version-signal plumbing.
  • createQuery exposes both worlds: non-suspending accessors (data, isLoading, isFetching, isAuthoritative, hasData, isEmpty, isSettled, error) and a suspending ready() for <Loading> boundaries. Queries are born committed, so a local-first cache paint never suspends.
  • Status hooks are async-iterable-backed memos with a loadingValue.
  • Mutations stay plain async calls — the engine is already optimistic local-first end to end (local commit → live re-emit → outbox sync). createSubmission only adds button pending/error state.
  1. Install
    pnpm add @spooky-sync/core @spooky-sync/client-solid2 \
      solid-js@2.0.0-rc.0 @solidjs/web@2.0.0-rc.0 @solidjs/signals@2.0.0-rc.0
    Coordinated RC

    solid-js, @solidjs/web and @solidjs/signals must sit on matching 2.0.0-rc.x versions. Pin them exactly. In Solid 2 the JSX import source is @solidjs/web.

    tsconfig.json
    {
      "compilerOptions": {
        "jsx": "preserve",
        "jsxImportSource": "@solidjs/web"
      }
    }
  2. Generate types
    spky generate

    Same generator as every other client — see Generated types.

  3. Configure the client
    src/db.ts
    import type { SyncedDbConfig } from '@spooky-sync/client-solid2';
    import { schema, SURQL_SCHEMA } from './schema.gen';
    
    export const dbConfig: SyncedDbConfig<typeof schema> = {
      logLevel: 'info',
      schema,
      schemaSurql: SURQL_SCHEMA,
      database: {
        namespace: 'main',
        database: 'main',
        // spky dev exposes SurrealDB on 8666 by default.
        endpoint: 'ws://localhost:8666/rpc',
        store: 'indexeddb',
      },
    };

    Every key is documented in Client config.

  4. Mount the provider
    src/App.tsx
    import { Sp00kyProvider } from '@spooky-sync/client-solid2';
    import { dbConfig } from './db';
    import { MainContent } from './MainContent';
    
    export default function App() {
      return (
        <Sp00kyProvider
          config={dbConfig}
          fallback={<div>Initializing database…</div>}
          preload={async (db) => {
            // Optional: warm the local cache before the UI is revealed.
            await db.preload(db.query('config').build());
          }}
          onError={(err) => console.error('Failed to initialize database:', err)}
        >
          <MainContent />
        </Sp00kyProvider>
      );
    }

    Props are identical to client-solid’s provider: config, fallback, preload (awaited gate before children render), onReady, onError. A mounted client is deliberately not closed on unmount.

Queries

createQuery replaces useQuery (kept as a deprecated alias). Same overloads: (query, options?) with a provider in context, or (db, query, options?).

src/components/PostList.tsx
import { For, Show } from 'solid-js';
import { createQuery, useDb } from '@spooky-sync/client-solid2';
import type { schema } from '../schema.gen';

export function PostList() {
  const db = useDb<typeof schema>();

  const posts = createQuery(
    db.query('post').orderBy('created_at', 'desc').limit(20).build()
  );

  return (
    <Show when={!posts.isLoading()} fallback={<div>Loading…</div>}>
      <ul>
        <For each={posts.data()}>
          {(post) => <li>{post.title}</li>}
        </For>
      </ul>
    </Show>
  );
}

Pass a thunk when the query depends on signals — read them inside it:

const post = createQuery(() =>
  postId() ? db.query('post').where({ id: postId() }).one().build() : null
);

Suspension style

ready() reads the same data but participates in Solid 2’s boundary protocol: it pends until the first real result lands. <Loading> is Solid 2’s renamed <Suspense>.

import { Loading } from 'solid-js';

<Loading fallback={<Skeleton />}>
  <For each={posts.ready()}>{(post) => <PostRow post={post} />}</For>
</Loading>

Result surface

AccessorMeaning
data()Rows, or the row / null for .one(). Never suspends, never throws. Keyed-reconciled in place.
ready()Same data, suspends into the nearest <Loading> until the first result or an error.
error()Registration or sync error (e.g. SSP 503 during bootstrap). Never thrown into the render tree; the sync scheduler retries underneath.
isLoading()Cold first load only: nothing has painted for this identity, the server has not answered, and registration has not failed. A paint from the local cache ends it without the server; the server answering with zero rows ends it without rows. Re-arms only when the query identity changes.
isFetching()The sync engine is pulling records for this query (registration, a sync round). Re-enters on later rounds.
isAuthoritative()Server membership is known for this identity: a registration or poll landed, or a previous session’s membership was read on boot. From here data() is the server’s answer, not a cache guess. Latched until the identity changes.
hasData()data() holds something: rows for a list, a row for .one().
isEmpty()Authoritative and no data: the server said “no rows”. Render empty states on this, never on a bare !hasData().
isSettled()Authoritative and idle — the gate a windowed list needs before trusting a short result as the end of the list. Not monotonic (a later sync round re-enters isFetching); latch it when a verdict must stick.

A query that painted cached rows is therefore not loading while the server is still catching up, and a list that is merely waiting for the server is not empty. Gates built on isLoading() / isEmpty() stop guessing with timeouts.

Options: { enabled?: () => boolean, deregisterOnCleanup?: boolean }, as in client-solid.

Rows are store proxies

Solid 2 stores wrap class instances too and serve their methods bound. A RecordId read out of a row renders fine and db.delete('post', row.id) works, but if you hand a whole row back to a surrealdb API that checks instanceof, unwrap it first with snapshot(row) from solid-js.

Mutations

Engine writes are already optimistic and local-first, so mutations are plain calls:

await db.create('post:' + crypto.randomUUID(), { title: 'Hi' });
await db.update('post', 'post:xyz', { title: 'Edited' });
await db.delete('post', row.id);
await db.run('backend', 'sendMail', { to });

For button pending/error state, wrap the call in createSubmission:

import { createSubmission } from '@spooky-sync/client-solid2';

const save = createSubmission((title: string) =>
  db.create('post:' + crypto.randomUUID(), { title })
);

<button disabled={save.pending()} onClick={() => save.submit(title())}>
  Save
</button>
<Show when={save.error()}>{(err) => <p>{err().message}</p>}</Show>

To track a backend job, query its outbox row: db.run() writes one with status: 'pending'.

const job = createQuery(() =>
  db.query('job_outbox').where({ id: jobId() }).one().build()
);
// job.data()?.status → 'pending' | 'running' | 'done' | 'failed'

Status hooks

Same shapes as client-solid, rebuilt on async-iterable-backed memos: useDb, usePendingMutations, useSyncStatus, useSyncActivity, useStorageStatus, useFeatureFlag, useAppRelease, useCrdtField, useFileUpload / useDownloadFile, and createPreload.

import { useSyncStatus, usePendingMutations } from '@spooky-sync/client-solid2';

const sync = useSyncStatus();
const pending = usePendingMutations();

<Show when={sync().isDegraded}>
  <Banner>Can't reach the server — {pending()} change(s) queued.</Banner>
</Show>

Migrating from client-solid

client-solid (Solid 1)client-solid2 (Solid 2)
useQuery(q)createQuery(q) (useQuery kept as an alias)
data() may be undefined before the first fetchdata() is [] / null before the first fetch
No suspensionready() + <Loading>
<Suspense><Loading>
jsxImportSource: "solid-js"jsxImportSource: "@solidjs/web"
everything elseunchanged call signatures

Testing

Under Node, solid-js resolves its node export condition to the SSR build, where user effects never run. Point Vitest at the browser build:

vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  resolve: {
    // Without this, solid-js resolves to the SSR build under Node
    // and user effects never run.
    conditions: ['browser', 'development'],
  },
});

Next