@happyvertical/smrt-template-sveltekit 0.38.26 → 0.39.0

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,106 +1,103 @@
1
- /**
2
- * Home page server load + demo mutation.
3
- *
4
- * This file is the reference data-loading pattern for SMRT + SvelteKit apps:
5
- *
6
- * 1. **Query collections in a server `load`** — not with a client-side
7
- * `fetch` on mount. SvelteKit runs this during SSR, serializes the
8
- * returned data into the initial HTML, and hydrates it on the client
9
- * without re-fetching. The page renders with its data on first paint;
10
- * there is no empty-then-fetch waterfall and no duplicate first-render
11
- * request. Server loads read the database directly through the
12
- * collection API — they do not round-trip through your own
13
- * `/api/*` routes.
14
- *
15
- * 2. **Declare one dependency key per collection** with
16
- * `depends('smrt:<collection>')`, where `<collection>` matches the
17
- * generated REST route segment (`/api/items` → `smrt:items`). After a
18
- * mutation, client code calls `invalidate('smrt:<collection>')`
19
- * (see `+page.svelte`) and every load that declared the key re-runs.
20
- *
21
- * 3. **Opt read-heavy SSR reads into the collection read cache** with
22
- * `cache: { ttl }`. Mutations that go through SMRT (`create()`,
23
- * `save()`, `delete()`) invalidate the table's cache entries
24
- * in-process automatically, so the post-mutation `invalidate()`
25
- * re-runs this load against fresh rows. See "Data loading" in the
26
- * README for multi-replica caveats and when NOT to cache.
27
- */
28
-
1
+ import { assertOperationPermission } from '@happyvertical/smrt-users';
29
2
  import { fail } from '@sveltejs/kit';
3
+
30
4
  import type { Item } from '$lib/objects/Item';
31
- import { getCollection } from '$lib/server/smrt';
5
+ import { getCollection, getSmrtConfig } from '$lib/server/smrt';
32
6
  import type { Actions, PageServerLoad } from './$types';
33
7
 
34
- export const load: PageServerLoad = async ({ depends }) => {
35
- // Convention: `smrt:<collection>`. Registered before the query so even a
36
- // failed load (e.g. database not initialized yet) re-runs on invalidation.
8
+ const READ_PERMISSION = 'items.read';
9
+ const CREATE_PERMISSION = 'items.create';
10
+
11
+ export interface ItemRow {
12
+ id: string;
13
+ title: string;
14
+ status: string;
15
+ }
16
+
17
+ export const load: PageServerLoad = async ({ depends, locals }) => {
37
18
  depends('smrt:items');
38
19
 
20
+ const authenticated = Boolean(locals.user);
21
+ const hasTenant = Boolean(locals.tenantId);
22
+ const canRead =
23
+ authenticated && hasTenant && locals.permissions.includes(READ_PERMISSION);
24
+ const canCreate =
25
+ authenticated && hasTenant && locals.permissions.includes(CREATE_PERMISSION);
26
+
27
+ if (!canRead) {
28
+ return {
29
+ items: [] as ItemRow[],
30
+ canCreate,
31
+ loadError: null as string | null,
32
+ accessMessage: !authenticated
33
+ ? 'Sign in before loading tenant data.'
34
+ : !hasTenant
35
+ ? 'Select a tenant through a membership-gated session switch.'
36
+ : `Your active role does not grant ${READ_PERMISSION}.`,
37
+ };
38
+ }
39
+
39
40
  try {
40
41
  const items = await getCollection<Item>('Item');
41
42
  const rows = await items.list({
42
43
  orderBy: 'created_at DESC',
43
44
  limit: 50,
44
- // Opt this SSR read into the collection read cache (issue #1499).
45
- // Safe here because all writes go through SMRT, which invalidates the
46
- // table's entries in this process. Running multiple replicas? Add
47
- // `crossProcess: true` or drop the cache — see the README.
48
- cache: { ttl: 30_000 },
49
45
  });
50
46
 
51
47
  return {
52
- // Return plain serializable objects: SvelteKit encodes load data into
53
- // the initial HTML with devalue, so pick the fields the page needs
54
- // rather than returning class instances. Persisted rows always carry a
55
- // UUID `id`; the filter narrows the field's `string | null | undefined`
56
- // type instead of coercing — a '' fallback would produce colliding
57
- // `{#each}` keys if an id were ever missing.
58
- items: rows
59
- .filter((item): item is Item & { id: string } => Boolean(item.id))
60
- .map((item) => ({
61
- id: item.id,
62
- title: item.title,
63
- status: item.status,
64
- })),
48
+ items: rows.flatMap((item): ItemRow[] =>
49
+ item.id
50
+ ? [{ id: item.id, title: item.title, status: item.status }]
51
+ : [],
52
+ ),
53
+ canCreate,
65
54
  loadError: null as string | null,
55
+ accessMessage: null as string | null,
66
56
  };
67
- } catch (e) {
68
- // First run before `smrt db:setup` has created the tables: render the
69
- // page with guidance instead of a 500.
57
+ } catch (error) {
70
58
  return {
71
- items: [] as { id: string; title: string; status: string }[],
72
- loadError: e instanceof Error ? e.message : 'Failed to load items',
59
+ items: [] as ItemRow[],
60
+ canCreate,
61
+ loadError:
62
+ error instanceof Error ? error.message : 'Failed to load items',
63
+ accessMessage: null as string | null,
73
64
  };
74
65
  }
75
66
  };
76
67
 
77
68
  export const actions: Actions = {
78
- /**
79
- * Demo mutation for the invalidation convention: creates an Item, then the
80
- * client (`+page.svelte`) calls `invalidate('smrt:items')` so the load
81
- * above re-runs and the new row appears without a full page reload.
82
- *
83
- * NOTE: this demo action is intentionally unauthenticated so the refresh
84
- * flow works out of the box. Real mutations should be gated on
85
- * `locals.user` / permissions (the generated `/api/*` routes are already
86
- * fail-closed: they 401 unless authenticated or `@smrt({ api: { public } })`).
87
- */
88
- create: async ({ request }) => {
69
+ create: async ({ request, locals }) => {
70
+ if (!locals.user?.id || !locals.tenantId) {
71
+ return fail(401, { error: 'Authentication and an active tenant are required.' });
72
+ }
73
+
74
+ const decision = await assertOperationPermission({
75
+ ...getSmrtConfig('Item'),
76
+ action: 'create',
77
+ collection: 'items',
78
+ onDeny: 'return',
79
+ permissionSet: locals.permissions,
80
+ tenantId: locals.tenantId,
81
+ userId: locals.user.id,
82
+ });
83
+ if (!decision.allowed) {
84
+ return fail(403, { error: `Missing permission: ${CREATE_PERMISSION}` });
85
+ }
86
+
89
87
  const form = await request.formData();
90
88
  const title = String(form.get('title') ?? '').trim();
91
-
92
89
  if (!title) {
93
- return fail(400, { title, error: 'Title is required' });
90
+ return fail(400, { title, error: 'Title is required.' });
94
91
  }
95
92
 
96
93
  try {
97
94
  const items = await getCollection<Item>('Item');
98
95
  await items.create({ title });
99
96
  return { created: title };
100
- } catch (e) {
97
+ } catch (error) {
101
98
  return fail(500, {
102
99
  title,
103
- error: e instanceof Error ? e.message : 'Failed to create item',
100
+ error: error instanceof Error ? error.message : 'Failed to create item',
104
101
  });
105
102
  }
106
103
  },
@@ -3,158 +3,191 @@
3
3
  import { invalidate } from '$app/navigation';
4
4
  import type { PageProps } from './$types';
5
5
 
6
- // `data` comes from the server load in `+page.server.ts`. It is fetched
7
- // during SSR, serialized into the initial HTML, and hydrated here — the
8
- // first client render issues NO fetch for it. It re-runs only when
9
- // something calls `invalidate('smrt:items')` (below) or on navigation.
10
6
  let { data, form }: PageProps = $props();
11
-
12
7
  let creating = $state(false);
13
8
  </script>
14
9
 
15
10
  <svelte:head>
16
- <title>SMRT SvelteKit App</title>
11
+ <title>s-m-r-t SvelteKit starter</title>
17
12
  </svelte:head>
18
13
 
19
- <!--
20
- This page renders inside the root layout's <AdminShell> `<main>`
21
- (`src/routes/+layout.svelte`). Its server load + `invalidate('smrt:items')`
22
- refresh flow is unchanged by the shell — the shell is chrome around the
23
- page, not a replacement for its data loading. A plain wrapper element (not a
24
- second <main>) keeps the HTML valid.
25
- -->
26
14
  <div class="page">
27
- <h1>Welcome to SMRT + SvelteKit</h1>
15
+ <header>
16
+ <p class="eyebrow">s-m-r-t 0.38.25</p>
17
+ <h1>Learn the application foundation</h1>
18
+ <p>
19
+ One object, SQLite defaults, server-loaded data, generated interfaces,
20
+ session-authorized tenancy, and room to grow.
21
+ </p>
22
+ </header>
28
23
 
29
24
  <section>
30
- <h2>Items</h2>
25
+ <div class="section-heading">
26
+ <div>
27
+ <h2>Items</h2>
28
+ <p>The initial list comes from <code>+page.server.ts</code>.</p>
29
+ </div>
30
+ </div>
31
31
 
32
32
  {#if data.loadError}
33
- <p class="error">{data.loadError}</p>
34
- <p>If this is a fresh project, initialize the database with <code>smrt db:setup</code>.</p>
33
+ <p class="notice notice--error">{data.loadError}</p>
34
+ <p>Run <code>pnpm db:migrate</code> after changing object fields.</p>
35
+ {:else if data.accessMessage}
36
+ <p class="notice">{data.accessMessage}</p>
35
37
  {:else if data.items.length === 0}
36
- <p>No items yet. Create your first item below!</p>
38
+ <p class="notice">No items yet.</p>
37
39
  {:else}
38
- <ul>
40
+ <ul class="items">
39
41
  {#each data.items as item (item.id)}
40
42
  <li>
41
43
  <strong>{item.title}</strong>
42
- <span class="status">{item.status}</span>
44
+ <span>{item.status}</span>
43
45
  </li>
44
46
  {/each}
45
47
  </ul>
46
48
  {/if}
47
49
 
48
- <!--
49
- Post-mutation refresh convention: submit the mutation (a form action
50
- here, but a fetch to a generated /api route works the same way), then
51
- call `invalidate('smrt:items')`. Every load that declared
52
- `depends('smrt:items')` re-runs and the list updates in place.
53
- Without JavaScript this form still works — SvelteKit falls back to a
54
- full-page POST + re-render.
55
- -->
56
- <form
57
- method="POST"
58
- action="?/create"
59
- use:enhance={() => {
60
- creating = true;
61
- return async ({ result, update }) => {
62
- try {
63
- // Apply the action result (sets `form`, resets the input) but
64
- // skip SvelteKit's default invalidateAll() — we re-run only the
65
- // loads that depend on this collection.
66
- await update({ invalidateAll: false });
67
- if (result.type === 'success') {
68
- await invalidate('smrt:items');
50
+ {#if data.canCreate}
51
+ <form
52
+ method="POST"
53
+ action="?/create"
54
+ use:enhance={() => {
55
+ creating = true;
56
+ return async ({ result, update }) => {
57
+ try {
58
+ await update({ invalidateAll: false });
59
+ if (result.type === 'success') {
60
+ await invalidate('smrt:items');
61
+ }
62
+ } finally {
63
+ creating = false;
69
64
  }
70
- } finally {
71
- // Always re-enable the form, even if update()/invalidate()
72
- // throws (network error, aborted navigation, …).
73
- creating = false;
74
- }
75
- };
76
- }}
77
- >
78
- <input
79
- name="title"
80
- placeholder="New item title"
81
- aria-label="New item title"
82
- required
83
- />
84
- <button type="submit" disabled={creating}>
85
- {creating ? 'Creating…' : 'Create item'}
86
- </button>
87
- </form>
65
+ };
66
+ }}
67
+ >
68
+ <label for="title">New item</label>
69
+ <div class="form-row">
70
+ <input id="title" name="title" placeholder="Write a title" required />
71
+ <button type="submit" disabled={creating}>
72
+ {creating ? 'Creating…' : 'Create item'}
73
+ </button>
74
+ </div>
75
+ </form>
76
+ {/if}
88
77
 
89
78
  {#if form?.error}
90
- <p class="error">{form.error}</p>
79
+ <p class="notice notice--error">{form.error}</p>
91
80
  {/if}
92
81
  </section>
93
82
 
94
83
  <section>
95
- <h2>Getting Started</h2>
96
- <ul>
97
- <li>Edit <code>src/lib/objects/Item.ts</code> to customize your SMRT object</li>
98
- <li>Load data in <code>+page.server.ts</code> files — see this page's load for the SSR + <code>depends</code>/<code>invalidate</code> pattern</li>
99
- <li>Run <code>smrt objects</code> to see registered objects</li>
100
- <li>Run <code>smrt generate-routes</code> to regenerate API routes</li>
101
- <li>API routes are auto-generated in <code>src/routes/api/</code></li>
102
- </ul>
84
+ <h2>Where to extend</h2>
85
+ <ol>
86
+ <li>Edit <code>src/lib/objects/Item.ts</code>.</li>
87
+ <li>Run <code>pnpm db:migrate</code>.</li>
88
+ <li>Add pages that query collections in <code>+page.server.ts</code>.</li>
89
+ <li>Add membership-gated login and tenant-switch actions for your app.</li>
90
+ </ol>
103
91
  </section>
104
92
  </div>
105
93
 
106
94
  <style>
107
95
  .page {
108
- max-width: 800px;
109
- margin: 0 auto;
110
- padding: 2rem;
111
- font-family: system-ui, -apple-system, sans-serif;
96
+ display: grid;
97
+ gap: var(--smrt-spacing-6);
98
+ width: min(100%, 56rem);
99
+ margin-inline: auto;
100
+ padding: var(--smrt-spacing-8);
101
+ }
102
+
103
+ header,
104
+ section,
105
+ .section-heading,
106
+ form {
107
+ display: grid;
108
+ gap: var(--smrt-spacing-3);
112
109
  }
113
110
 
114
- h1 {
115
- color: #667eea;
111
+ header p,
112
+ h1,
113
+ h2,
114
+ section p {
115
+ margin: 0;
116
+ }
117
+
118
+ .eyebrow {
119
+ color: var(--smrt-color-primary);
120
+ font: var(--smrt-typography-label-large);
121
+ letter-spacing: 0.08em;
122
+ text-transform: uppercase;
116
123
  }
117
124
 
118
125
  section {
119
- margin: 2rem 0;
120
- padding: 1rem;
121
- background: #f5f5f5;
122
- border-radius: var(--smrt-radius-md, 8px);
126
+ padding: var(--smrt-spacing-6);
127
+ border: 1px solid var(--smrt-color-outline-variant);
128
+ border-radius: var(--smrt-radius-large);
129
+ background: var(--smrt-color-surface);
123
130
  }
124
131
 
125
- .error {
126
- color: #c33;
132
+ .notice {
133
+ padding: var(--smrt-spacing-3);
134
+ border-radius: var(--smrt-radius-medium);
135
+ background: var(--smrt-color-surface-container);
136
+ color: var(--smrt-color-on-surface-variant);
127
137
  }
128
138
 
129
- .status {
130
- font-size: 0.8em;
131
- background: #e0e0e0;
132
- padding: 2px 8px;
133
- border-radius: var(--smrt-radius-sm, 4px);
134
- margin-left: 8px;
139
+ .notice--error {
140
+ color: var(--smrt-color-error);
135
141
  }
136
142
 
137
- form {
143
+ .items {
144
+ display: grid;
145
+ gap: var(--smrt-spacing-2);
146
+ margin: 0;
147
+ padding: 0;
148
+ list-style: none;
149
+ }
150
+
151
+ .items li {
138
152
  display: flex;
139
- gap: 0.5rem;
140
- margin-top: 1rem;
153
+ justify-content: space-between;
154
+ gap: var(--smrt-spacing-3);
155
+ padding-block: var(--smrt-spacing-2);
156
+ border-block-end: 1px solid var(--smrt-color-outline-variant);
157
+ }
158
+
159
+ .items span {
160
+ color: var(--smrt-color-on-surface-variant);
161
+ }
162
+
163
+ label {
164
+ font-weight: var(--smrt-typography-weight-semibold);
165
+ }
166
+
167
+ .form-row {
168
+ display: flex;
169
+ gap: var(--smrt-spacing-2);
170
+ }
171
+
172
+ input,
173
+ button {
174
+ min-height: 2.75rem;
175
+ padding-inline: var(--smrt-spacing-3);
176
+ border-radius: var(--smrt-radius-medium);
177
+ font: inherit;
141
178
  }
142
179
 
143
180
  input {
144
181
  flex: 1;
145
- padding: 0.5rem;
146
- border: 1px solid #ccc;
147
- border-radius: var(--smrt-radius-sm, 4px);
148
- font-size: 1em;
182
+ border: 1px solid var(--smrt-color-outline);
183
+ background: var(--smrt-color-surface);
184
+ color: var(--smrt-color-on-surface);
149
185
  }
150
186
 
151
187
  button {
152
- padding: 0.5rem 1rem;
153
- border: none;
154
- border-radius: var(--smrt-radius-sm, 4px);
155
- background: #667eea;
156
- color: white;
157
- font-size: 1em;
188
+ border: 0;
189
+ background: var(--smrt-color-primary);
190
+ color: var(--smrt-color-on-primary);
158
191
  cursor: pointer;
159
192
  }
160
193
 
@@ -164,18 +197,17 @@
164
197
  }
165
198
 
166
199
  code {
167
- background: #e0e0e0;
168
- padding: 2px 6px;
169
- border-radius: var(--smrt-radius-sm, 4px);
170
- font-size: 0.9em;
200
+ font-family: var(--smrt-typography-code-font-family, monospace);
171
201
  }
172
202
 
173
- ul {
174
- list-style: disc;
175
- padding-left: 1.5rem;
176
- }
203
+ @media (max-width: 42rem) {
204
+ .page {
205
+ padding: var(--smrt-spacing-4);
206
+ }
177
207
 
178
- li {
179
- margin: 0.5rem 0;
208
+ .form-row {
209
+ align-items: stretch;
210
+ flex-direction: column;
211
+ }
180
212
  }
181
213
  </style>
@@ -9,7 +9,7 @@
9
9
  </script>
10
10
 
11
11
  <svelte:head>
12
- <title>Settings · SMRT App</title>
12
+ <title>Settings · s-m-r-t app</title>
13
13
  </svelte:head>
14
14
 
15
15
  <section class="settings">
@@ -18,10 +18,24 @@ export default defineConfig({
18
18
  },
19
19
  plugins: [
20
20
  sveltekit(),
21
+ // Only packages whose object manifests this app consumes belong here.
22
+ // Keeping the list explicit avoids treating tooling/UI packages as domain
23
+ // model providers and makes generated runtime registration deterministic.
24
+ smrtConsumer({
25
+ packages: [
26
+ '@happyvertical/smrt-profiles',
27
+ '@happyvertical/smrt-tenancy',
28
+ '@happyvertical/smrt-users',
29
+ ],
30
+ generateTypes: true,
31
+ typesDir: 'src/lib/types/smrt-generated',
32
+ svelteKit: true,
33
+ }),
21
34
  smrtPlugin({
22
35
  include: ['src/lib/objects/**/*.ts'],
23
36
  exclude: ['**/*.test.ts', '**/*.spec.ts'],
24
37
  generateTypes: true,
38
+ typeDeclarationsPath: 'src/lib/types/smrt-generated',
25
39
  svelteKit: {
26
40
  enabled: true,
27
41
  routesDir: 'src/routes/api',
@@ -30,9 +44,5 @@ export default defineConfig({
30
44
  configFileName: 'smrt.ts',
31
45
  },
32
46
  }),
33
- // Required alongside smrtPlugin(): this project depends on external SMRT
34
- // packages (@happyvertical/smrt-users, smrt-tenancy, …), and the consumer
35
- // plugin emits `.smrt/register.js` so their classes load for CLI/runtime.
36
- smrtConsumer(),
37
47
  ],
38
48
  });
package/LICENSE DELETED
@@ -1,7 +0,0 @@
1
- Copyright <2025> <Happy Vertical Corporation>
2
-
3
- Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
-
5
- The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
-
7
- THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.