@enbox/create-dapp 0.0.3 → 0.0.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enbox/create-dapp",
3
- "version": "0.0.3",
3
+ "version": "0.0.5",
4
4
  "description": "Create a minimal Enbox React application",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -30,6 +30,11 @@ toolchain. Production builds precache the app shell for
30
30
  later offline visits; `npm run preview` serves that built app locally. Deploy
31
31
  the generated `dist` directory on an HTTPS origin with service worker support.
32
32
 
33
+ This starter explicitly activates worker updates immediately in `src/sw.ts`.
34
+ Apps with an update prompt, such as Notesd, approve activation after pending
35
+ edits are persisted. `prepareBrowser()` waits for the current worker; it does
36
+ not choose an application's update policy.
37
+
33
38
  You can also use `bun install` and `bun run dev`. No lockfile is generated
34
39
  until your first install. Commit the resulting `package-lock.json` or `bun.lock`;
35
40
  then use `npm ci` or `bun install --frozen-lockfile` in CI. Direct dependencies
@@ -47,24 +52,28 @@ The application follows the same feature-store structure as notesd:
47
52
  ```text
48
53
  src/
49
54
  app/
50
- application.ts # feature registration and wallet identity
51
- runtime.ts # one browser-lifetime application owner
55
+ application.ts # features, wallet identity, and one application owner
52
56
  App.tsx # connection shell and feature composition
53
57
  features/tasks/
54
58
  protocol.ts # typed encrypted records and validation
55
- store.ts # shared resources, commands, and domain defaults
59
+ store.ts # shared observations, commands, and domain defaults
56
60
  components/ # task board and its styles
57
- shared/components/
61
+ components/ # reusable UI shared by features
58
62
  ProfileCard.tsx
59
63
  main.tsx
60
64
  sw.ts
61
65
  ```
62
66
 
63
- `defineApplication` derives the protocol manifest from registered stores.
64
- `getApplication()` creates one headless runtime over a native connection.
67
+ `createApplication` derives the protocol manifest from registered stores and
68
+ constructs the browser connection with the configured wallet identity.
69
+ `getApplication()` keeps one headless runtime for the page lifetime.
65
70
  `main.tsx` waits for worker control before passing that runtime to
66
71
  `EnboxProvider application={...}`. The provider initializes the borrowed runtime;
67
- the application owns its lifetime. Development module replacement disposes it.
72
+ the application owns its page lifetime. Component Fast Refresh is useful for UI
73
+ edits, but replacing a connected runtime or its store/protocol definitions is
74
+ not a supported hot-reload operation. Disconnect before changing those files,
75
+ then reload the page. Apps with document writers, such as Notesd, drain edits
76
+ as part of disconnecting.
68
77
 
69
78
  The profile card reads published profile information through `useProfile`.
70
79
  Names, images, settlement state, and image URL lifetimes belong to the SDK.
@@ -77,13 +86,18 @@ The replaceable task feature lives in `src/features/tasks`:
77
86
  - `protocol.ts` declares encrypted `project` and `project/task` records with
78
87
  payload validators. Tasks store a title and completion state.
79
88
  - `store.ts` declares one shared project collection and a task collection scoped
80
- with `tasks.within(projectContextId)`. Creation and rename defaults live here.
81
- Native resource commands share pending/error state across consumers.
89
+ with `tasks.within(projectContextId)`. Its `activeTasks` observation keeps
90
+ the chosen project's query live through `activeTasks.select`; parent metadata and
91
+ rows arrive together. Fixed and selected observations use the same SDK
92
+ implementation. Creation and rename helpers reuse each collection's commands and their
93
+ shared pending/error state; observed state includes pagination progress.
82
94
  - `components/TaskBoard.tsx` resolves the feature with `useFeature(TaskBoardStore)`
83
- and reads resources or commands with `useStore`. Task details reuse loaded list
95
+ and reads observed data or commands with `useStore`. Task details reuse loaded list
84
96
  rows and acquire a bounded single-record observation for a selection outside
85
- the retained window. Input drafts and UI selection remain in React.
97
+ the retained window. Input drafts and task-detail selection remain in React.
86
98
  Awaited handlers check command authorization before applying their UI result.
99
+ Reopened task details also use selection identity to reject an older panel’s
100
+ completion.
87
101
  - `components/tasks.css` styles the task feature. Shared app and profile styles
88
102
  live in `src/style.css`.
89
103
 
@@ -102,6 +116,10 @@ try {
102
116
 
103
117
  Handles belong to their authorization session. Resolve a fresh feature when
104
118
  that session changes. React's `useFeature` handles that replacement automatically.
119
+ `activeTasks` owns its selected query until another project, null, or session
120
+ retirement. Other features can declare observations with changing inputs through
121
+ `observe({ source: (parent: string) => tasks.within(parent) })`;
122
+ commands remain on canonical `tasks.within(parentContextId)` handles.
105
123
 
106
124
  To replace the task feature, register your own store in `app/application.ts`,
107
125
  delete `src/features/tasks`, and change the feature rendered by `app/App.tsx`.
@@ -10,8 +10,8 @@
10
10
  "preview": "vite preview"
11
11
  },
12
12
  "dependencies": {
13
- "@enbox/browser": "0.3.83",
14
- "@enbox/react": "0.0.8",
13
+ "@enbox/browser": "0.3.85",
14
+ "@enbox/react": "0.0.10",
15
15
  "react": "19.3.0",
16
16
  "react-dom": "19.3.0"
17
17
  },
@@ -3,7 +3,7 @@ import { useEffect } from 'react';
3
3
 
4
4
  import app from '../app.json' with { type: 'json' };
5
5
  import { TaskBoard } from '../features/tasks/components/TaskBoard.js';
6
- import { ProfileCard } from '../shared/components/ProfileCard.js';
6
+ import { ProfileCard } from '../components/ProfileCard.js';
7
7
 
8
8
  export function App() {
9
9
  const connection = useEnboxConnection();
@@ -1,7 +1,16 @@
1
- import { defineApplication } from '@enbox/browser';
1
+ import { createApplication, type BrowserApplicationOptions } from '@enbox/browser';
2
2
 
3
3
  import app from '../app.json' with { type: 'json' };
4
4
  import { TaskBoardStore } from '../features/tasks/store.js';
5
5
 
6
- export const applicationDefinition = defineApplication({ stores: { tasks: TaskBoardStore } });
7
- export const wallet = { appName: app.name, applicationId: app.id };
6
+ export const applicationOptions = {
7
+ stores: { tasks: TaskBoardStore },
8
+ wallet: { appName: app.name, applicationId: app.id },
9
+ } satisfies BrowserApplicationOptions;
10
+
11
+ let application: ReturnType<typeof createApplication> | undefined;
12
+
13
+ /** One application owner for this page, shared by React and application controllers. */
14
+ export function getApplication() {
15
+ return application ??= createApplication(applicationOptions);
16
+ }
@@ -1,3 +1,4 @@
1
+ import type { StoreInstance } from '@enbox/browser';
1
2
  import { useFeature, useStore } from '@enbox/react';
2
3
  import { useState } from 'react';
3
4
  import type { FormEvent } from 'react';
@@ -5,12 +6,14 @@ import type { FormEvent } from 'react';
5
6
  import { TaskBoardStore } from '../store.js';
6
7
  import './tasks.css';
7
8
 
9
+ type TaskState = ReturnType<StoreInstance<typeof TaskBoardStore>['activeTasks']['getSnapshot']>;
10
+
8
11
  export function TaskBoard({ disabled = false }: { disabled?: boolean }) {
9
12
  const board = useFeature(TaskBoardStore);
10
13
  const projects = useStore(board.projects);
11
- const creation = useStore(board.createProject);
12
- const paging = useStore(board.moreProjects);
13
- const [projectContextId, setProjectContextId] = useState<string>();
14
+ const activeTasks = useStore(board.activeTasks);
15
+ const creation = useStore(board.projects.create);
16
+ const projectContextId = activeTasks.selection;
14
17
  const [name, setName] = useState('');
15
18
  const creating = creation.pendingCount > 0;
16
19
  const prompt = projects.status === 'loading' ? 'Loading projects…'
@@ -19,8 +22,11 @@ export function TaskBoard({ disabled = false }: { disabled?: boolean }) {
19
22
  async function addProject(event: FormEvent) {
20
23
  event.preventDefault();
21
24
  try {
22
- const record = await board.createProject.run(name);
23
- if (board.createProject.isCurrent()) { setProjectContextId(record.contextId); setName(''); }
25
+ const record = await board.createProject(name);
26
+ if (board.projects.create.isCurrent()) {
27
+ setName('');
28
+ await board.activeTasks.select(record.contextId);
29
+ }
24
30
  } catch { /* The command exposes the error below. */ }
25
31
  }
26
32
  return (
@@ -38,28 +44,28 @@ export function TaskBoard({ disabled = false }: { disabled?: boolean }) {
38
44
  {creation.error && <p role="alert">Couldn’t create the project: {creation.error.message}</p>}
39
45
  {projects.status === 'error' && <div role="alert">
40
46
  <p>Couldn’t load projects: {projects.error.message}</p>
41
- <button type="button" disabled={disabled} onClick={() => { void board.retryProjects.run(); }}>Try again</button>
47
+ <button type="button" disabled={disabled} onClick={() => { void board.projects.retry().catch(() => {}); }}>Try again</button>
42
48
  </div>}
43
49
  <div className="project-picker" role="group" aria-label="Projects">
44
50
  {projects.records.map(({ record, value }) => <button type="button" className="button button-quiet" key={record.id} disabled={disabled}
45
- aria-pressed={record.contextId === projectContextId} onClick={() => setProjectContextId(record.contextId)}>{value.name}</button>)}
51
+ aria-pressed={record.contextId === projectContextId} onClick={() => { void board.activeTasks.select(record.contextId).catch(() => {}); }}>{value.name}</button>)}
46
52
  </div>
47
- {projects.hasMore && <button type="button" disabled={disabled || paging.pendingCount > 0}
48
- onClick={() => { void board.moreProjects.run(); }}>More projects</button>}
49
- {projectContextId !== undefined
50
- ? <ProjectTasks key={projectContextId} within={projectContextId} disabled={disabled} />
53
+ {projects.hasMore && <button type="button" disabled={disabled || projects.isLoadingMore}
54
+ onClick={() => { void board.projects.loadMore().catch(() => {}); }}>More projects</button>}
55
+ {projectContextId !== null
56
+ ? <ProjectTasks key={projectContextId} within={projectContextId} tasks={activeTasks} disabled={disabled} />
51
57
  : <p role="status">{prompt}</p>}
52
58
  </section>
53
59
  );
54
60
  }
55
61
 
56
- function ProjectTasks({ within, disabled }: { within: string; disabled: boolean }) {
62
+ function ProjectTasks({ within, tasks, disabled }: {
63
+ within: string; tasks: TaskState; disabled: boolean;
64
+ }) {
57
65
  const board = useFeature(TaskBoardStore);
58
- const resource = board.tasks.within(within);
59
- const tasks = useStore(resource);
60
- const creation = useStore(resource.create);
61
- const updates = useStore(resource.patch);
62
- const paging = useStore(board.moreTasks);
66
+ const taskCollection = board.tasks.within(within);
67
+ const creation = useStore(taskCollection.create);
68
+ const updates = useStore(taskCollection.patch);
63
69
  const [title, setTitle] = useState('');
64
70
  // Reopening the same record creates a new UI target for completion guards.
65
71
  const [selection, setSelection] = useState<{ id: string }>();
@@ -68,7 +74,7 @@ function ProjectTasks({ within, disabled }: { within: string; disabled: boolean
68
74
  event.preventDefault();
69
75
  try {
70
76
  const record = await board.createTask({ within, title });
71
- if (resource.create.isCurrent()) { setSelection({ id: record.id }); setTitle(''); }
77
+ if (taskCollection.create.isCurrent()) { setSelection({ id: record.id }); setTitle(''); }
72
78
  } catch { /* The scoped create command exposes the error below. */ }
73
79
  }
74
80
  return <div className="project-content">
@@ -82,18 +88,18 @@ function ProjectTasks({ within, disabled }: { within: string; disabled: boolean
82
88
  {creation.error && <p role="alert">Couldn’t add the task: {creation.error.message}</p>}
83
89
  {updates.error && <p role="alert">Couldn’t update the task: {updates.error.message}</p>}
84
90
  {tasks.status === 'error' && <p role="alert">Couldn’t load tasks. <button type="button"
85
- disabled={disabled} onClick={() => { void board.retryTasks.run(within); }}>Retry tasks</button></p>}
91
+ disabled={disabled} onClick={() => { void taskCollection.retry().catch(() => {}); }}>Retry tasks</button></p>}
86
92
  <ul className="task-list">
87
- {tasks.records.map(({ record, value }) => <li key={record.id} data-completed={value.completed}>
93
+ {(tasks.records ?? []).map(({ record, value }) => <li key={record.id} data-completed={value.completed}>
88
94
  <input type="checkbox" aria-label={`Complete ${value.title}`} checked={value.completed}
89
- disabled={disabled || updates.pendingVariables.some((input) => input.id === record.id)}
90
- onChange={(event) => { void resource.patch.run({ id: record.id, patch: { completed: event.currentTarget.checked } }); }} />
95
+ disabled={disabled || updates.pendingInputs.some((input) => input.id === record.id)}
96
+ onChange={(event) => { void taskCollection.patch.run({ id: record.id, patch: { completed: event.currentTarget.checked } }).catch(() => {}); }} />
91
97
  <button type="button" className="button button-quiet" aria-label={`Open task ${value.title}`}
92
98
  disabled={disabled} aria-pressed={record.id === selection?.id} onClick={() => setSelection({ id: record.id })}>{value.title}</button>
93
99
  </li>)}
94
100
  </ul>
95
- {tasks.hasMore && <button type="button" disabled={disabled || paging.pendingVariables.includes(within)}
96
- onClick={() => { void board.moreTasks.run(within); }}>More tasks</button>}
101
+ {tasks.hasMore && <button type="button" disabled={disabled || tasks.isLoadingMore}
102
+ onClick={() => { void taskCollection.loadMore().catch(() => {}); }}>More tasks</button>}
97
103
  {selection !== undefined
98
104
  ? <TaskDetails key={selection.id} within={within} recordId={selection.id} disabled={disabled}
99
105
  onClose={() => setSelection((current) => current === selection ? undefined : current)} />
@@ -105,31 +111,32 @@ function TaskDetails({ within, recordId, disabled, onClose }: {
105
111
  within: string; recordId: string; disabled: boolean; onClose: () => void;
106
112
  }) {
107
113
  const board = useFeature(TaskBoardStore);
108
- const resource = board.tasks.within(within);
109
- const list = useStore(resource);
114
+ const taskCollection = board.tasks.within(within);
115
+ const list = useStore(taskCollection);
110
116
  const listed = list.records.some((row) => row.record.id === recordId);
111
117
  // A bounded window cannot prove an unlisted record is missing. Reuse loaded
112
118
  // rows, and acquire a single-record view only while that proof is unavailable.
113
119
  const needsLookup = !listed && (list.status !== 'ready' || list.hasMore);
114
- const selection = useStore(needsLookup ? board.taskById(within, recordId) : resource);
115
- const task = selection.records.find((row) => row.record.id === recordId);
116
- const status = selection.status;
117
- const updates = useStore(resource.patch);
118
- const deletion = useStore(resource.delete);
120
+ const lookup = needsLookup ? board.taskById(within, recordId) : null;
121
+ const details = useStore(lookup, { fallback: list });
122
+ const task = details.records.find((row) => row.record.id === recordId);
123
+ const status = details.status;
124
+ const updates = useStore(taskCollection.patch);
125
+ const deletion = useStore(taskCollection.delete);
119
126
  const [draft, setDraft] = useState<string>();
120
- const pending = updates.pendingVariables.some((input) => input.id === recordId) || deletion.pendingVariables.includes(recordId);
127
+ const pending = updates.pendingInputs.some((input) => input.id === recordId) || deletion.pendingInputs.includes(recordId);
121
128
  async function saveTitle(event: FormEvent) {
122
129
  event.preventDefault();
123
130
  if (draft === undefined) return;
124
131
  try {
125
132
  await board.renameTask({ within, id: recordId, title: draft });
126
- if (resource.patch.isCurrent()) setDraft(undefined);
133
+ if (taskCollection.patch.isCurrent()) setDraft(undefined);
127
134
  } catch { /* The scoped patch command exposes the error below. */ }
128
135
  }
129
136
  async function deleteTask() {
130
137
  try {
131
- await resource.delete.run(recordId);
132
- if (resource.delete.isCurrent()) onClose();
138
+ await taskCollection.delete.run(recordId);
139
+ if (taskCollection.delete.isCurrent()) onClose();
133
140
  } catch { /* The scoped delete command exposes the error below. */ }
134
141
  }
135
142
  const data = task?.value;
@@ -137,8 +144,7 @@ function TaskDetails({ within, recordId, disabled, onClose }: {
137
144
  <h3>Task details</h3>
138
145
  {status === 'error' && <p role="alert">Couldn’t load the task. <button type="button"
139
146
  disabled={disabled} onClick={() => {
140
- if (needsLookup) void board.retryTask.run({ within, recordId });
141
- else void board.retryTasks.run(within);
147
+ void (lookup ?? taskCollection).retry().catch(() => {});
142
148
  }}>Retry task</button></p>}
143
149
  {data === undefined && status === 'loading' && <p role="status">Loading task…</p>}
144
150
  {data === undefined && status === 'ready' && <p role="status">This task is no longer available.</p>}
@@ -2,12 +2,13 @@ import { DateSort, defineStore } from '@enbox/browser';
2
2
 
3
3
  import { TaskBoardProtocol } from './protocol.js';
4
4
 
5
- /** Protocol resources and domain defaults are shared with every consumer. */
5
+ /** Protocol observations and domain defaults are shared with every consumer. */
6
6
  export const TaskBoardStore = defineStore({
7
7
  protocols: { board: TaskBoardProtocol },
8
- create({ protocols, command }) {
8
+ create({ protocols, observe }) {
9
9
  const projects = protocols.board.collection('project', { dateSort: DateSort.CreatedDescending });
10
10
  const tasks = protocols.board.collection('project/task');
11
+ const activeTasks = observe({ source: (parent: string) => tasks.within(parent) });
11
12
  const taskById = (within: string, recordId: string) => protocols.board.collection('project/task', {
12
13
  filter: { recordId }, pagination: { limit: 1 },
13
14
  }).within(within);
@@ -15,17 +16,13 @@ export const TaskBoardStore = defineStore({
15
16
  return {
16
17
  projects,
17
18
  tasks,
19
+ activeTasks,
18
20
  taskById,
19
- createProject: command((name: string) => projects.create.run({ name: name.trim() })),
21
+ createProject: (name: string) => projects.create.run({ name: name.trim() }),
20
22
  createTask: ({ within, title }: { within: string; title: string }) =>
21
23
  tasks.within(within).create.run({ title: title.trim(), completed: false }),
22
24
  renameTask: ({ within, id, title }: { within: string; id: string; title: string }) =>
23
25
  tasks.within(within).patch.run({ id, patch: { title: title.trim() } }),
24
- moreProjects: command(() => projects.loadMore()),
25
- moreTasks: command((within: string) => tasks.within(within).loadMore()),
26
- retryProjects: command(() => projects.retry()),
27
- retryTasks: command((within: string) => tasks.within(within).retry()),
28
- retryTask: command(({ within, recordId }: { within: string; recordId: string }) => taskById(within, recordId).retry()),
29
26
  };
30
27
  },
31
28
  });
@@ -4,7 +4,7 @@ import { StrictMode } from 'react';
4
4
  import { createRoot } from 'react-dom/client';
5
5
 
6
6
  import { App } from './app/App.js';
7
- import { getApplication } from './app/runtime.js';
7
+ import { getApplication } from './app/application.js';
8
8
  import workerPath from './sw.ts?worker&url';
9
9
  import './style.css';
10
10
 
@@ -36,5 +36,7 @@ if (import.meta.env.PROD) {
36
36
  });
37
37
  }
38
38
 
39
- // The current browser SDK performs automatic worker takeover.
39
+ // This starter activates updates immediately. Apps with an update prompt should
40
+ // call skipWaiting only after the user accepts and pending edits are persisted.
41
+ self.addEventListener('install', (event) => { event.waitUntil(self.skipWaiting()); });
40
42
  activatePolyfills({ onCacheCheck: () => ({ ttl: 30_000 }) });
@@ -1,17 +0,0 @@
1
- import { BrowserConnectHandler, createApplication, createConnectionStore } from '@enbox/browser';
2
-
3
- import { applicationDefinition, wallet } from './application.js';
4
-
5
- let runtime: ReturnType<typeof createApplication> | undefined;
6
-
7
- /** One browser-lifetime owner shared by React and application controllers. */
8
- export function getApplication() {
9
- return runtime ??= createApplication(applicationDefinition, {
10
- connection: createConnectionStore({
11
- application: applicationDefinition.manifest,
12
- connectHandler: BrowserConnectHandler(wallet),
13
- }),
14
- });
15
- }
16
-
17
- if (import.meta.hot) import.meta.hot.dispose(() => { void runtime?.dispose(); });