firstly 0.4.5 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,18 +1,83 @@
1
- import { type ClassType, type FindOptions, type GroupByOptions, type GroupByResult, type MembersOnly, type NumericKeys, type QueryOptions, type Repository } from 'remult';
2
- type EmptyAggregateResult = {
3
- $count: number;
1
+ import { type ClassType, type EntityFilter, type EntityMetadata, type EntityOrderBy, type GroupByOptions, type GroupByResult, type MembersOnly, type MembersToInclude, type NumericKeys, type QueryOptions, type Repository } from 'remult';
2
+ /**
3
+ * `ffRepo` - thin reactive wrapper around a Remult `repo`, exposing its results as
4
+ * Svelte runes. Pick the mode with a verb:
5
+ *
6
+ * ffRepo(E).load(() => ({ where })) // load - one-shot list + refresh()
7
+ * ffRepo(E).listen(() => ({ where })) // live - liveQuery, auto-updates
8
+ * ffRepo(E).paginate(() => ({ where })) // paginate - more() / hasNextPage / aggregates
9
+ * ffRepo(E).one(() => ({ where })) // one - reactive single record in `item`
10
+ *
11
+ * Two surfaces, one rule: anything NOT under `.repo` is reactive (a verb returns a
12
+ * runes handle; that handle's writes sync its own state). Anything under `.repo` is
13
+ * the plain remult repo - imperative, returns Promises, touches no runes state.
14
+ *
15
+ * The options getter is reactive: change `where` (e.g. a search box), `orderBy`,
16
+ * `enabled`, etc. and the query re-runs - `listen` re-subscribes (the old
17
+ * subscription is torn down), `load`/`paginate`/`one` re-fetch and ignore any
18
+ * stale in-flight response. `orderBy` defaults to the entity's `defaultOrderBy`.
19
+ *
20
+ * Getter hygiene: read SvelteKit load `data` through a `$derived`
21
+ * (`const did = $derived(data.targetDid)`), never raw inside the getter. A derived
22
+ * only propagates on value change, so a parent layout load revalidating - e.g. an
23
+ * SP URL write re-running url-dependent loads on every filter - hands you a new
24
+ * `data` object but does NOT re-fetch unless the value actually changed; a real
25
+ * change (route param switch) still does. Reading `data.x` raw re-fetches on every
26
+ * revalidation (a duplicate request per filter).
27
+ *
28
+ * `enabled: false` skips the query entirely (keeps the last result) and runs it
29
+ * the moment it flips true - use it for search-min-length, tab visibility,
30
+ * dependent queries, or a manual button trigger.
31
+ *
32
+ * Writes: only the record handle (`one` / `create()`) writes - argless `save()` / `delete()`
33
+ * act on its `item` and re-sync it. List handles (`load` / `listen` / `paginate`) don't write;
34
+ * go through `.repo` (the plain remult repo: `insert` / `update` / `save` / `delete` /
35
+ * `deleteMany`), then reflect it in a `load` / `paginate` list with the local reconcilers
36
+ * (`addItem` / `updateItem` / `removeItem`) - a `listen` list re-syncs itself via the liveQuery.
37
+ * A failed write fills `error` and re-throws.
38
+ *
39
+ * The factory's return type is mode-specific, so e.g. `.more()` doesn't exist on
40
+ * a `listen()` handle. Methods also throw if reached via a cast in the wrong mode.
41
+ *
42
+ * Reactive vs imperative: the reactive verbs take a *getter* (`() => ({ ... })`) and
43
+ * return a runes handle; they build an `$effect`, so they must be created during
44
+ * component init. For a one-off read/write in a click handler / async fn (no runes
45
+ * context) go through `.repo` (plain remult): `ffRepo(E).repo.findFirst(where)`,
46
+ * `ffRepo(E).repo.findId(id)`, `ffRepo(E).repo.find(...)`.
47
+ *
48
+ * Tip: prefer `.paginate()` whenever you want a total - it returns `aggregates.$count`
49
+ * for free in the same request. `load`/`listen`/`one` don't count; for a one-off count
50
+ * use `ffRepo(E).repo.count(where)`.
51
+ */
52
+ /** The aggregate request shape - remult's `GroupByOptions` minus the grouping/paging keys. */
53
+ export type AggregateOptions<Entity> = Omit<GroupByOptions<Entity, never, NumericKeys<Entity>[], NumericKeys<Entity>[], (keyof MembersOnly<Entity>)[], (keyof MembersOnly<Entity>)[], (keyof MembersOnly<Entity>)[]>, 'group' | 'orderBy' | 'where' | 'limit' | 'page'>;
54
+ /**
55
+ * Remult `QueryOptions` plus the `aggregate` request shape. Exported for callers
56
+ * that build query options outside `ffRepo` (e.g. a generic table component).
57
+ */
58
+ export type QueryOptionsHelper<Entity> = QueryOptions<Entity> & {
59
+ aggregate?: AggregateOptions<Entity>;
4
60
  };
5
- type Loading = {
6
- init: boolean;
7
- fetching: boolean;
8
- more: boolean;
9
- saving: boolean;
10
- deleting: boolean;
61
+ export type FF_RepoOptions<Entity> = {
62
+ where?: EntityFilter<Entity>;
63
+ orderBy?: EntityOrderBy<Entity>;
64
+ /** paginate: rows per page (default 25). */
65
+ pageSize?: number;
66
+ /** find/one/live: cap the rows returned. No default - returns every matching row. */
67
+ limit?: number;
68
+ include?: MembersToInclude<Entity>;
69
+ /** When false, the query is skipped (last result kept) until it flips true. */
70
+ enabled?: boolean;
71
+ /** Aggregations to compute alongside the page (paginate mode only). `$count` is always returned. */
72
+ aggregate?: AggregateOptions<Entity>;
11
73
  };
12
- type QueryOptionsHelper<entityType> = QueryOptions<entityType> & {
13
- aggregate?: Omit<GroupByOptions<entityType, never, NumericKeys<entityType>[], NumericKeys<entityType>[], (keyof MembersOnly<entityType>)[], (keyof MembersOnly<entityType>)[], (keyof MembersOnly<entityType>)[]>, 'group' | 'orderBy' | 'where' | 'limit' | 'page'>;
74
+ type Getter<Entity, O extends FF_RepoOptions<Entity> = FF_RepoOptions<Entity>> = () => O;
75
+ /** `$count` is always present; richer keys appear only for the requested `aggregate`. */
76
+ type EmptyAggregateResult = {
77
+ $count: number;
14
78
  };
15
- type ExtractAggregateResult<Entity, Options extends QueryOptionsHelper<Entity>> = Options extends {
79
+ /** The typed aggregate result for a given options object (paginate mode). */
80
+ type ExtractAggregateResult<Entity, O extends FF_RepoOptions<Entity>> = O extends {
16
81
  aggregate: infer A;
17
82
  } ? GroupByResult<Entity, never, A extends {
18
83
  sum?: infer S;
@@ -20,57 +85,114 @@ type ExtractAggregateResult<Entity, Options extends QueryOptionsHelper<Entity>>
20
85
  avg?: infer V;
21
86
  } ? (V extends NumericKeys<Entity>[] ? V : never) : never, A extends {
22
87
  min?: infer M;
23
- } ? (M extends (keyof MembersOnly<Entity>)[] ? M : never) : never, A extends {
88
+ } ? (M extends NumericKeys<Entity>[] ? M : never) : never, A extends {
24
89
  max?: infer X;
25
- } ? (X extends (keyof MembersOnly<Entity>)[] ? X : never) : never, A extends {
90
+ } ? (X extends NumericKeys<Entity>[] ? X : never) : never, A extends {
26
91
  distinctCount?: infer D;
27
92
  } ? D extends (keyof MembersOnly<Entity>)[] ? D : never : never> : EmptyAggregateResult;
28
- export declare class FF_Repo<Entity, QueryOptions extends QueryOptionsHelper<Entity> = QueryOptionsHelper<Entity>> {
93
+ export type FF_RepoLoading = {
94
+ init: boolean;
95
+ fetching: boolean;
96
+ more: boolean;
97
+ saving: boolean;
98
+ deleting: boolean;
99
+ };
100
+ type Mode = 'live' | 'load' | 'paginate' | 'one';
101
+ /**
102
+ * The reactive handle implementation. Not exported directly - consumers use a per-mode
103
+ * alias (`FF_RepoLoad`/`FF_RepoLive`/`FF_RepoPaginate`/`FF_RepoOne`) or the umbrella
104
+ * union `FF_Repo` (any mode). Each verb returns the Omit'd per-mode view of this.
105
+ */
106
+ declare class FF_RepoHandle<Entity, O extends FF_RepoOptions<Entity> = FF_RepoOptions<Entity>> {
29
107
  #private;
30
- ent: ClassType<Entity>;
31
- fields: Repository<Entity>['fields'];
32
- metadata: Repository<Entity>['metadata'];
33
- loading: Loading;
34
- items: Entity[] | undefined;
35
- aggregates: ExtractAggregateResult<Entity, QueryOptions> | undefined;
36
- hasNextPage: boolean | undefined;
108
+ items: Entity[];
109
+ /** Single-record slot: the loaded row in `one` mode, or a create/edit draft (see `create`). */
37
110
  item: Entity | undefined;
38
- globalError: string | undefined;
39
- private loadingEnd;
40
- constructor(ent: ClassType<Entity>, o?: ({
41
- findOptions?: FindOptions<Entity> & {
42
- skipAutoFetch?: boolean;
43
- };
44
- queryOptions?: never;
45
- } | {
46
- findOptions?: never;
47
- queryOptions?: QueryOptions & {
48
- skipAutoFetch?: boolean;
49
- };
50
- }) & {
51
- item?: Entity;
52
- });
53
- find(options: FindOptions<Entity>): Promise<Entity[] | undefined>;
54
- query(options: Pick<QueryOptionsHelper<Entity>, 'where' | 'orderBy'>): Promise<{
55
- items: Entity[];
56
- aggregates: ExtractAggregateResult<Entity, QueryOptions>;
57
- hasNextPage: boolean;
58
- } | undefined>;
59
- queryMore(): Promise<{
60
- items: Entity[] | undefined;
61
- aggregates: ExtractAggregateResult<Entity, QueryOptions>;
62
- hasNextPage: boolean;
63
- } | undefined>;
111
+ loading: FF_RepoLoading;
112
+ error: string | undefined;
113
+ hasNextPage: boolean;
114
+ /** Aggregations for the whole query (paginate mode). `aggregates.$count` is the total row count. */
115
+ aggregates: ExtractAggregateResult<Entity, O> | undefined;
116
+ constructor(r: Repository<Entity>, opts: Getter<Entity, O>, mode: Mode);
117
+ /** Re-run the current query (load/paginate/one), back to the first page. */
118
+ refresh(): Promise<void>;
119
+ /** Load and append the next page (paginate mode). */
120
+ more(): Promise<void>;
64
121
  /**
65
- * Refresh query keeping current items count (BIG refresh)
66
- * Useful after edit/delete to stay at current scroll position
122
+ * Run `fn` once - the first time a row exists (`items[0]`).
123
+ *
124
+ * The point: seed editable UI state from the latest row WITHOUT a live query
125
+ * clobbering in-progress edits. It fires a single time, on the first non-empty
126
+ * result, and never again - later ticks (an edit, a delete, a re-sort) are
127
+ * ignored. Empty snapshots are skipped (a liveQuery often emits one before the
128
+ * data lands; there is nothing to seed from an empty result).
129
+ *
130
+ * For pure derived state prefer `$derived`; reach for `onFirst` only when the
131
+ * seed must become independently editable (a draft the user then mutates).
132
+ *
133
+ * ```svelte
134
+ * const list = ffRepo(Plan).listen(() => ({ where: { ownerDid } }))
135
+ * let draft = $state({ title: '' })
136
+ * list.onFirst((latest) => (draft.title = latest.title)) // seed once, then edit freely
137
+ * ```
67
138
  */
68
- queryRefresh(options: Pick<QueryOptionsHelper<Entity>, 'where' | 'orderBy'>): Promise<{
69
- items: Entity[];
70
- aggregates: ExtractAggregateResult<Entity, QueryOptions>;
71
- hasNextPage: boolean;
72
- } | undefined>;
139
+ onFirst(fn: (latest: Entity) => void): void;
140
+ /** Create a new unsaved entity into the `item` slot (for an edit form). */
73
141
  create(...args: Parameters<Repository<Entity>['create']>): Entity;
74
- delete(...args: Parameters<Repository<Entity>['delete']>): Promise<undefined>;
142
+ /** Save the current `item` (from `one` / `create()`). To save a specific row, use `.repo.save(row)`. */
143
+ save(): Promise<Entity>;
144
+ /** Delete the current `item`. To delete a specific row/id, use `.repo.delete(idOrRow)`. */
145
+ delete(): Promise<void>;
146
+ /** Insert into `items` at `top` (default) / `bottom` / an index (`-1` = last). +1 to `$count`. */
147
+ addItem(item: Entity, options?: {
148
+ at?: 'top' | 'bottom' | number;
149
+ }): void;
150
+ /** Replace the row whose id matches `item`'s id (no `$count` change). */
151
+ updateItem(item: Entity): void;
152
+ /** Drop the matching row (pass an id or the item). -1 to `$count`. */
153
+ removeItem(idOrItem: Parameters<Repository<Entity>['delete']>[0]): void;
154
+ /**
155
+ * The entity's remult metadata - the single escape hatch for everything not on
156
+ * this handle: permissions (`apiInsertAllowed()`, `apiUpdateAllowed(item)`,
157
+ * `apiDeleteAllowed(item)`, `apiReadAllowed`), `fields`, `idMetadata`, `options`,
158
+ * `key`. Reflects the current `remult.user`.
159
+ */
160
+ get meta(): EntityMetadata<Entity>;
161
+ /** Escape hatch to the underlying repo (count, findId, upsert, projections, ...). */
162
+ get repo(): Repository<Entity>;
75
163
  }
164
+ /** load: one-shot list (`refresh()` to re-run) - a read+reconcile view. No paging/aggregates, and no `item`/`save`/`delete`/`create` (edit via `one`, write via `.repo`). */
165
+ export type FF_RepoLoad<Entity, O extends FF_RepoOptions<Entity> = FF_RepoOptions<Entity>> = Omit<FF_RepoHandle<Entity, O>, 'more' | 'hasNextPage' | 'aggregates' | 'item' | 'save' | 'delete' | 'create'>;
166
+ /** live: reactive subscription, auto-updates - a read view. No refresh/paging/aggregates/reconcilers (the liveQuery does it), and no `item`/`save`/`delete`/`create`. */
167
+ export type FF_RepoLive<Entity, O extends FF_RepoOptions<Entity> = FF_RepoOptions<Entity>> = Omit<FF_RepoHandle<Entity, O>, 'refresh' | 'more' | 'hasNextPage' | 'aggregates' | 'addItem' | 'updateItem' | 'removeItem' | 'item' | 'save' | 'delete' | 'create'>;
168
+ /** paginate: `more()` / `hasNextPage` / `aggregates` - a read+reconcile view. No `onFirst` (paged ≠ latest), and no `item`/`save`/`delete`/`create`. */
169
+ export type FF_RepoPaginate<Entity, O extends FF_RepoOptions<Entity> = FF_RepoOptions<Entity>> = Omit<FF_RepoHandle<Entity, O>, 'onFirst' | 'item' | 'save' | 'delete' | 'create'>;
170
+ /** one: a single reactive record in `item`. No paging / aggregates / list reconcilers. */
171
+ export type FF_RepoOne<Entity, O extends FF_RepoOptions<Entity> = FF_RepoOptions<Entity>> = Omit<FF_RepoHandle<Entity, O>, 'more' | 'hasNextPage' | 'aggregates' | 'addItem' | 'updateItem' | 'removeItem'>;
172
+ /**
173
+ * Umbrella handle type - any mode. Use for a component prop that accepts a
174
+ * `load`/`listen`/`paginate`/`one` handle (`r: FF_Repo<T>`). It exposes the surface
175
+ * common to every mode (`items`/`loading`/`error`/`meta`/`repo`); mode-specific members
176
+ * (`item`/`save`/`delete`/`create` on `one`; `more`/`hasNextPage`/`aggregates`/`refresh`/
177
+ * `onFirst`/`addItem`/`updateItem`/`removeItem`) require the matching per-mode type.
178
+ */
179
+ export type FF_Repo<Entity, O extends FF_RepoOptions<Entity> = FF_RepoOptions<Entity>> = FF_RepoLoad<Entity, O> | FF_RepoLive<Entity, O> | FF_RepoPaginate<Entity, O> | FF_RepoOne<Entity, O>;
180
+ type StrictGetter<Entity, O extends FF_RepoOptions<Entity>> = () => O & Record<Exclude<keyof O, keyof FF_RepoOptions<Entity>>, never>;
181
+ /**
182
+ * The builder returned by `ffRepo(E)`. Two surfaces only: the reactive verbs
183
+ * (`load`/`listen`/`paginate`/`one`), and `.repo` - the plain remult repo for every
184
+ * imperative read/write (`findFirst`, `findId`, `find`, `insert`, `update`, `save`,
185
+ * `delete`, `count`, `upsert`, ...). `.meta` is a shortcut to `repo.metadata`.
186
+ */
187
+ export type FF_RepoBuilder<Entity> = {
188
+ load: <O extends FF_RepoOptions<Entity>>(opts: StrictGetter<Entity, O>) => FF_RepoLoad<Entity, O>;
189
+ listen: <O extends FF_RepoOptions<Entity>>(opts: StrictGetter<Entity, O>) => FF_RepoLive<Entity, O>;
190
+ paginate: <O extends FF_RepoOptions<Entity>>(opts: StrictGetter<Entity, O>) => FF_RepoPaginate<Entity, O>;
191
+ one: <O extends FF_RepoOptions<Entity>>(opts: StrictGetter<Entity, O>) => FF_RepoOne<Entity, O>;
192
+ /** The entity's remult metadata (permissions, fields, key). Shortcut to `repo.metadata`. */
193
+ readonly meta: EntityMetadata<Entity>;
194
+ /** The underlying remult repo - every imperative read/write lives here. */
195
+ readonly repo: Repository<Entity>;
196
+ };
197
+ export declare function ffRepo<Entity>(entity: ClassType<Entity>): FF_RepoBuilder<Entity>;
76
198
  export {};