rastack 0.0.63 → 0.0.64

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4
4
 
5
+ ### [0.0.64](https://github.com/theserverkid/reactapistack/compare/v0.0.63...v0.0.64) (2026-07-20)
6
+
7
+
8
+ ### Features
9
+
10
+ * **define,provider:** nullable FK relations + bundled-warehouse seed ([276f8c6](https://github.com/theserverkid/reactapistack/commit/276f8c6d6555a6dbcb8965926009f0ddd076ad96))
11
+
5
12
  ### [0.0.63](https://github.com/theserverkid/reactapistack/compare/v0.0.62...v0.0.63) (2026-07-20)
6
13
 
7
14
 
@@ -10,7 +10,7 @@
10
10
  * Layout: models on the left (`<Sidebar>`), the changelist table in the main
11
11
  * area, a Linear-style filter bar on top (`<FilterBar>`), and add/change forms.
12
12
  */
13
- import React, { useEffect, useMemo, useState } from "react";
13
+ import React, { useCallback, useEffect, useMemo, useState } from "react";
14
14
  import {
15
15
  AutoForm,
16
16
  Button,
@@ -28,9 +28,11 @@ import {
28
28
  mergeDiscovered,
29
29
  } from "./adapters";
30
30
  import {
31
+ RefreshContext,
31
32
  useDashboardStats,
32
33
  useDeleteRecord,
33
34
  useFilterModel,
35
+ useRefresh,
34
36
  useResourceForm,
35
37
  useResourceTable,
36
38
  } from "./useAdmin";
@@ -56,6 +58,7 @@ import {
56
58
  IconLayers,
57
59
  IconLink,
58
60
  IconPlus,
61
+ IconRefresh,
59
62
  IconTable,
60
63
  IconTrash,
61
64
  } from "./ui/icons";
@@ -100,7 +103,43 @@ interface Crumb {
100
103
  hash?: string;
101
104
  }
102
105
 
106
+ /** Compact relative age, e.g. "just now", "3m ago", "2h ago". */
107
+ function formatAgo(ts: number, now: number): string {
108
+ if (!ts) return "—";
109
+ const secs = Math.max(0, Math.round((now - ts) / 1000));
110
+ if (secs < 5) return "just now";
111
+ if (secs < 60) return `${secs}s ago`;
112
+ const mins = Math.round(secs / 60);
113
+ if (mins < 60) return `${mins}m ago`;
114
+ const hrs = Math.round(mins / 60);
115
+ if (hrs < 24) return `${hrs}h ago`;
116
+ return `${Math.round(hrs / 24)}d ago`;
117
+ }
118
+
119
+ /** The "Updated …" label — ticks over time and snaps fresh when data lands. */
120
+ function LastUpdated(): React.JSX.Element {
121
+ const { lastUpdated } = useRefresh();
122
+ const [now, setNow] = useState(() => Date.now());
123
+ useEffect(() => {
124
+ const id = window.setInterval(() => setNow(Date.now()), 30_000);
125
+ return () => window.clearInterval(id);
126
+ }, []);
127
+ useEffect(() => setNow(Date.now()), [lastUpdated]);
128
+ return (
129
+ <span className="ras-updated" style={{ marginLeft: "auto" }}>
130
+ Updated {formatAgo(lastUpdated, now)}
131
+ </span>
132
+ );
133
+ }
134
+
103
135
  function Topbar({ crumbs }: { crumbs: Crumb[] }): React.JSX.Element {
136
+ const { refresh } = useRefresh();
137
+ const [spinning, setSpinning] = useState(false);
138
+ const onReload = () => {
139
+ setSpinning(true);
140
+ refresh();
141
+ window.setTimeout(() => setSpinning(false), 500);
142
+ };
104
143
  return (
105
144
  <header className="ras-topbar">
106
145
  <nav className="ras-crumbs">
@@ -115,6 +154,16 @@ function Topbar({ crumbs }: { crumbs: Crumb[] }): React.JSX.Element {
115
154
  </React.Fragment>
116
155
  ))}
117
156
  </nav>
157
+ <LastUpdated />
158
+ <button
159
+ type="button"
160
+ className="ras-icon-btn"
161
+ onClick={onReload}
162
+ title="Reload data"
163
+ aria-label="Reload data"
164
+ >
165
+ <IconRefresh size={16} className={spinning ? "ras-spin" : undefined} />
166
+ </button>
118
167
  </header>
119
168
  );
120
169
  }
@@ -695,6 +744,10 @@ export function AdminApp(props: {
695
744
  const { engine } = props;
696
745
  const resources = useDiscoveredResources(engine, props.resources);
697
746
  const [collapsed, toggleCollapsed] = useSidebarCollapsed();
747
+ const [nonce, setNonce] = useState(0);
748
+ const [lastUpdated, setLastUpdated] = useState(0);
749
+ const refresh = useCallback(() => setNonce((n) => n + 1), []);
750
+ const markUpdated = useCallback(() => setLastUpdated(Date.now()), []);
698
751
 
699
752
  const activeKey =
700
753
  route.name === "index" ? undefined : `${route.app}.${route.model}`;
@@ -737,7 +790,7 @@ export function AdminApp(props: {
737
790
  }
738
791
 
739
792
  return (
740
- <>
793
+ <RefreshContext.Provider value={{ nonce, refresh, lastUpdated, markUpdated }}>
741
794
  <AdminStyles />
742
795
  <div className={"ras-root" + (collapsed ? " is-collapsed" : "")}>
743
796
  <Sidebar
@@ -748,6 +801,6 @@ export function AdminApp(props: {
748
801
  />
749
802
  <main className="ras-main">{screen}</main>
750
803
  </div>
751
- </>
804
+ </RefreshContext.Provider>
752
805
  );
753
806
  }
package/admin/index.tsx CHANGED
@@ -132,8 +132,14 @@ function Boot(): React.JSX.Element {
132
132
  openapi={openapi}
133
133
  loadEngine={loadEngine}
134
134
  warehouseBaseUrl={cfg.warehouseBaseUrl ?? "/data/warehouse/"}
135
- persist={false}
136
- cache={false}
135
+ // In local mode the WASM warehouse *is* the database — there is no server
136
+ // behind it. So edits (creates/updates/deletes) MUST persist to IndexedDB;
137
+ // otherwise every reload re-seeds from the committed `/data/warehouse/`
138
+ // and silently resurrects deleted rows. The Iceberg file cache (`cache`,
139
+ // on by default) still makes those reloads fast — it caches the immutable
140
+ // Parquet used for the *first* seed; the persisted warehouse blob, once it
141
+ // exists, wins over re-seeding and carries the user's edits forward.
142
+ persist={true}
137
143
  >
138
144
  <ThemeProvider tokens={theme}>
139
145
  <LocalAdmin resources={resources} />
@@ -152,6 +152,15 @@ export const IconArrowUpRight = (p: SvgProps) => (
152
152
  </Svg>
153
153
  );
154
154
 
155
+ export const IconRefresh = (p: SvgProps) => (
156
+ <Svg {...p}>
157
+ <path d="M3 12a9 9 0 0 1 15-6.7L21 8" />
158
+ <path d="M21 3v5h-5" />
159
+ <path d="M21 12a9 9 0 0 1-15 6.7L3 16" />
160
+ <path d="M3 21v-5h5" />
161
+ </Svg>
162
+ );
163
+
155
164
  export const IconPanelLeft = (p: SvgProps) => (
156
165
  <Svg {...p}>
157
166
  <rect x="3.5" y="4.5" width="17" height="15" rx="2" />
@@ -192,6 +192,8 @@ a { color: inherit; text-decoration: none; }
192
192
  transition: background 0.14s, color 0.14s, border-color 0.14s;
193
193
  }
194
194
  .ras-icon-btn:hover { color: var(--color-text); border-color: color-mix(in srgb, var(--color-text) 22%, transparent); }
195
+ @keyframes ras-spin { to { transform: rotate(360deg); } }
196
+ .ras-spin { animation: ras-spin 0.5s linear; }
195
197
 
196
198
  /* ---- top bar ------------------------------------------------------------- */
197
199
  .ras-topbar {
@@ -212,6 +214,7 @@ a { color: inherit; text-decoration: none; }
212
214
  .ras-crumbs a:hover { color: var(--color-text); }
213
215
  .ras-crumb-sep { color: color-mix(in srgb, var(--color-text-muted) 55%, transparent); }
214
216
  .ras-crumb-current { color: var(--color-text); font-weight: 600; }
217
+ .ras-updated { font-size: 12.5px; color: var(--color-text-muted); white-space: nowrap; }
215
218
 
216
219
  .ras-content { padding: 26px 28px 64px; max-width: 1240px; width: 100%; }
217
220
 
package/admin/useAdmin.ts CHANGED
@@ -7,7 +7,14 @@
7
7
  * one admin serves any app.
8
8
  */
9
9
 
10
- import { useCallback, useEffect, useMemo, useState } from "react";
10
+ import {
11
+ createContext,
12
+ useCallback,
13
+ useContext,
14
+ useEffect,
15
+ useMemo,
16
+ useState,
17
+ } from "react";
11
18
  import type { RastackEngine } from "../provider/types";
12
19
  import type { AutoFormField, AutoFormLike, TableQuery } from "../components/types";
13
20
  import type {
@@ -45,6 +52,30 @@ interface EngineResult {
45
52
  }
46
53
  const ok = (r: EngineResult) => r.status >= 200 && r.status < 300;
47
54
 
55
+ /**
56
+ * A global "reload" signal for the admin. The engine holds the live data (the
57
+ * WASM warehouse under `rastack dev`, the real `rastack-server` under
58
+ * `rastack admin`), so a manual reload just re-runs the read effects against it.
59
+ * `nonce` bumps on every {@link RefreshApi.refresh}; the data hooks include it in
60
+ * their fetch deps so they refetch **without** resetting filters/paging. The
61
+ * default value keeps the hooks usable with no provider (tests, embedding).
62
+ */
63
+ export interface RefreshApi {
64
+ nonce: number;
65
+ refresh: () => void;
66
+ /** Epoch ms of the most recent successful load (0 before the first). */
67
+ lastUpdated: number;
68
+ /** Called by the data hooks when a fetch resolves, to stamp {@link lastUpdated}. */
69
+ markUpdated: () => void;
70
+ }
71
+ export const RefreshContext = createContext<RefreshApi>({
72
+ nonce: 0,
73
+ refresh: () => {},
74
+ lastUpdated: 0,
75
+ markUpdated: () => {},
76
+ });
77
+ export const useRefresh = (): RefreshApi => useContext(RefreshContext);
78
+
48
79
  /** What `useResourceTable` returns: a `TableQuery` plus changelist controls. */
49
80
  export interface ResourceTable {
50
81
  query: TableQuery;
@@ -75,6 +106,7 @@ export function useResourceTable(
75
106
  const [isLoading, setLoading] = useState(true);
76
107
  const [error, setError] = useState<unknown>(undefined);
77
108
  const [tick, setTick] = useState(0);
109
+ const { nonce, markUpdated } = useRefresh();
78
110
 
79
111
  // Reset paging/search/filters when the resource changes.
80
112
  useEffect(() => {
@@ -111,6 +143,7 @@ export function useResourceTable(
111
143
  setData([]);
112
144
  }
113
145
  setLoading(false);
146
+ markUpdated();
114
147
  })
115
148
  .catch((e: unknown) => {
116
149
  if (cancelled) return;
@@ -121,7 +154,7 @@ export function useResourceTable(
121
154
  return () => {
122
155
  cancelled = true;
123
156
  };
124
- }, [engine, resource.key, resource.app, resource.model, page, search, filterKey, tick]);
157
+ }, [engine, resource.key, resource.app, resource.model, page, search, filterKey, tick, nonce]);
125
158
 
126
159
  const setSearch = (v: string | undefined) => {
127
160
  setSearchState(v && v.length ? v : undefined);
@@ -447,6 +480,7 @@ export function useDashboardStats(
447
480
  ): DashboardStats {
448
481
  const models = useMemo(() => adminResources(resources), [resources]);
449
482
  const [counts, setCounts] = useState<Record<string, number> | null>(null);
483
+ const { nonce, markUpdated } = useRefresh();
450
484
 
451
485
  useEffect(() => {
452
486
  let cancelled = false;
@@ -467,12 +501,15 @@ export function useDashboardStats(
467
501
  }
468
502
  }),
469
503
  );
470
- if (!cancelled) setCounts(Object.fromEntries(entries));
504
+ if (!cancelled) {
505
+ setCounts(Object.fromEntries(entries));
506
+ markUpdated();
507
+ }
471
508
  })();
472
509
  return () => {
473
510
  cancelled = true;
474
511
  };
475
- }, [engine, models]);
512
+ }, [engine, models, nonce]);
476
513
 
477
514
  return useMemo((): DashboardStats => {
478
515
  const loading = counts === null;