Skip to main content

packages/client/src/types.ts

// Public types for `@musubi/client`.
//
// Consumers thread their generated `Musubi.Stores` type (or any
// store-map type) into the API exactly once via the `createMusubi<R>()`
// factory (from `@musubi/react`) or the `connect<R>()` generic (from
// `@musubi/client`). Every returned handle closes over `R`, so subsequent
// calls infer the store type from the `module` string literal without
// re-threading the registry generic.
//
// All helpers (`ShapeOf`, `CommandsOf`, `StoreSnapshot`, `StoreProxy`, …)
// take the module key first and require the registry type as the second
// generic parameter.

// ---------------------------------------------------------------------------
// Public runtime types
// ---------------------------------------------------------------------------

export type StoreId = string[]

export type AsyncError =
  | { kind: "error"; value: unknown }
  | { kind: "exit"; value: unknown }

export type AsyncResult<T> =
  | { status: "loading"; data: T | null; error: null }
  | { status: "ok"; data: T; error: null }
  | { status: "failed"; data: T | null; error: AsyncError | unknown }

// ---------------------------------------------------------------------------
// Registry-driven accessors
// ---------------------------------------------------------------------------

export type StoreModule<R> = Extract<keyof R, string>

export type DefOf<M extends StoreModule<R>, R> = R[M & keyof R]

type SymbolMarker<T> = T extends object
  ? NonNullable<T[Extract<keyof T, symbol>]>
  : never

type StoreDefMarker<T> = Extract<
  SymbolMarker<T>,
  { readonly module: string; readonly shape: unknown; readonly commands: unknown }
>

type FieldMarker<T> = Extract<
  SymbolMarker<T>,
  { readonly kind: "store" | "stream" | "async" | "upload" }
>

export type ShapeOf<M extends StoreModule<R>, R> =
  [StoreDefMarker<DefOf<M, R>>] extends [never]
    ? never
    : StoreDefMarker<DefOf<M, R>> extends { readonly shape: infer Shape }
      ? Shape
      : never

export type CommandsOf<M extends StoreModule<R>, R> =
  [StoreDefMarker<DefOf<M, R>>] extends [never]
    ? never
    : StoreDefMarker<DefOf<M, R>> extends { readonly commands: infer Commands }
      ? Commands
      : never

export type CommandName<M extends StoreModule<R>, R> = keyof CommandsOf<M, R>

export type CommandPayload<
  M extends StoreModule<R>,
  K extends CommandName<M, R>,
  R
> = CommandsOf<M, R>[K] extends { payload: infer Payload } ? Payload : never

export type CommandReply<
  M extends StoreModule<R>,
  K extends CommandName<M, R>,
  R
> = CommandsOf<M, R>[K] extends { reply: infer Reply } ? Reply : unknown

// ---------------------------------------------------------------------------
// Snapshot and proxy projection (symbol-branded generated marker matching)
// ---------------------------------------------------------------------------

type FieldMarkerOfKind<T, Kind extends "store" | "stream" | "async" | "upload"> = Extract<
  FieldMarker<T>,
  { readonly kind: Kind }
>

type IsStoreField<T> = [FieldMarkerOfKind<T, "store">] extends [never] ? false : true
type IsStreamField<T> = [FieldMarkerOfKind<T, "stream">] extends [never] ? false : true
type IsAsyncField<T> = [FieldMarkerOfKind<T, "async">] extends [never] ? false : true
type IsUploadField<T> = [FieldMarkerOfKind<T, "upload">] extends [never] ? false : true

type StoreFieldModule<T> =
  [FieldMarkerOfKind<T, "store">] extends [never]
    ? never
    : FieldMarkerOfKind<T, "store"> extends { readonly module: infer M }
      ? M
      : never
type StreamFieldItem<T> =
  [FieldMarkerOfKind<T, "stream">] extends [never]
    ? never
    : FieldMarkerOfKind<T, "stream"> extends { readonly item: infer Item }
      ? Item
      : never
type AsyncFieldValue<T> =
  [FieldMarkerOfKind<T, "async">] extends [never]
    ? never
    : FieldMarkerOfKind<T, "async"> extends { readonly value: infer Value }
      ? Value
      : never

type SnapshotAsyncValue<T, R> =
  IsStreamField<T> extends true
    ? SnapshotValue<StreamFieldItem<T>, R>[]
    : SnapshotValue<T, R>

export type SnapshotValue<T, R> =
  IsStoreField<T> extends true
    ? StoreFieldModule<T> extends infer M
      ? M extends StoreModule<R>
        ? StoreSnapshot<M, R>
        : never
      : never
    : IsUploadField<T> extends true
      ? UploadHandle
      : IsAsyncField<T> extends true
        ? AsyncResult<SnapshotAsyncValue<AsyncFieldValue<T>, R>>
        : IsStreamField<T> extends true
          ? SnapshotValue<StreamFieldItem<T>, R>[]
          : T extends readonly (infer U)[]
            ? SnapshotValue<U, R>[]
            : T extends object
              ? { [K in keyof T]: SnapshotValue<T[K], R> }
              : T

export type StoreSnapshot<M extends StoreModule<R>, R> = {
  readonly __musubi_store_id__: StoreId
} & {
  [K in keyof ShapeOf<M, R>]: SnapshotValue<ShapeOf<M, R>[K], R>
}

export type ProxyValue<T, R> =
  IsStoreField<T> extends true
    ? StoreFieldModule<T> extends infer M
      ? M extends StoreModule<R>
        ? StoreProxy<M, R>
        : never
      : never
    : IsUploadField<T> extends true
      ? UploadHandle
      : IsAsyncField<T> extends true
        ? AsyncResult<SnapshotAsyncValue<AsyncFieldValue<T>, R>>
        : IsStreamField<T> extends true
          ? SnapshotValue<StreamFieldItem<T>, R>[]
          : T extends readonly (infer U)[]
            ? ProxyValue<U, R>[]
            : T extends object
              ? { [K in keyof T]: ProxyValue<T[K], R> }
              : T

export interface StoreRuntime<M extends StoreModule<R>, R> {
  readonly __musubi_store_id__: StoreId
  dispatchCommand<K extends CommandName<M, R>>(
    name: K,
    payload: CommandPayload<M, K, R>
  ): Promise<CommandReply<M, K, R>>
  subscribe(listener: () => void): () => void
  snapshot(): StoreSnapshot<M, R>
}

export type StoreProxy<M extends StoreModule<R>, R> = StoreRuntime<M, R> & {
  [K in keyof ShapeOf<M, R>]: ProxyValue<ShapeOf<M, R>[K], R>
}

// ---------------------------------------------------------------------------
// Wire shapes
// ---------------------------------------------------------------------------

export type StreamEntry<T> = {
  itemKey: string
  item: T
}

export type WireStreamMarker = {
  __musubi_stream__: string
}

export type WireUploadMarker = {
  __musubi_upload__: string
}

export type UploadStatus =
  | "idle"
  | "selecting"
  | "uploading"
  | "success"
  | "error"
  | "cancelled"

export type EntryStatus =
  | "pending"
  | "uploading"
  | "success"
  | "error"
  | "cancelled"

export interface UploadError {
  code:
    | "too_large"
    | "too_many_files"
    | "not_accepted"
    | "chunk_timeout"
    | "external_failed"
    | "preflight_rejected"
    | (string & {})
  message: string
}

export interface UploadEntry {
  ref: string
  clientName: string
  clientSize: number
  clientType: string
  progress: number
  status: EntryStatus
  errors: UploadError[]
  readonly isPending: boolean
  readonly isUploading: boolean
  readonly isSuccess: boolean
  readonly isError: boolean
  readonly isCancelled: boolean
}

export interface UploadConfig {
  accept: string[] | "any"
  maxEntries: number
  maxFileSize: number
  chunkSize: number
}

export interface UploadHandle {
  readonly config: UploadConfig
  readonly status: UploadStatus
  readonly entries: readonly UploadEntry[]
  readonly errors: readonly UploadError[]
  readonly progress: number
  readonly isIdle: boolean
  readonly isSelecting: boolean
  readonly isUploading: boolean
  readonly isSuccess: boolean
  readonly isError: boolean
  select(files: FileList | File[]): Promise<readonly UploadEntry[]>
  start(): Promise<void>
  cancel(entryRef?: string): Promise<void>
  reset(): Promise<void>
}

export interface ExternalUploaderArgs {
  entry: UploadEntry
  file: File
  meta: unknown
  onProgress: (pct: number) => void
  signal: AbortSignal
}

export type ExternalUploader = (args: ExternalUploaderArgs) => Promise<void>

// Wire shapes for upload_ops
export type UploadOp =
  | {
      op: "config"
      upload: string
      store_id: StoreId
      config: {
        accept: string[] | "any"
        max_entries: number
        max_file_size: number
        chunk_size: number
      }
    }
  | {
      op: "add"
      upload: string
      store_id: StoreId
      ref: string
      entry: {
        ref: string
        client_name: string
        client_size: number
        client_type: string
        progress: number
        status: EntryStatus
        errors: UploadError[]
      }
    }
  | {
      op: "progress"
      upload: string
      store_id: StoreId
      ref: string
      progress: number
    }
  | { op: "complete"; upload: string; store_id: StoreId; ref: string }
  | {
      op: "error"
      upload: string
      store_id: StoreId
      ref?: string
      error: UploadError
    }
  | { op: "cancel"; upload: string; store_id: StoreId; ref: string }
  | { op: "reset"; upload: string; store_id: StoreId }

export type JsonPatchOp =
  | { op: "add"; path: string; value: unknown }
  | { op: "remove"; path: string }
  | { op: "replace"; path: string; value: unknown }

export type StreamOp =
  | { op: "reset"; stream: string; ref: string; store_id: StoreId }
  | {
      op: "insert"
      stream: string
      ref: string
      store_id: StoreId
      item_key: string
      at: number
      item: unknown
      limit: number | null
    }
  | {
      op: "delete"
      stream: string
      ref: string
      store_id: StoreId
      item_key: string
    }

export type PatchEnvelope = {
  type: "patch"
  base_version: number
  version: number
  ops: JsonPatchOp[]
  stream_ops: StreamOp[]
  upload_ops: UploadOp[]
}

export type ConnectionPatchEnvelope = PatchEnvelope & {
  root_id: string
}

export type WireAsyncError =
  | { kind: "error"; value: unknown }
  | { kind: "exit"; value: unknown }

export type WireAsyncResult<T = unknown> =
  | { __musubi_async__: true; status: "loading"; result: T | null; reason: null }
  | { __musubi_async__: true; status: "ok"; result: T; reason: null }
  | { __musubi_async__: true; status: "failed"; result: T | null; reason: WireAsyncError | unknown }

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

export const STORE_ID_KEY = "__musubi_store_id__" as const
export const STREAM_MARKER_KEY = "__musubi_stream__" as const
export const UPLOAD_MARKER_KEY = "__musubi_upload__" as const

const UPLOAD_KEY_SEP = "\0"

export function uploadStoreKey(storeId: StoreId, uploadName: string): string {
  return `${storeIdKey(storeId)}${UPLOAD_KEY_SEP}${uploadName}`
}

export function storeIdKey(storeId: StoreId): string {
  return JSON.stringify(storeId)
}

const STREAM_KEY_SEP = "\0"

export function streamStoreKey(storeId: StoreId, streamName: string): string {
  return `${storeIdKey(storeId)}${STREAM_KEY_SEP}${streamName}`
}

export function streamStoreKeyPrefix(storeId: StoreId): string {
  return `${storeIdKey(storeId)}${STREAM_KEY_SEP}`
}

export function streamFieldNameFromKey(key: string): string {
  const parts = key.split(STREAM_KEY_SEP)
  return parts[1] ?? ""
}

export function storeKeyFromStreamStoreKey(key: string): string {
  const parts = key.split(STREAM_KEY_SEP)
  return parts[0] ?? key
}