@noy-db/pinia 0.3.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.
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/dist/index.cjs +214 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +257 -0
- package/dist/index.d.ts +257 -0
- package/dist/index.js +183 -0
- package/dist/index.js.map +1 -0
- package/package.json +70 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 vLannaAi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# @noy-db/pinia
|
|
2
|
+
|
|
3
|
+
> Pinia integration for [noy-db](https://github.com/vLannaAi/noy-db) — drop-in encrypted Pinia stores for Vue 3 / Nuxt 4.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pnpm add @noy-db/pinia @noy-db/core pinia vue
|
|
7
|
+
# pick an adapter for your environment:
|
|
8
|
+
pnpm add @noy-db/file # local disk / USB
|
|
9
|
+
pnpm add @noy-db/browser # localStorage / IndexedDB
|
|
10
|
+
pnpm add @noy-db/dynamo # AWS DynamoDB
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
// stores/invoices.ts
|
|
17
|
+
import { defineNoydbStore } from '@noy-db/pinia';
|
|
18
|
+
|
|
19
|
+
interface Invoice {
|
|
20
|
+
id: string;
|
|
21
|
+
amount: number;
|
|
22
|
+
status: 'draft' | 'open' | 'paid';
|
|
23
|
+
client: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const useInvoices = defineNoydbStore<Invoice>('invoices', {
|
|
27
|
+
compartment: 'C101',
|
|
28
|
+
});
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
// main.ts
|
|
33
|
+
import { createApp } from 'vue';
|
|
34
|
+
import { createPinia } from 'pinia';
|
|
35
|
+
import { createNoydb } from '@noy-db/core';
|
|
36
|
+
import { jsonFile } from '@noy-db/file';
|
|
37
|
+
import { setActiveNoydb } from '@noy-db/pinia';
|
|
38
|
+
import App from './App.vue';
|
|
39
|
+
|
|
40
|
+
const db = await createNoydb({
|
|
41
|
+
adapter: jsonFile({ dir: './data' }),
|
|
42
|
+
user: 'owner',
|
|
43
|
+
secret: () => prompt('Passphrase')!,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
setActiveNoydb(db);
|
|
47
|
+
|
|
48
|
+
createApp(App).use(createPinia()).mount('#app');
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
```vue
|
|
52
|
+
<!-- App.vue -->
|
|
53
|
+
<script setup lang="ts">
|
|
54
|
+
import { useInvoices } from './stores/invoices';
|
|
55
|
+
|
|
56
|
+
const invoices = useInvoices();
|
|
57
|
+
await invoices.$ready;
|
|
58
|
+
|
|
59
|
+
async function addOne() {
|
|
60
|
+
const id = `inv-${Date.now()}`;
|
|
61
|
+
await invoices.add(id, { id, amount: 100, status: 'draft', client: 'Acme' });
|
|
62
|
+
}
|
|
63
|
+
</script>
|
|
64
|
+
|
|
65
|
+
<template>
|
|
66
|
+
<div>
|
|
67
|
+
<button @click="addOne">Add invoice</button>
|
|
68
|
+
<p>{{ invoices.count }} invoices</p>
|
|
69
|
+
<ul>
|
|
70
|
+
<li v-for="inv in invoices.items" :key="inv.id">
|
|
71
|
+
{{ inv.client }} — {{ inv.amount }} — {{ inv.status }}
|
|
72
|
+
</li>
|
|
73
|
+
</ul>
|
|
74
|
+
</div>
|
|
75
|
+
</template>
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Store API
|
|
79
|
+
|
|
80
|
+
Every store returned by `defineNoydbStore` exposes:
|
|
81
|
+
|
|
82
|
+
| Member | Type | Purpose |
|
|
83
|
+
|---|---|---|
|
|
84
|
+
| `items` | `Ref<T[]>` | Reactive array of all decrypted records |
|
|
85
|
+
| `count` | `ComputedRef<number>` | Reactive count |
|
|
86
|
+
| `$ready` | `Promise<void>` | Resolves once the collection has hydrated on first use |
|
|
87
|
+
| `byId(id)` | `(id) => T \| undefined` | O(N) cache lookup |
|
|
88
|
+
| `add(id, record)` | `async (id, T) => void` | Encrypt + persist + update reactive state |
|
|
89
|
+
| `update(id, record)` | `async (id, T) => void` | Alias for `add` (NOYDB `put` is upsert) |
|
|
90
|
+
| `remove(id)` | `async (id) => void` | Delete + update reactive state |
|
|
91
|
+
| `refresh()` | `async () => void` | Re-hydrate from the adapter (use after sync pulls) |
|
|
92
|
+
| `query()` | `() => Query<T>` | Chainable query DSL — see `@noy-db/core` |
|
|
93
|
+
|
|
94
|
+
## Composition
|
|
95
|
+
|
|
96
|
+
The store is a real Pinia store. All these work unmodified:
|
|
97
|
+
|
|
98
|
+
- `storeToRefs(store)` — destructure with reactivity intact
|
|
99
|
+
- Vue Devtools — appears in the devtools tab like any other store
|
|
100
|
+
- SSR — `items` is empty during server render, hydrates on the client
|
|
101
|
+
- `pinia-plugin-persistedstate` — works as a fallback layer below NOYDB encryption
|
|
102
|
+
|
|
103
|
+
## Schema validation
|
|
104
|
+
|
|
105
|
+
Pass any object exposing `parse(input): T` (Zod, Valibot, ArkType, Effect Schema, etc.):
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
import { z } from 'zod';
|
|
109
|
+
|
|
110
|
+
const InvoiceSchema = z.object({
|
|
111
|
+
id: z.string(),
|
|
112
|
+
amount: z.number().positive(),
|
|
113
|
+
status: z.enum(['draft', 'open', 'paid']),
|
|
114
|
+
client: z.string(),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
export const useInvoices = defineNoydbStore<z.infer<typeof InvoiceSchema>>('invoices', {
|
|
118
|
+
compartment: 'C101',
|
|
119
|
+
schema: InvoiceSchema,
|
|
120
|
+
});
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`add()` and `update()` will throw if the record fails validation, before any encryption or write happens.
|
|
124
|
+
|
|
125
|
+
## Query DSL
|
|
126
|
+
|
|
127
|
+
The store's `query()` method returns the same chainable builder as `Collection.query()`:
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
const overdue = invoices.query()
|
|
131
|
+
.where('status', '==', 'open')
|
|
132
|
+
.where('dueDate', '<', new Date())
|
|
133
|
+
.orderBy('dueDate', 'asc')
|
|
134
|
+
.limit(50)
|
|
135
|
+
.toArray();
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
See [`@noy-db/core` query DSL docs](../core/README.md#query-dsl) for the full operator list.
|
|
139
|
+
|
|
140
|
+
## Status
|
|
141
|
+
|
|
142
|
+
Part of the v0.3 release. See [ROADMAP.md](../../ROADMAP.md#v03--pinia-first-dx--query--scale).
|
|
143
|
+
|
|
144
|
+
## License
|
|
145
|
+
|
|
146
|
+
MIT © vLannaAi
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
createNoydbPiniaPlugin: () => createNoydbPiniaPlugin,
|
|
24
|
+
defineNoydbStore: () => defineNoydbStore,
|
|
25
|
+
getActiveNoydb: () => getActiveNoydb,
|
|
26
|
+
resolveNoydb: () => resolveNoydb,
|
|
27
|
+
setActiveNoydb: () => setActiveNoydb
|
|
28
|
+
});
|
|
29
|
+
module.exports = __toCommonJS(index_exports);
|
|
30
|
+
|
|
31
|
+
// src/defineNoydbStore.ts
|
|
32
|
+
var import_pinia = require("pinia");
|
|
33
|
+
var import_vue = require("vue");
|
|
34
|
+
|
|
35
|
+
// src/context.ts
|
|
36
|
+
var activeInstance = null;
|
|
37
|
+
function setActiveNoydb(instance) {
|
|
38
|
+
activeInstance = instance;
|
|
39
|
+
}
|
|
40
|
+
function getActiveNoydb() {
|
|
41
|
+
return activeInstance;
|
|
42
|
+
}
|
|
43
|
+
function resolveNoydb(explicit) {
|
|
44
|
+
if (explicit) return explicit;
|
|
45
|
+
if (activeInstance) return activeInstance;
|
|
46
|
+
throw new Error(
|
|
47
|
+
"@noy-db/pinia: no Noydb instance bound.\n Option A \u2014 pass `noydb:` directly to defineNoydbStore({...})\n Option B \u2014 call setActiveNoydb(instance) once at app startup\n Option C \u2014 install the @noy-db/nuxt module (Nuxt 4+)"
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// src/defineNoydbStore.ts
|
|
52
|
+
function defineNoydbStore(id, options) {
|
|
53
|
+
const collectionName = options.collection ?? id;
|
|
54
|
+
const prefetch = options.prefetch ?? true;
|
|
55
|
+
return (0, import_pinia.defineStore)(id, () => {
|
|
56
|
+
const items = (0, import_vue.shallowRef)([]);
|
|
57
|
+
const count = (0, import_vue.computed)(() => items.value.length);
|
|
58
|
+
let cachedCompartment = null;
|
|
59
|
+
let cachedCollection = null;
|
|
60
|
+
async function getCollection() {
|
|
61
|
+
if (cachedCollection) return cachedCollection;
|
|
62
|
+
const noydb = resolveNoydb(options.noydb ?? null);
|
|
63
|
+
cachedCompartment = await noydb.openCompartment(options.compartment);
|
|
64
|
+
cachedCollection = cachedCompartment.collection(collectionName);
|
|
65
|
+
return cachedCollection;
|
|
66
|
+
}
|
|
67
|
+
async function refresh() {
|
|
68
|
+
const c = await getCollection();
|
|
69
|
+
const list = await c.list();
|
|
70
|
+
items.value = list;
|
|
71
|
+
}
|
|
72
|
+
function byId(id2) {
|
|
73
|
+
for (const item of items.value) {
|
|
74
|
+
if (item.id === id2) return item;
|
|
75
|
+
}
|
|
76
|
+
return void 0;
|
|
77
|
+
}
|
|
78
|
+
async function add(id2, record) {
|
|
79
|
+
const validated = options.schema ? options.schema.parse(record) : record;
|
|
80
|
+
const c = await getCollection();
|
|
81
|
+
await c.put(id2, validated);
|
|
82
|
+
items.value = await c.list();
|
|
83
|
+
}
|
|
84
|
+
async function update(id2, record) {
|
|
85
|
+
await add(id2, record);
|
|
86
|
+
}
|
|
87
|
+
async function remove(id2) {
|
|
88
|
+
const c = await getCollection();
|
|
89
|
+
await c.delete(id2);
|
|
90
|
+
items.value = await c.list();
|
|
91
|
+
}
|
|
92
|
+
function query() {
|
|
93
|
+
if (!cachedCollection) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
"@noy-db/pinia: query() called before the store was ready. Await store.$ready first, or set prefetch: true (default)."
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return cachedCollection.query();
|
|
99
|
+
}
|
|
100
|
+
const $ready = prefetch ? refresh() : Promise.resolve();
|
|
101
|
+
return {
|
|
102
|
+
items,
|
|
103
|
+
count,
|
|
104
|
+
$ready,
|
|
105
|
+
byId,
|
|
106
|
+
add,
|
|
107
|
+
update,
|
|
108
|
+
remove,
|
|
109
|
+
refresh,
|
|
110
|
+
query
|
|
111
|
+
};
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// src/plugin.ts
|
|
116
|
+
var import_core = require("@noy-db/core");
|
|
117
|
+
var STATE_DOC_ID = "__state__";
|
|
118
|
+
function createNoydbPiniaPlugin(opts) {
|
|
119
|
+
let dbPromise = null;
|
|
120
|
+
function getDb() {
|
|
121
|
+
if (!dbPromise) {
|
|
122
|
+
dbPromise = (async () => {
|
|
123
|
+
const secret = await opts.secret();
|
|
124
|
+
return (0, import_core.createNoydb)({
|
|
125
|
+
adapter: opts.adapter,
|
|
126
|
+
user: opts.user,
|
|
127
|
+
secret,
|
|
128
|
+
...opts.noydbOptions
|
|
129
|
+
});
|
|
130
|
+
})();
|
|
131
|
+
}
|
|
132
|
+
return dbPromise;
|
|
133
|
+
}
|
|
134
|
+
const compartmentCache = /* @__PURE__ */ new Map();
|
|
135
|
+
function getCompartment(name) {
|
|
136
|
+
let p = compartmentCache.get(name);
|
|
137
|
+
if (!p) {
|
|
138
|
+
p = getDb().then((db) => db.openCompartment(name));
|
|
139
|
+
compartmentCache.set(name, p);
|
|
140
|
+
}
|
|
141
|
+
return p;
|
|
142
|
+
}
|
|
143
|
+
return (context) => {
|
|
144
|
+
const noydbOption = context.options.noydb;
|
|
145
|
+
if (!noydbOption) {
|
|
146
|
+
context.store.$noydbAugmented = false;
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
context.store.$noydbAugmented = true;
|
|
150
|
+
context.store.$noydbError = null;
|
|
151
|
+
const pending = /* @__PURE__ */ new Set();
|
|
152
|
+
const ready = (async () => {
|
|
153
|
+
try {
|
|
154
|
+
const compartment = await getCompartment(noydbOption.compartment);
|
|
155
|
+
const collection = compartment.collection(
|
|
156
|
+
noydbOption.collection
|
|
157
|
+
);
|
|
158
|
+
const persisted = await collection.get(STATE_DOC_ID);
|
|
159
|
+
if (persisted) {
|
|
160
|
+
const validated = noydbOption.schema ? noydbOption.schema.parse(persisted) : persisted;
|
|
161
|
+
const picked = pickKeys(validated, noydbOption.persist);
|
|
162
|
+
context.store.$patch(picked);
|
|
163
|
+
}
|
|
164
|
+
context.store.$subscribe(
|
|
165
|
+
(_mutation, state) => {
|
|
166
|
+
const subset = pickKeys(state, noydbOption.persist);
|
|
167
|
+
const p = collection.put(STATE_DOC_ID, subset).catch((err) => {
|
|
168
|
+
context.store.$noydbError = err instanceof Error ? err : new Error(String(err));
|
|
169
|
+
}).finally(() => {
|
|
170
|
+
pending.delete(p);
|
|
171
|
+
});
|
|
172
|
+
pending.add(p);
|
|
173
|
+
},
|
|
174
|
+
{ detached: true }
|
|
175
|
+
// outlive the component that triggered the mutation
|
|
176
|
+
);
|
|
177
|
+
} catch (err) {
|
|
178
|
+
context.store.$noydbError = err instanceof Error ? err : new Error(String(err));
|
|
179
|
+
}
|
|
180
|
+
})();
|
|
181
|
+
context.store.$noydbReady = ready;
|
|
182
|
+
context.store.$noydbFlush = async () => {
|
|
183
|
+
await ready;
|
|
184
|
+
while (pending.size > 0) {
|
|
185
|
+
await Promise.all([...pending]);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
function pickKeys(state, persist) {
|
|
191
|
+
if (persist === void 0 || persist === "*") {
|
|
192
|
+
return { ...state };
|
|
193
|
+
}
|
|
194
|
+
if (typeof persist === "string") {
|
|
195
|
+
return { [persist]: state[persist] };
|
|
196
|
+
}
|
|
197
|
+
if (Array.isArray(persist)) {
|
|
198
|
+
const out = {};
|
|
199
|
+
for (const key of persist) {
|
|
200
|
+
out[key] = state[key];
|
|
201
|
+
}
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
return { ...state };
|
|
205
|
+
}
|
|
206
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
207
|
+
0 && (module.exports = {
|
|
208
|
+
createNoydbPiniaPlugin,
|
|
209
|
+
defineNoydbStore,
|
|
210
|
+
getActiveNoydb,
|
|
211
|
+
resolveNoydb,
|
|
212
|
+
setActiveNoydb
|
|
213
|
+
});
|
|
214
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/defineNoydbStore.ts","../src/context.ts","../src/plugin.ts"],"sourcesContent":["/**\n * @noy-db/pinia — Pinia integration for noy-db.\n *\n * Two adoption paths:\n *\n * 1. **Greenfield** — `defineNoydbStore<T>(id, options)` creates a new\n * Pinia store fully wired to a NOYDB collection.\n *\n * 2. **Augmentation** — `createNoydbPiniaPlugin(options)` lets existing\n * `defineStore()` stores opt into NOYDB persistence by adding one\n * `noydb:` option, with no component code changes.\n *\n * Plus a global instance binding for both paths:\n * - `setActiveNoydb(instance)` / `getActiveNoydb()` / `resolveNoydb()`\n */\n\nexport { defineNoydbStore } from './defineNoydbStore.js'\nexport type { NoydbStoreOptions, NoydbStore } from './defineNoydbStore.js'\nexport { setActiveNoydb, getActiveNoydb, resolveNoydb } from './context.js'\nexport { createNoydbPiniaPlugin } from './plugin.js'\nexport type { StoreNoydbOptions, NoydbPiniaPluginOptions } from './plugin.js'\n","/**\n * `defineNoydbStore` — drop-in `defineStore` that wires a Pinia store to a\n * NOYDB compartment + collection.\n *\n * Returned store exposes:\n * - `items` — reactive array of all records\n * - `byId(id)` — O(1) lookup\n * - `count` — reactive count getter\n * - `add(id, rec)` — encrypt + persist + update reactive state\n * - `update(id, rec)` — same as add (Collection.put is upsert)\n * - `remove(id)` — delete + update reactive state\n * - `refresh()` — re-hydrate from the adapter\n * - `query()` — chainable query DSL bound to the store\n * - `$ready` — Promise<void> resolved on first hydration\n *\n * Compatible with `storeToRefs`, Vue Devtools, SSR, and pinia plugins.\n */\n\nimport { defineStore } from 'pinia'\nimport { computed, shallowRef, type Ref, type ComputedRef } from 'vue'\nimport type { Noydb, Compartment, Collection, Query } from '@noy-db/core'\nimport { resolveNoydb } from './context.js'\n\n/**\n * Options accepted by `defineNoydbStore`.\n *\n * Generic `T` is the record shape — defaults to `unknown` if the caller\n * doesn't supply a type. Use `defineNoydbStore<Invoice>('invoices', {...})`\n * for full type safety.\n */\nexport interface NoydbStoreOptions<T> {\n /** Compartment (tenant) name. */\n compartment: string\n /** Collection name within the compartment. Defaults to the store id. */\n collection?: string\n /**\n * Optional explicit Noydb instance. If omitted, the store resolves the\n * globally bound instance via `getActiveNoydb()`.\n */\n noydb?: Noydb | null\n /**\n * If true (default), hydration kicks off immediately when the store is\n * first instantiated. If false, hydration is deferred until the first\n * call to `refresh()` or any read accessor.\n */\n prefetch?: boolean\n /**\n * Optional schema validator. Any object exposing a `parse(input): T`\n * method (Zod, Valibot, ArkType, etc.) is accepted.\n */\n schema?: { parse: (input: unknown) => T }\n}\n\n/**\n * The runtime shape of the store returned by `defineNoydbStore`.\n * Exposed as a public type so consumers can write `useStore: ReturnType<typeof useInvoices>`.\n */\nexport interface NoydbStore<T> {\n items: Ref<T[]>\n count: ComputedRef<number>\n $ready: Promise<void>\n byId(id: string): T | undefined\n add(id: string, record: T): Promise<void>\n update(id: string, record: T): Promise<void>\n remove(id: string): Promise<void>\n refresh(): Promise<void>\n query(): Query<T>\n}\n\n/**\n * Define a Pinia store that's wired to a NOYDB collection.\n *\n * Generic T defaults to `unknown` — pass `<MyType>` for full type inference.\n *\n * @example\n * ```ts\n * import { defineNoydbStore } from '@noy-db/pinia';\n *\n * export const useInvoices = defineNoydbStore<Invoice>('invoices', {\n * compartment: 'C101',\n * schema: InvoiceSchema, // optional\n * });\n * ```\n */\nexport function defineNoydbStore<T>(\n id: string,\n options: NoydbStoreOptions<T>,\n) {\n const collectionName = options.collection ?? id\n const prefetch = options.prefetch ?? true\n\n return defineStore(id, () => {\n // Reactive state. shallowRef on items because the array reference is what\n // changes — replacing it triggers reactivity without per-record proxying.\n const items: Ref<T[]> = shallowRef<T[]>([])\n const count = computed(() => items.value.length)\n\n // Lazy collection handle — created on first hydrate.\n let cachedCompartment: Compartment | null = null\n let cachedCollection: Collection<T> | null = null\n\n async function getCollection(): Promise<Collection<T>> {\n if (cachedCollection) return cachedCollection\n const noydb = resolveNoydb(options.noydb ?? null)\n cachedCompartment = await noydb.openCompartment(options.compartment)\n cachedCollection = cachedCompartment.collection<T>(collectionName)\n return cachedCollection\n }\n\n async function refresh(): Promise<void> {\n const c = await getCollection()\n const list = await c.list()\n items.value = list\n }\n\n function byId(id: string): T | undefined {\n // Linear scan against the reactive cache. Index-aware lookups land in #13.\n // Optimization opportunity: maintain a Map<string, T> alongside items.\n for (const item of items.value) {\n if ((item as { id?: string }).id === id) return item\n }\n return undefined\n }\n\n async function add(id: string, record: T): Promise<void> {\n const validated = options.schema ? options.schema.parse(record) : record\n const c = await getCollection()\n await c.put(id, validated)\n // Re-list to pick up the new record. Cheaper alternative would be to\n // splice into items.value directly, but list() ensures consistency\n // with the underlying cache.\n items.value = await c.list()\n }\n\n async function update(id: string, record: T): Promise<void> {\n // Collection.put is upsert; this is just a more readable alias.\n await add(id, record)\n }\n\n async function remove(id: string): Promise<void> {\n const c = await getCollection()\n await c.delete(id)\n items.value = await c.list()\n }\n\n function query(): Query<T> {\n // Synchronous query() requires the collection to be hydrated.\n // The lazy refresh() in $ready handles that — but if the user calls\n // query() before $ready resolves, the collection still works because\n // Collection.query() reads from its own internal cache (which Noydb\n // hydrates lazily as well).\n if (!cachedCollection) {\n throw new Error(\n '@noy-db/pinia: query() called before the store was ready. ' +\n 'Await store.$ready first, or set prefetch: true (default).',\n )\n }\n return cachedCollection.query()\n }\n\n // Kick off hydration. The promise is exposed as $ready so components\n // can `await store.$ready` before rendering data-dependent UI.\n const $ready: Promise<void> = prefetch\n ? refresh()\n : Promise.resolve()\n\n return {\n items,\n count,\n $ready,\n byId,\n add,\n update,\n remove,\n refresh,\n query,\n }\n })\n}\n","/**\n * Active NOYDB instance binding.\n *\n * `defineNoydbStore` resolves the `Noydb` instance from one of three places,\n * in priority order:\n *\n * 1. The store options' explicit `noydb:` field (highest precedence — useful\n * for tests and multi-database apps).\n * 2. A globally bound instance set via `setActiveNoydb()` — this is what the\n * Nuxt module's runtime plugin and playground apps use.\n * 3. Throws a clear error if neither is set.\n *\n * Keeping the binding pluggable means tests can pass an instance directly\n * without polluting global state.\n */\n\nimport type { Noydb } from '@noy-db/core'\n\nlet activeInstance: Noydb | null = null\n\n/** Bind a Noydb instance globally. Called by the Nuxt module / app plugin. */\nexport function setActiveNoydb(instance: Noydb | null): void {\n activeInstance = instance\n}\n\n/** Returns the globally bound Noydb instance, or null if none. */\nexport function getActiveNoydb(): Noydb | null {\n return activeInstance\n}\n\n/**\n * Resolve the Noydb instance to use for a store. Throws if no instance is\n * bound — the error message points the developer at the three options.\n */\nexport function resolveNoydb(explicit?: Noydb | null): Noydb {\n if (explicit) return explicit\n if (activeInstance) return activeInstance\n throw new Error(\n '@noy-db/pinia: no Noydb instance bound.\\n' +\n ' Option A — pass `noydb:` directly to defineNoydbStore({...})\\n' +\n ' Option B — call setActiveNoydb(instance) once at app startup\\n' +\n ' Option C — install the @noy-db/nuxt module (Nuxt 4+)',\n )\n}\n","/**\n * `createNoydbPiniaPlugin` — augmentation path for existing Pinia stores.\n *\n * Lets a developer take any existing `defineStore()` call and opt into NOYDB\n * persistence by adding a single `noydb:` option, without touching component\n * code. The plugin watches the chosen state key(s), encrypts on change, syncs\n * to a NOYDB collection, and rehydrates on store init.\n *\n * @example\n * ```ts\n * import { createPinia } from 'pinia';\n * import { createNoydbPiniaPlugin } from '@noy-db/pinia';\n * import { jsonFile } from '@noy-db/file';\n *\n * const pinia = createPinia();\n * pinia.use(createNoydbPiniaPlugin({\n * adapter: jsonFile({ dir: './data' }),\n * user: 'owner-01',\n * secret: () => promptPassphrase(),\n * }));\n *\n * // existing store — add one option, no component changes:\n * export const useClients = defineStore('clients', {\n * state: () => ({ list: [] as Client[] }),\n * noydb: { compartment: 'C101', collection: 'clients', persist: 'list' },\n * });\n * ```\n *\n * Design notes\n * ------------\n * - Each augmented store persists a SINGLE document at id `__state__`\n * containing the picked keys. We don't try to map state arrays onto\n * per-element records — that's `defineNoydbStore`'s territory.\n * - The Noydb instance is constructed lazily on first store-with-noydb\n * instantiation, then memoized for the lifetime of the Pinia app.\n * This means apps that don't actually use any noydb-augmented stores\n * pay zero crypto cost.\n * - `secret` is a function so the passphrase can come from a prompt,\n * biometric unlock, or session token — never stored in config.\n * - The plugin sets `store.$noydbReady` (a `Promise<void>`) and\n * `store.$noydbError` (an `Error | null`) on every augmented store\n * so components can await hydration and surface failures.\n */\n\nimport type { PiniaPluginContext, PiniaPlugin, StateTree } from 'pinia'\nimport { createNoydb, type Noydb, type NoydbOptions, type NoydbAdapter, type Compartment, type Collection } from '@noy-db/core'\n\n/**\n * Per-store NOYDB configuration. Attached to a Pinia store via the `noydb`\n * option inside `defineStore({ ..., noydb: {...} })`.\n *\n * `persist` selects which top-level state keys to mirror into NOYDB.\n * Pass a single key, an array of keys, or `'*'` to mirror the entire state.\n */\nexport interface StoreNoydbOptions<S extends StateTree = StateTree> {\n /** Compartment (tenant) name. */\n compartment: string\n /** Collection name within the compartment. */\n collection: string\n /**\n * Which state keys to persist. Defaults to `'*'` (the entire state object).\n * Pass a string or string[] to scope to specific keys.\n */\n persist?: keyof S | (keyof S)[] | '*'\n /**\n * Optional schema validator applied at the document level (the persisted\n * subset of state, not individual records). Throws if validation fails on\n * hydration — the store stays at its initial state and `$noydbError` is set.\n */\n schema?: { parse: (input: unknown) => unknown }\n}\n\n/**\n * Configuration for `createNoydbPiniaPlugin`. Mirrors `NoydbOptions` but\n * makes `secret` a function so the passphrase can come from a prompt\n * rather than being stored in config.\n */\nexport interface NoydbPiniaPluginOptions {\n /** The NOYDB adapter to use for persistence. */\n adapter: NoydbAdapter\n /** User identifier (matches the keyring file). */\n user: string\n /**\n * Passphrase provider. Called once on first noydb-augmented store\n * instantiation. Return a string or a Promise that resolves to one.\n */\n secret: () => string | Promise<string>\n /** Optional Noydb open-options forwarded to `createNoydb`. */\n noydbOptions?: Partial<Omit<NoydbOptions, 'adapter' | 'user' | 'secret'>>\n}\n\n// The fixed document id under which a store's persisted state lives. Using a\n// reserved prefix so it can't collide with any user-chosen record id.\nconst STATE_DOC_ID = '__state__'\n\n/**\n * Create a Pinia plugin that wires NOYDB persistence into any store\n * declaring a `noydb:` option.\n *\n * Returns a `PiniaPlugin` directly usable with `pinia.use(...)`.\n */\nexport function createNoydbPiniaPlugin(opts: NoydbPiniaPluginOptions): PiniaPlugin {\n // Single Noydb instance shared across all augmented stores in this Pinia\n // app. Created lazily on first use so apps that never instantiate a\n // noydb-augmented store pay zero crypto cost.\n let dbPromise: Promise<Noydb> | null = null\n function getDb(): Promise<Noydb> {\n if (!dbPromise) {\n dbPromise = (async (): Promise<Noydb> => {\n const secret = await opts.secret()\n return createNoydb({\n adapter: opts.adapter,\n user: opts.user,\n secret,\n ...opts.noydbOptions,\n })\n })()\n }\n return dbPromise\n }\n\n // Compartment cache so opening a compartment is a one-time cost per app.\n const compartmentCache = new Map<string, Promise<Compartment>>()\n function getCompartment(name: string): Promise<Compartment> {\n let p = compartmentCache.get(name)\n if (!p) {\n p = getDb().then((db) => db.openCompartment(name))\n compartmentCache.set(name, p)\n }\n return p\n }\n\n return (context: PiniaPluginContext) => {\n // Pinia stores can declare arbitrary options on `defineStore`, but the\n // plugin context only exposes them via `context.options`. Pull our\n // `noydb` option out and bail early if it's not present — that's\n // the \"store is untouched\" path for non-augmented stores.\n const noydbOption = (context.options as { noydb?: StoreNoydbOptions }).noydb\n if (!noydbOption) {\n // Mark the store as opted-out so devtools / consumers can detect it.\n context.store.$noydbAugmented = false\n return\n }\n\n context.store.$noydbAugmented = true\n context.store.$noydbError = null as Error | null\n\n // Track in-flight persistence promises so tests (and consumers) can\n // await deterministic flushes via `$noydbFlush()`. Plain Set-of-Promises\n // — entries auto-remove on settle.\n const pending = new Set<Promise<void>>()\n\n // Hydrate-then-subscribe. Both happen inside an async closure so the\n // store can be awaited via `$noydbReady`.\n const ready = (async (): Promise<void> => {\n try {\n const compartment = await getCompartment(noydbOption.compartment)\n const collection: Collection<StateTree> = compartment.collection<StateTree>(\n noydbOption.collection,\n )\n\n // 1. Hydration: read the persisted document (if any) and apply\n // the picked keys onto the store's current state. We use\n // `$patch` so reactivity fires correctly.\n const persisted = await collection.get(STATE_DOC_ID)\n if (persisted) {\n const validated = noydbOption.schema\n ? (noydbOption.schema.parse(persisted) as StateTree)\n : persisted\n const picked = pickKeys(validated, noydbOption.persist)\n context.store.$patch(picked)\n }\n\n // 2. Subscribe: every state mutation triggers an encrypted write\n // of the picked subset back to NOYDB. The subscription captures\n // `collection` so it doesn't re-resolve on every event.\n context.store.$subscribe(\n (_mutation, state) => {\n const subset = pickKeys(state, noydbOption.persist)\n const p = collection.put(STATE_DOC_ID, subset)\n .catch((err: unknown) => {\n context.store.$noydbError = err instanceof Error ? err : new Error(String(err))\n })\n .finally(() => {\n pending.delete(p)\n })\n pending.add(p)\n },\n { detached: true }, // outlive the component that triggered the mutation\n )\n } catch (err) {\n context.store.$noydbError = err instanceof Error ? err : new Error(String(err))\n }\n })()\n\n context.store.$noydbReady = ready\n /**\n * Wait for all in-flight persistence puts to settle. Use this in tests\n * to deterministically observe the encrypted state on the adapter, and\n * in app code before unmounting components that mutated the store.\n */\n context.store.$noydbFlush = async (): Promise<void> => {\n await ready\n // Snapshot the current pending set; new puts added during await\n // are picked up by the next $noydbFlush() call.\n while (pending.size > 0) {\n await Promise.all([...pending])\n }\n }\n }\n}\n\n/**\n * Pick the configured subset of keys from a state object.\n *\n * Behaviors:\n * - `undefined` or `'*'` → returns the entire state shallow-copied\n * - single key string → returns `{ [key]: state[key] }`\n * - key array → returns `{ [k1]: state[k1], [k2]: state[k2], ... }`\n *\n * The result is always a fresh object so callers can mutate it without\n * touching the store's reactive state.\n */\nfunction pickKeys(state: StateTree, persist: StoreNoydbOptions['persist']): StateTree {\n if (persist === undefined || persist === '*') {\n return { ...state }\n }\n if (typeof persist === 'string') {\n return { [persist]: state[persist] } as StateTree\n }\n if (Array.isArray(persist)) {\n const out: StateTree = {}\n for (const key of persist) {\n out[key as string] = state[key as string]\n }\n return out\n }\n // Should be unreachable thanks to the type, but defensive default.\n return { ...state }\n}\n\n// ─── Pinia module augmentation ─────────────────────────────────────\n//\n// Pinia exposes `DefineStoreOptionsBase` as the place where third-party\n// plugins are expected to attach their custom option types. Augmenting it\n// here means `defineStore('x', { ..., noydb: {...} })` autocompletes inside\n// the IDE and type-checks correctly without forcing users to import\n// anything from `@noy-db/pinia`.\n//\n// We also augment `PiniaCustomProperties` so the runtime fields we add to\n// every store (`$noydbReady`, `$noydbError`, `$noydbAugmented`) are typed.\n\ndeclare module 'pinia' {\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n export interface DefineStoreOptionsBase<S extends StateTree, Store> {\n /**\n * Opt this store into NOYDB persistence via the\n * `createNoydbPiniaPlugin` augmentation plugin.\n *\n * The chosen state keys are encrypted and persisted to the configured\n * compartment + collection on every mutation, and rehydrated on first\n * store access.\n */\n noydb?: StoreNoydbOptions<S>\n }\n\n export interface PiniaCustomProperties {\n /**\n * Resolves once this store has finished its initial hydration from\n * NOYDB. `undefined` for stores that don't declare a `noydb:` option.\n */\n $noydbReady?: Promise<void>\n /**\n * Set when hydration or persistence fails. `null` while healthy.\n * Plugins (and devtools) can poll this to surface storage errors.\n */\n $noydbError?: Error | null\n /**\n * `true` if this store opted into NOYDB persistence via the `noydb:`\n * option, `false` otherwise. Useful for debugging and devtools.\n */\n $noydbAugmented?: boolean\n /**\n * Wait for all in-flight encrypted persistence puts to complete.\n * Useful in tests for deterministic flushing, and in app code before\n * unmounting components that just mutated the store.\n */\n $noydbFlush?: () => Promise<void>\n }\n}\n"],"mappings":"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","names":["id"]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import * as pinia from 'pinia';
|
|
2
|
+
import { StateTree, PiniaPlugin } from 'pinia';
|
|
3
|
+
import { Ref, ComputedRef } from 'vue';
|
|
4
|
+
import { Query, Noydb, NoydbAdapter, NoydbOptions } from '@noy-db/core';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Options accepted by `defineNoydbStore`.
|
|
8
|
+
*
|
|
9
|
+
* Generic `T` is the record shape — defaults to `unknown` if the caller
|
|
10
|
+
* doesn't supply a type. Use `defineNoydbStore<Invoice>('invoices', {...})`
|
|
11
|
+
* for full type safety.
|
|
12
|
+
*/
|
|
13
|
+
interface NoydbStoreOptions<T> {
|
|
14
|
+
/** Compartment (tenant) name. */
|
|
15
|
+
compartment: string;
|
|
16
|
+
/** Collection name within the compartment. Defaults to the store id. */
|
|
17
|
+
collection?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Optional explicit Noydb instance. If omitted, the store resolves the
|
|
20
|
+
* globally bound instance via `getActiveNoydb()`.
|
|
21
|
+
*/
|
|
22
|
+
noydb?: Noydb | null;
|
|
23
|
+
/**
|
|
24
|
+
* If true (default), hydration kicks off immediately when the store is
|
|
25
|
+
* first instantiated. If false, hydration is deferred until the first
|
|
26
|
+
* call to `refresh()` or any read accessor.
|
|
27
|
+
*/
|
|
28
|
+
prefetch?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Optional schema validator. Any object exposing a `parse(input): T`
|
|
31
|
+
* method (Zod, Valibot, ArkType, etc.) is accepted.
|
|
32
|
+
*/
|
|
33
|
+
schema?: {
|
|
34
|
+
parse: (input: unknown) => T;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The runtime shape of the store returned by `defineNoydbStore`.
|
|
39
|
+
* Exposed as a public type so consumers can write `useStore: ReturnType<typeof useInvoices>`.
|
|
40
|
+
*/
|
|
41
|
+
interface NoydbStore<T> {
|
|
42
|
+
items: Ref<T[]>;
|
|
43
|
+
count: ComputedRef<number>;
|
|
44
|
+
$ready: Promise<void>;
|
|
45
|
+
byId(id: string): T | undefined;
|
|
46
|
+
add(id: string, record: T): Promise<void>;
|
|
47
|
+
update(id: string, record: T): Promise<void>;
|
|
48
|
+
remove(id: string): Promise<void>;
|
|
49
|
+
refresh(): Promise<void>;
|
|
50
|
+
query(): Query<T>;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Define a Pinia store that's wired to a NOYDB collection.
|
|
54
|
+
*
|
|
55
|
+
* Generic T defaults to `unknown` — pass `<MyType>` for full type inference.
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* ```ts
|
|
59
|
+
* import { defineNoydbStore } from '@noy-db/pinia';
|
|
60
|
+
*
|
|
61
|
+
* export const useInvoices = defineNoydbStore<Invoice>('invoices', {
|
|
62
|
+
* compartment: 'C101',
|
|
63
|
+
* schema: InvoiceSchema, // optional
|
|
64
|
+
* });
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
declare function defineNoydbStore<T>(id: string, options: NoydbStoreOptions<T>): pinia.StoreDefinition<string, Pick<{
|
|
68
|
+
items: Ref<T[], T[]>;
|
|
69
|
+
count: ComputedRef<number>;
|
|
70
|
+
$ready: Promise<void>;
|
|
71
|
+
byId: (id: string) => T | undefined;
|
|
72
|
+
add: (id: string, record: T) => Promise<void>;
|
|
73
|
+
update: (id: string, record: T) => Promise<void>;
|
|
74
|
+
remove: (id: string) => Promise<void>;
|
|
75
|
+
refresh: () => Promise<void>;
|
|
76
|
+
query: () => Query<T>;
|
|
77
|
+
}, "items" | "$ready">, Pick<{
|
|
78
|
+
items: Ref<T[], T[]>;
|
|
79
|
+
count: ComputedRef<number>;
|
|
80
|
+
$ready: Promise<void>;
|
|
81
|
+
byId: (id: string) => T | undefined;
|
|
82
|
+
add: (id: string, record: T) => Promise<void>;
|
|
83
|
+
update: (id: string, record: T) => Promise<void>;
|
|
84
|
+
remove: (id: string) => Promise<void>;
|
|
85
|
+
refresh: () => Promise<void>;
|
|
86
|
+
query: () => Query<T>;
|
|
87
|
+
}, "count">, Pick<{
|
|
88
|
+
items: Ref<T[], T[]>;
|
|
89
|
+
count: ComputedRef<number>;
|
|
90
|
+
$ready: Promise<void>;
|
|
91
|
+
byId: (id: string) => T | undefined;
|
|
92
|
+
add: (id: string, record: T) => Promise<void>;
|
|
93
|
+
update: (id: string, record: T) => Promise<void>;
|
|
94
|
+
remove: (id: string) => Promise<void>;
|
|
95
|
+
refresh: () => Promise<void>;
|
|
96
|
+
query: () => Query<T>;
|
|
97
|
+
}, "byId" | "add" | "update" | "remove" | "refresh" | "query">>;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Active NOYDB instance binding.
|
|
101
|
+
*
|
|
102
|
+
* `defineNoydbStore` resolves the `Noydb` instance from one of three places,
|
|
103
|
+
* in priority order:
|
|
104
|
+
*
|
|
105
|
+
* 1. The store options' explicit `noydb:` field (highest precedence — useful
|
|
106
|
+
* for tests and multi-database apps).
|
|
107
|
+
* 2. A globally bound instance set via `setActiveNoydb()` — this is what the
|
|
108
|
+
* Nuxt module's runtime plugin and playground apps use.
|
|
109
|
+
* 3. Throws a clear error if neither is set.
|
|
110
|
+
*
|
|
111
|
+
* Keeping the binding pluggable means tests can pass an instance directly
|
|
112
|
+
* without polluting global state.
|
|
113
|
+
*/
|
|
114
|
+
|
|
115
|
+
/** Bind a Noydb instance globally. Called by the Nuxt module / app plugin. */
|
|
116
|
+
declare function setActiveNoydb(instance: Noydb | null): void;
|
|
117
|
+
/** Returns the globally bound Noydb instance, or null if none. */
|
|
118
|
+
declare function getActiveNoydb(): Noydb | null;
|
|
119
|
+
/**
|
|
120
|
+
* Resolve the Noydb instance to use for a store. Throws if no instance is
|
|
121
|
+
* bound — the error message points the developer at the three options.
|
|
122
|
+
*/
|
|
123
|
+
declare function resolveNoydb(explicit?: Noydb | null): Noydb;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* `createNoydbPiniaPlugin` — augmentation path for existing Pinia stores.
|
|
127
|
+
*
|
|
128
|
+
* Lets a developer take any existing `defineStore()` call and opt into NOYDB
|
|
129
|
+
* persistence by adding a single `noydb:` option, without touching component
|
|
130
|
+
* code. The plugin watches the chosen state key(s), encrypts on change, syncs
|
|
131
|
+
* to a NOYDB collection, and rehydrates on store init.
|
|
132
|
+
*
|
|
133
|
+
* @example
|
|
134
|
+
* ```ts
|
|
135
|
+
* import { createPinia } from 'pinia';
|
|
136
|
+
* import { createNoydbPiniaPlugin } from '@noy-db/pinia';
|
|
137
|
+
* import { jsonFile } from '@noy-db/file';
|
|
138
|
+
*
|
|
139
|
+
* const pinia = createPinia();
|
|
140
|
+
* pinia.use(createNoydbPiniaPlugin({
|
|
141
|
+
* adapter: jsonFile({ dir: './data' }),
|
|
142
|
+
* user: 'owner-01',
|
|
143
|
+
* secret: () => promptPassphrase(),
|
|
144
|
+
* }));
|
|
145
|
+
*
|
|
146
|
+
* // existing store — add one option, no component changes:
|
|
147
|
+
* export const useClients = defineStore('clients', {
|
|
148
|
+
* state: () => ({ list: [] as Client[] }),
|
|
149
|
+
* noydb: { compartment: 'C101', collection: 'clients', persist: 'list' },
|
|
150
|
+
* });
|
|
151
|
+
* ```
|
|
152
|
+
*
|
|
153
|
+
* Design notes
|
|
154
|
+
* ------------
|
|
155
|
+
* - Each augmented store persists a SINGLE document at id `__state__`
|
|
156
|
+
* containing the picked keys. We don't try to map state arrays onto
|
|
157
|
+
* per-element records — that's `defineNoydbStore`'s territory.
|
|
158
|
+
* - The Noydb instance is constructed lazily on first store-with-noydb
|
|
159
|
+
* instantiation, then memoized for the lifetime of the Pinia app.
|
|
160
|
+
* This means apps that don't actually use any noydb-augmented stores
|
|
161
|
+
* pay zero crypto cost.
|
|
162
|
+
* - `secret` is a function so the passphrase can come from a prompt,
|
|
163
|
+
* biometric unlock, or session token — never stored in config.
|
|
164
|
+
* - The plugin sets `store.$noydbReady` (a `Promise<void>`) and
|
|
165
|
+
* `store.$noydbError` (an `Error | null`) on every augmented store
|
|
166
|
+
* so components can await hydration and surface failures.
|
|
167
|
+
*/
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Per-store NOYDB configuration. Attached to a Pinia store via the `noydb`
|
|
171
|
+
* option inside `defineStore({ ..., noydb: {...} })`.
|
|
172
|
+
*
|
|
173
|
+
* `persist` selects which top-level state keys to mirror into NOYDB.
|
|
174
|
+
* Pass a single key, an array of keys, or `'*'` to mirror the entire state.
|
|
175
|
+
*/
|
|
176
|
+
interface StoreNoydbOptions<S extends StateTree = StateTree> {
|
|
177
|
+
/** Compartment (tenant) name. */
|
|
178
|
+
compartment: string;
|
|
179
|
+
/** Collection name within the compartment. */
|
|
180
|
+
collection: string;
|
|
181
|
+
/**
|
|
182
|
+
* Which state keys to persist. Defaults to `'*'` (the entire state object).
|
|
183
|
+
* Pass a string or string[] to scope to specific keys.
|
|
184
|
+
*/
|
|
185
|
+
persist?: keyof S | (keyof S)[] | '*';
|
|
186
|
+
/**
|
|
187
|
+
* Optional schema validator applied at the document level (the persisted
|
|
188
|
+
* subset of state, not individual records). Throws if validation fails on
|
|
189
|
+
* hydration — the store stays at its initial state and `$noydbError` is set.
|
|
190
|
+
*/
|
|
191
|
+
schema?: {
|
|
192
|
+
parse: (input: unknown) => unknown;
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Configuration for `createNoydbPiniaPlugin`. Mirrors `NoydbOptions` but
|
|
197
|
+
* makes `secret` a function so the passphrase can come from a prompt
|
|
198
|
+
* rather than being stored in config.
|
|
199
|
+
*/
|
|
200
|
+
interface NoydbPiniaPluginOptions {
|
|
201
|
+
/** The NOYDB adapter to use for persistence. */
|
|
202
|
+
adapter: NoydbAdapter;
|
|
203
|
+
/** User identifier (matches the keyring file). */
|
|
204
|
+
user: string;
|
|
205
|
+
/**
|
|
206
|
+
* Passphrase provider. Called once on first noydb-augmented store
|
|
207
|
+
* instantiation. Return a string or a Promise that resolves to one.
|
|
208
|
+
*/
|
|
209
|
+
secret: () => string | Promise<string>;
|
|
210
|
+
/** Optional Noydb open-options forwarded to `createNoydb`. */
|
|
211
|
+
noydbOptions?: Partial<Omit<NoydbOptions, 'adapter' | 'user' | 'secret'>>;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Create a Pinia plugin that wires NOYDB persistence into any store
|
|
215
|
+
* declaring a `noydb:` option.
|
|
216
|
+
*
|
|
217
|
+
* Returns a `PiniaPlugin` directly usable with `pinia.use(...)`.
|
|
218
|
+
*/
|
|
219
|
+
declare function createNoydbPiniaPlugin(opts: NoydbPiniaPluginOptions): PiniaPlugin;
|
|
220
|
+
declare module 'pinia' {
|
|
221
|
+
interface DefineStoreOptionsBase<S extends StateTree, Store> {
|
|
222
|
+
/**
|
|
223
|
+
* Opt this store into NOYDB persistence via the
|
|
224
|
+
* `createNoydbPiniaPlugin` augmentation plugin.
|
|
225
|
+
*
|
|
226
|
+
* The chosen state keys are encrypted and persisted to the configured
|
|
227
|
+
* compartment + collection on every mutation, and rehydrated on first
|
|
228
|
+
* store access.
|
|
229
|
+
*/
|
|
230
|
+
noydb?: StoreNoydbOptions<S>;
|
|
231
|
+
}
|
|
232
|
+
interface PiniaCustomProperties {
|
|
233
|
+
/**
|
|
234
|
+
* Resolves once this store has finished its initial hydration from
|
|
235
|
+
* NOYDB. `undefined` for stores that don't declare a `noydb:` option.
|
|
236
|
+
*/
|
|
237
|
+
$noydbReady?: Promise<void>;
|
|
238
|
+
/**
|
|
239
|
+
* Set when hydration or persistence fails. `null` while healthy.
|
|
240
|
+
* Plugins (and devtools) can poll this to surface storage errors.
|
|
241
|
+
*/
|
|
242
|
+
$noydbError?: Error | null;
|
|
243
|
+
/**
|
|
244
|
+
* `true` if this store opted into NOYDB persistence via the `noydb:`
|
|
245
|
+
* option, `false` otherwise. Useful for debugging and devtools.
|
|
246
|
+
*/
|
|
247
|
+
$noydbAugmented?: boolean;
|
|
248
|
+
/**
|
|
249
|
+
* Wait for all in-flight encrypted persistence puts to complete.
|
|
250
|
+
* Useful in tests for deterministic flushing, and in app code before
|
|
251
|
+
* unmounting components that just mutated the store.
|
|
252
|
+
*/
|
|
253
|
+
$noydbFlush?: () => Promise<void>;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export { type NoydbPiniaPluginOptions, type NoydbStore, type NoydbStoreOptions, type StoreNoydbOptions, createNoydbPiniaPlugin, defineNoydbStore, getActiveNoydb, resolveNoydb, setActiveNoydb };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import * as pinia from 'pinia';
|
|
2
|
+
import { StateTree, PiniaPlugin } from 'pinia';
|
|
3
|
+
import { Ref, ComputedRef } from 'vue';
|
|
4
|
+
import { Query, Noydb, NoydbAdapter, NoydbOptions } from '@noy-db/core';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Options accepted by `defineNoydbStore`.
|
|
8
|
+
*
|
|
9
|
+
* Generic `T` is the record shape — defaults to `unknown` if the caller
|
|
10
|
+
* doesn't supply a type. Use `defineNoydbStore<Invoice>('invoices', {...})`
|
|
11
|
+
* for full type safety.
|
|
12
|
+
*/
|
|
13
|
+
interface NoydbStoreOptions<T> {
|
|
14
|
+
/** Compartment (tenant) name. */
|
|
15
|
+
compartment: string;
|
|
16
|
+
/** Collection name within the compartment. Defaults to the store id. */
|
|
17
|
+
collection?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Optional explicit Noydb instance. If omitted, the store resolves the
|
|
20
|
+
* globally bound instance via `getActiveNoydb()`.
|
|
21
|
+
*/
|
|
22
|
+
noydb?: Noydb | null;
|
|
23
|
+
/**
|
|
24
|
+
* If true (default), hydration kicks off immediately when the store is
|
|
25
|
+
* first instantiated. If false, hydration is deferred until the first
|
|
26
|
+
* call to `refresh()` or any read accessor.
|
|
27
|
+
*/
|
|
28
|
+
prefetch?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Optional schema validator. Any object exposing a `parse(input): T`
|
|
31
|
+
* method (Zod, Valibot, ArkType, etc.) is accepted.
|
|
32
|
+
*/
|
|
33
|
+
schema?: {
|
|
34
|
+
parse: (input: unknown) => T;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The runtime shape of the store returned by `defineNoydbStore`.
|
|
39
|
+
* Exposed as a public type so consumers can write `useStore: ReturnType<typeof useInvoices>`.
|
|
40
|
+
*/
|
|
41
|
+
interface NoydbStore<T> {
|
|
42
|
+
items: Ref<T[]>;
|
|
43
|
+
count: ComputedRef<number>;
|
|
44
|
+
$ready: Promise<void>;
|
|
45
|
+
byId(id: string): T | undefined;
|
|
46
|
+
add(id: string, record: T): Promise<void>;
|
|
47
|
+
update(id: string, record: T): Promise<void>;
|
|
48
|
+
remove(id: string): Promise<void>;
|
|
49
|
+
refresh(): Promise<void>;
|
|
50
|
+
query(): Query<T>;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Define a Pinia store that's wired to a NOYDB collection.
|
|
54
|
+
*
|
|
55
|
+
* Generic T defaults to `unknown` — pass `<MyType>` for full type inference.
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* ```ts
|
|
59
|
+
* import { defineNoydbStore } from '@noy-db/pinia';
|
|
60
|
+
*
|
|
61
|
+
* export const useInvoices = defineNoydbStore<Invoice>('invoices', {
|
|
62
|
+
* compartment: 'C101',
|
|
63
|
+
* schema: InvoiceSchema, // optional
|
|
64
|
+
* });
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
declare function defineNoydbStore<T>(id: string, options: NoydbStoreOptions<T>): pinia.StoreDefinition<string, Pick<{
|
|
68
|
+
items: Ref<T[], T[]>;
|
|
69
|
+
count: ComputedRef<number>;
|
|
70
|
+
$ready: Promise<void>;
|
|
71
|
+
byId: (id: string) => T | undefined;
|
|
72
|
+
add: (id: string, record: T) => Promise<void>;
|
|
73
|
+
update: (id: string, record: T) => Promise<void>;
|
|
74
|
+
remove: (id: string) => Promise<void>;
|
|
75
|
+
refresh: () => Promise<void>;
|
|
76
|
+
query: () => Query<T>;
|
|
77
|
+
}, "items" | "$ready">, Pick<{
|
|
78
|
+
items: Ref<T[], T[]>;
|
|
79
|
+
count: ComputedRef<number>;
|
|
80
|
+
$ready: Promise<void>;
|
|
81
|
+
byId: (id: string) => T | undefined;
|
|
82
|
+
add: (id: string, record: T) => Promise<void>;
|
|
83
|
+
update: (id: string, record: T) => Promise<void>;
|
|
84
|
+
remove: (id: string) => Promise<void>;
|
|
85
|
+
refresh: () => Promise<void>;
|
|
86
|
+
query: () => Query<T>;
|
|
87
|
+
}, "count">, Pick<{
|
|
88
|
+
items: Ref<T[], T[]>;
|
|
89
|
+
count: ComputedRef<number>;
|
|
90
|
+
$ready: Promise<void>;
|
|
91
|
+
byId: (id: string) => T | undefined;
|
|
92
|
+
add: (id: string, record: T) => Promise<void>;
|
|
93
|
+
update: (id: string, record: T) => Promise<void>;
|
|
94
|
+
remove: (id: string) => Promise<void>;
|
|
95
|
+
refresh: () => Promise<void>;
|
|
96
|
+
query: () => Query<T>;
|
|
97
|
+
}, "byId" | "add" | "update" | "remove" | "refresh" | "query">>;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Active NOYDB instance binding.
|
|
101
|
+
*
|
|
102
|
+
* `defineNoydbStore` resolves the `Noydb` instance from one of three places,
|
|
103
|
+
* in priority order:
|
|
104
|
+
*
|
|
105
|
+
* 1. The store options' explicit `noydb:` field (highest precedence — useful
|
|
106
|
+
* for tests and multi-database apps).
|
|
107
|
+
* 2. A globally bound instance set via `setActiveNoydb()` — this is what the
|
|
108
|
+
* Nuxt module's runtime plugin and playground apps use.
|
|
109
|
+
* 3. Throws a clear error if neither is set.
|
|
110
|
+
*
|
|
111
|
+
* Keeping the binding pluggable means tests can pass an instance directly
|
|
112
|
+
* without polluting global state.
|
|
113
|
+
*/
|
|
114
|
+
|
|
115
|
+
/** Bind a Noydb instance globally. Called by the Nuxt module / app plugin. */
|
|
116
|
+
declare function setActiveNoydb(instance: Noydb | null): void;
|
|
117
|
+
/** Returns the globally bound Noydb instance, or null if none. */
|
|
118
|
+
declare function getActiveNoydb(): Noydb | null;
|
|
119
|
+
/**
|
|
120
|
+
* Resolve the Noydb instance to use for a store. Throws if no instance is
|
|
121
|
+
* bound — the error message points the developer at the three options.
|
|
122
|
+
*/
|
|
123
|
+
declare function resolveNoydb(explicit?: Noydb | null): Noydb;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* `createNoydbPiniaPlugin` — augmentation path for existing Pinia stores.
|
|
127
|
+
*
|
|
128
|
+
* Lets a developer take any existing `defineStore()` call and opt into NOYDB
|
|
129
|
+
* persistence by adding a single `noydb:` option, without touching component
|
|
130
|
+
* code. The plugin watches the chosen state key(s), encrypts on change, syncs
|
|
131
|
+
* to a NOYDB collection, and rehydrates on store init.
|
|
132
|
+
*
|
|
133
|
+
* @example
|
|
134
|
+
* ```ts
|
|
135
|
+
* import { createPinia } from 'pinia';
|
|
136
|
+
* import { createNoydbPiniaPlugin } from '@noy-db/pinia';
|
|
137
|
+
* import { jsonFile } from '@noy-db/file';
|
|
138
|
+
*
|
|
139
|
+
* const pinia = createPinia();
|
|
140
|
+
* pinia.use(createNoydbPiniaPlugin({
|
|
141
|
+
* adapter: jsonFile({ dir: './data' }),
|
|
142
|
+
* user: 'owner-01',
|
|
143
|
+
* secret: () => promptPassphrase(),
|
|
144
|
+
* }));
|
|
145
|
+
*
|
|
146
|
+
* // existing store — add one option, no component changes:
|
|
147
|
+
* export const useClients = defineStore('clients', {
|
|
148
|
+
* state: () => ({ list: [] as Client[] }),
|
|
149
|
+
* noydb: { compartment: 'C101', collection: 'clients', persist: 'list' },
|
|
150
|
+
* });
|
|
151
|
+
* ```
|
|
152
|
+
*
|
|
153
|
+
* Design notes
|
|
154
|
+
* ------------
|
|
155
|
+
* - Each augmented store persists a SINGLE document at id `__state__`
|
|
156
|
+
* containing the picked keys. We don't try to map state arrays onto
|
|
157
|
+
* per-element records — that's `defineNoydbStore`'s territory.
|
|
158
|
+
* - The Noydb instance is constructed lazily on first store-with-noydb
|
|
159
|
+
* instantiation, then memoized for the lifetime of the Pinia app.
|
|
160
|
+
* This means apps that don't actually use any noydb-augmented stores
|
|
161
|
+
* pay zero crypto cost.
|
|
162
|
+
* - `secret` is a function so the passphrase can come from a prompt,
|
|
163
|
+
* biometric unlock, or session token — never stored in config.
|
|
164
|
+
* - The plugin sets `store.$noydbReady` (a `Promise<void>`) and
|
|
165
|
+
* `store.$noydbError` (an `Error | null`) on every augmented store
|
|
166
|
+
* so components can await hydration and surface failures.
|
|
167
|
+
*/
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Per-store NOYDB configuration. Attached to a Pinia store via the `noydb`
|
|
171
|
+
* option inside `defineStore({ ..., noydb: {...} })`.
|
|
172
|
+
*
|
|
173
|
+
* `persist` selects which top-level state keys to mirror into NOYDB.
|
|
174
|
+
* Pass a single key, an array of keys, or `'*'` to mirror the entire state.
|
|
175
|
+
*/
|
|
176
|
+
interface StoreNoydbOptions<S extends StateTree = StateTree> {
|
|
177
|
+
/** Compartment (tenant) name. */
|
|
178
|
+
compartment: string;
|
|
179
|
+
/** Collection name within the compartment. */
|
|
180
|
+
collection: string;
|
|
181
|
+
/**
|
|
182
|
+
* Which state keys to persist. Defaults to `'*'` (the entire state object).
|
|
183
|
+
* Pass a string or string[] to scope to specific keys.
|
|
184
|
+
*/
|
|
185
|
+
persist?: keyof S | (keyof S)[] | '*';
|
|
186
|
+
/**
|
|
187
|
+
* Optional schema validator applied at the document level (the persisted
|
|
188
|
+
* subset of state, not individual records). Throws if validation fails on
|
|
189
|
+
* hydration — the store stays at its initial state and `$noydbError` is set.
|
|
190
|
+
*/
|
|
191
|
+
schema?: {
|
|
192
|
+
parse: (input: unknown) => unknown;
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Configuration for `createNoydbPiniaPlugin`. Mirrors `NoydbOptions` but
|
|
197
|
+
* makes `secret` a function so the passphrase can come from a prompt
|
|
198
|
+
* rather than being stored in config.
|
|
199
|
+
*/
|
|
200
|
+
interface NoydbPiniaPluginOptions {
|
|
201
|
+
/** The NOYDB adapter to use for persistence. */
|
|
202
|
+
adapter: NoydbAdapter;
|
|
203
|
+
/** User identifier (matches the keyring file). */
|
|
204
|
+
user: string;
|
|
205
|
+
/**
|
|
206
|
+
* Passphrase provider. Called once on first noydb-augmented store
|
|
207
|
+
* instantiation. Return a string or a Promise that resolves to one.
|
|
208
|
+
*/
|
|
209
|
+
secret: () => string | Promise<string>;
|
|
210
|
+
/** Optional Noydb open-options forwarded to `createNoydb`. */
|
|
211
|
+
noydbOptions?: Partial<Omit<NoydbOptions, 'adapter' | 'user' | 'secret'>>;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Create a Pinia plugin that wires NOYDB persistence into any store
|
|
215
|
+
* declaring a `noydb:` option.
|
|
216
|
+
*
|
|
217
|
+
* Returns a `PiniaPlugin` directly usable with `pinia.use(...)`.
|
|
218
|
+
*/
|
|
219
|
+
declare function createNoydbPiniaPlugin(opts: NoydbPiniaPluginOptions): PiniaPlugin;
|
|
220
|
+
declare module 'pinia' {
|
|
221
|
+
interface DefineStoreOptionsBase<S extends StateTree, Store> {
|
|
222
|
+
/**
|
|
223
|
+
* Opt this store into NOYDB persistence via the
|
|
224
|
+
* `createNoydbPiniaPlugin` augmentation plugin.
|
|
225
|
+
*
|
|
226
|
+
* The chosen state keys are encrypted and persisted to the configured
|
|
227
|
+
* compartment + collection on every mutation, and rehydrated on first
|
|
228
|
+
* store access.
|
|
229
|
+
*/
|
|
230
|
+
noydb?: StoreNoydbOptions<S>;
|
|
231
|
+
}
|
|
232
|
+
interface PiniaCustomProperties {
|
|
233
|
+
/**
|
|
234
|
+
* Resolves once this store has finished its initial hydration from
|
|
235
|
+
* NOYDB. `undefined` for stores that don't declare a `noydb:` option.
|
|
236
|
+
*/
|
|
237
|
+
$noydbReady?: Promise<void>;
|
|
238
|
+
/**
|
|
239
|
+
* Set when hydration or persistence fails. `null` while healthy.
|
|
240
|
+
* Plugins (and devtools) can poll this to surface storage errors.
|
|
241
|
+
*/
|
|
242
|
+
$noydbError?: Error | null;
|
|
243
|
+
/**
|
|
244
|
+
* `true` if this store opted into NOYDB persistence via the `noydb:`
|
|
245
|
+
* option, `false` otherwise. Useful for debugging and devtools.
|
|
246
|
+
*/
|
|
247
|
+
$noydbAugmented?: boolean;
|
|
248
|
+
/**
|
|
249
|
+
* Wait for all in-flight encrypted persistence puts to complete.
|
|
250
|
+
* Useful in tests for deterministic flushing, and in app code before
|
|
251
|
+
* unmounting components that just mutated the store.
|
|
252
|
+
*/
|
|
253
|
+
$noydbFlush?: () => Promise<void>;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export { type NoydbPiniaPluginOptions, type NoydbStore, type NoydbStoreOptions, type StoreNoydbOptions, createNoydbPiniaPlugin, defineNoydbStore, getActiveNoydb, resolveNoydb, setActiveNoydb };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// src/defineNoydbStore.ts
|
|
2
|
+
import { defineStore } from "pinia";
|
|
3
|
+
import { computed, shallowRef } from "vue";
|
|
4
|
+
|
|
5
|
+
// src/context.ts
|
|
6
|
+
var activeInstance = null;
|
|
7
|
+
function setActiveNoydb(instance) {
|
|
8
|
+
activeInstance = instance;
|
|
9
|
+
}
|
|
10
|
+
function getActiveNoydb() {
|
|
11
|
+
return activeInstance;
|
|
12
|
+
}
|
|
13
|
+
function resolveNoydb(explicit) {
|
|
14
|
+
if (explicit) return explicit;
|
|
15
|
+
if (activeInstance) return activeInstance;
|
|
16
|
+
throw new Error(
|
|
17
|
+
"@noy-db/pinia: no Noydb instance bound.\n Option A \u2014 pass `noydb:` directly to defineNoydbStore({...})\n Option B \u2014 call setActiveNoydb(instance) once at app startup\n Option C \u2014 install the @noy-db/nuxt module (Nuxt 4+)"
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// src/defineNoydbStore.ts
|
|
22
|
+
function defineNoydbStore(id, options) {
|
|
23
|
+
const collectionName = options.collection ?? id;
|
|
24
|
+
const prefetch = options.prefetch ?? true;
|
|
25
|
+
return defineStore(id, () => {
|
|
26
|
+
const items = shallowRef([]);
|
|
27
|
+
const count = computed(() => items.value.length);
|
|
28
|
+
let cachedCompartment = null;
|
|
29
|
+
let cachedCollection = null;
|
|
30
|
+
async function getCollection() {
|
|
31
|
+
if (cachedCollection) return cachedCollection;
|
|
32
|
+
const noydb = resolveNoydb(options.noydb ?? null);
|
|
33
|
+
cachedCompartment = await noydb.openCompartment(options.compartment);
|
|
34
|
+
cachedCollection = cachedCompartment.collection(collectionName);
|
|
35
|
+
return cachedCollection;
|
|
36
|
+
}
|
|
37
|
+
async function refresh() {
|
|
38
|
+
const c = await getCollection();
|
|
39
|
+
const list = await c.list();
|
|
40
|
+
items.value = list;
|
|
41
|
+
}
|
|
42
|
+
function byId(id2) {
|
|
43
|
+
for (const item of items.value) {
|
|
44
|
+
if (item.id === id2) return item;
|
|
45
|
+
}
|
|
46
|
+
return void 0;
|
|
47
|
+
}
|
|
48
|
+
async function add(id2, record) {
|
|
49
|
+
const validated = options.schema ? options.schema.parse(record) : record;
|
|
50
|
+
const c = await getCollection();
|
|
51
|
+
await c.put(id2, validated);
|
|
52
|
+
items.value = await c.list();
|
|
53
|
+
}
|
|
54
|
+
async function update(id2, record) {
|
|
55
|
+
await add(id2, record);
|
|
56
|
+
}
|
|
57
|
+
async function remove(id2) {
|
|
58
|
+
const c = await getCollection();
|
|
59
|
+
await c.delete(id2);
|
|
60
|
+
items.value = await c.list();
|
|
61
|
+
}
|
|
62
|
+
function query() {
|
|
63
|
+
if (!cachedCollection) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
"@noy-db/pinia: query() called before the store was ready. Await store.$ready first, or set prefetch: true (default)."
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
return cachedCollection.query();
|
|
69
|
+
}
|
|
70
|
+
const $ready = prefetch ? refresh() : Promise.resolve();
|
|
71
|
+
return {
|
|
72
|
+
items,
|
|
73
|
+
count,
|
|
74
|
+
$ready,
|
|
75
|
+
byId,
|
|
76
|
+
add,
|
|
77
|
+
update,
|
|
78
|
+
remove,
|
|
79
|
+
refresh,
|
|
80
|
+
query
|
|
81
|
+
};
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// src/plugin.ts
|
|
86
|
+
import { createNoydb } from "@noy-db/core";
|
|
87
|
+
var STATE_DOC_ID = "__state__";
|
|
88
|
+
function createNoydbPiniaPlugin(opts) {
|
|
89
|
+
let dbPromise = null;
|
|
90
|
+
function getDb() {
|
|
91
|
+
if (!dbPromise) {
|
|
92
|
+
dbPromise = (async () => {
|
|
93
|
+
const secret = await opts.secret();
|
|
94
|
+
return createNoydb({
|
|
95
|
+
adapter: opts.adapter,
|
|
96
|
+
user: opts.user,
|
|
97
|
+
secret,
|
|
98
|
+
...opts.noydbOptions
|
|
99
|
+
});
|
|
100
|
+
})();
|
|
101
|
+
}
|
|
102
|
+
return dbPromise;
|
|
103
|
+
}
|
|
104
|
+
const compartmentCache = /* @__PURE__ */ new Map();
|
|
105
|
+
function getCompartment(name) {
|
|
106
|
+
let p = compartmentCache.get(name);
|
|
107
|
+
if (!p) {
|
|
108
|
+
p = getDb().then((db) => db.openCompartment(name));
|
|
109
|
+
compartmentCache.set(name, p);
|
|
110
|
+
}
|
|
111
|
+
return p;
|
|
112
|
+
}
|
|
113
|
+
return (context) => {
|
|
114
|
+
const noydbOption = context.options.noydb;
|
|
115
|
+
if (!noydbOption) {
|
|
116
|
+
context.store.$noydbAugmented = false;
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
context.store.$noydbAugmented = true;
|
|
120
|
+
context.store.$noydbError = null;
|
|
121
|
+
const pending = /* @__PURE__ */ new Set();
|
|
122
|
+
const ready = (async () => {
|
|
123
|
+
try {
|
|
124
|
+
const compartment = await getCompartment(noydbOption.compartment);
|
|
125
|
+
const collection = compartment.collection(
|
|
126
|
+
noydbOption.collection
|
|
127
|
+
);
|
|
128
|
+
const persisted = await collection.get(STATE_DOC_ID);
|
|
129
|
+
if (persisted) {
|
|
130
|
+
const validated = noydbOption.schema ? noydbOption.schema.parse(persisted) : persisted;
|
|
131
|
+
const picked = pickKeys(validated, noydbOption.persist);
|
|
132
|
+
context.store.$patch(picked);
|
|
133
|
+
}
|
|
134
|
+
context.store.$subscribe(
|
|
135
|
+
(_mutation, state) => {
|
|
136
|
+
const subset = pickKeys(state, noydbOption.persist);
|
|
137
|
+
const p = collection.put(STATE_DOC_ID, subset).catch((err) => {
|
|
138
|
+
context.store.$noydbError = err instanceof Error ? err : new Error(String(err));
|
|
139
|
+
}).finally(() => {
|
|
140
|
+
pending.delete(p);
|
|
141
|
+
});
|
|
142
|
+
pending.add(p);
|
|
143
|
+
},
|
|
144
|
+
{ detached: true }
|
|
145
|
+
// outlive the component that triggered the mutation
|
|
146
|
+
);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
context.store.$noydbError = err instanceof Error ? err : new Error(String(err));
|
|
149
|
+
}
|
|
150
|
+
})();
|
|
151
|
+
context.store.$noydbReady = ready;
|
|
152
|
+
context.store.$noydbFlush = async () => {
|
|
153
|
+
await ready;
|
|
154
|
+
while (pending.size > 0) {
|
|
155
|
+
await Promise.all([...pending]);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
function pickKeys(state, persist) {
|
|
161
|
+
if (persist === void 0 || persist === "*") {
|
|
162
|
+
return { ...state };
|
|
163
|
+
}
|
|
164
|
+
if (typeof persist === "string") {
|
|
165
|
+
return { [persist]: state[persist] };
|
|
166
|
+
}
|
|
167
|
+
if (Array.isArray(persist)) {
|
|
168
|
+
const out = {};
|
|
169
|
+
for (const key of persist) {
|
|
170
|
+
out[key] = state[key];
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
return { ...state };
|
|
175
|
+
}
|
|
176
|
+
export {
|
|
177
|
+
createNoydbPiniaPlugin,
|
|
178
|
+
defineNoydbStore,
|
|
179
|
+
getActiveNoydb,
|
|
180
|
+
resolveNoydb,
|
|
181
|
+
setActiveNoydb
|
|
182
|
+
};
|
|
183
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/defineNoydbStore.ts","../src/context.ts","../src/plugin.ts"],"sourcesContent":["/**\n * `defineNoydbStore` — drop-in `defineStore` that wires a Pinia store to a\n * NOYDB compartment + collection.\n *\n * Returned store exposes:\n * - `items` — reactive array of all records\n * - `byId(id)` — O(1) lookup\n * - `count` — reactive count getter\n * - `add(id, rec)` — encrypt + persist + update reactive state\n * - `update(id, rec)` — same as add (Collection.put is upsert)\n * - `remove(id)` — delete + update reactive state\n * - `refresh()` — re-hydrate from the adapter\n * - `query()` — chainable query DSL bound to the store\n * - `$ready` — Promise<void> resolved on first hydration\n *\n * Compatible with `storeToRefs`, Vue Devtools, SSR, and pinia plugins.\n */\n\nimport { defineStore } from 'pinia'\nimport { computed, shallowRef, type Ref, type ComputedRef } from 'vue'\nimport type { Noydb, Compartment, Collection, Query } from '@noy-db/core'\nimport { resolveNoydb } from './context.js'\n\n/**\n * Options accepted by `defineNoydbStore`.\n *\n * Generic `T` is the record shape — defaults to `unknown` if the caller\n * doesn't supply a type. Use `defineNoydbStore<Invoice>('invoices', {...})`\n * for full type safety.\n */\nexport interface NoydbStoreOptions<T> {\n /** Compartment (tenant) name. */\n compartment: string\n /** Collection name within the compartment. Defaults to the store id. */\n collection?: string\n /**\n * Optional explicit Noydb instance. If omitted, the store resolves the\n * globally bound instance via `getActiveNoydb()`.\n */\n noydb?: Noydb | null\n /**\n * If true (default), hydration kicks off immediately when the store is\n * first instantiated. If false, hydration is deferred until the first\n * call to `refresh()` or any read accessor.\n */\n prefetch?: boolean\n /**\n * Optional schema validator. Any object exposing a `parse(input): T`\n * method (Zod, Valibot, ArkType, etc.) is accepted.\n */\n schema?: { parse: (input: unknown) => T }\n}\n\n/**\n * The runtime shape of the store returned by `defineNoydbStore`.\n * Exposed as a public type so consumers can write `useStore: ReturnType<typeof useInvoices>`.\n */\nexport interface NoydbStore<T> {\n items: Ref<T[]>\n count: ComputedRef<number>\n $ready: Promise<void>\n byId(id: string): T | undefined\n add(id: string, record: T): Promise<void>\n update(id: string, record: T): Promise<void>\n remove(id: string): Promise<void>\n refresh(): Promise<void>\n query(): Query<T>\n}\n\n/**\n * Define a Pinia store that's wired to a NOYDB collection.\n *\n * Generic T defaults to `unknown` — pass `<MyType>` for full type inference.\n *\n * @example\n * ```ts\n * import { defineNoydbStore } from '@noy-db/pinia';\n *\n * export const useInvoices = defineNoydbStore<Invoice>('invoices', {\n * compartment: 'C101',\n * schema: InvoiceSchema, // optional\n * });\n * ```\n */\nexport function defineNoydbStore<T>(\n id: string,\n options: NoydbStoreOptions<T>,\n) {\n const collectionName = options.collection ?? id\n const prefetch = options.prefetch ?? true\n\n return defineStore(id, () => {\n // Reactive state. shallowRef on items because the array reference is what\n // changes — replacing it triggers reactivity without per-record proxying.\n const items: Ref<T[]> = shallowRef<T[]>([])\n const count = computed(() => items.value.length)\n\n // Lazy collection handle — created on first hydrate.\n let cachedCompartment: Compartment | null = null\n let cachedCollection: Collection<T> | null = null\n\n async function getCollection(): Promise<Collection<T>> {\n if (cachedCollection) return cachedCollection\n const noydb = resolveNoydb(options.noydb ?? null)\n cachedCompartment = await noydb.openCompartment(options.compartment)\n cachedCollection = cachedCompartment.collection<T>(collectionName)\n return cachedCollection\n }\n\n async function refresh(): Promise<void> {\n const c = await getCollection()\n const list = await c.list()\n items.value = list\n }\n\n function byId(id: string): T | undefined {\n // Linear scan against the reactive cache. Index-aware lookups land in #13.\n // Optimization opportunity: maintain a Map<string, T> alongside items.\n for (const item of items.value) {\n if ((item as { id?: string }).id === id) return item\n }\n return undefined\n }\n\n async function add(id: string, record: T): Promise<void> {\n const validated = options.schema ? options.schema.parse(record) : record\n const c = await getCollection()\n await c.put(id, validated)\n // Re-list to pick up the new record. Cheaper alternative would be to\n // splice into items.value directly, but list() ensures consistency\n // with the underlying cache.\n items.value = await c.list()\n }\n\n async function update(id: string, record: T): Promise<void> {\n // Collection.put is upsert; this is just a more readable alias.\n await add(id, record)\n }\n\n async function remove(id: string): Promise<void> {\n const c = await getCollection()\n await c.delete(id)\n items.value = await c.list()\n }\n\n function query(): Query<T> {\n // Synchronous query() requires the collection to be hydrated.\n // The lazy refresh() in $ready handles that — but if the user calls\n // query() before $ready resolves, the collection still works because\n // Collection.query() reads from its own internal cache (which Noydb\n // hydrates lazily as well).\n if (!cachedCollection) {\n throw new Error(\n '@noy-db/pinia: query() called before the store was ready. ' +\n 'Await store.$ready first, or set prefetch: true (default).',\n )\n }\n return cachedCollection.query()\n }\n\n // Kick off hydration. The promise is exposed as $ready so components\n // can `await store.$ready` before rendering data-dependent UI.\n const $ready: Promise<void> = prefetch\n ? refresh()\n : Promise.resolve()\n\n return {\n items,\n count,\n $ready,\n byId,\n add,\n update,\n remove,\n refresh,\n query,\n }\n })\n}\n","/**\n * Active NOYDB instance binding.\n *\n * `defineNoydbStore` resolves the `Noydb` instance from one of three places,\n * in priority order:\n *\n * 1. The store options' explicit `noydb:` field (highest precedence — useful\n * for tests and multi-database apps).\n * 2. A globally bound instance set via `setActiveNoydb()` — this is what the\n * Nuxt module's runtime plugin and playground apps use.\n * 3. Throws a clear error if neither is set.\n *\n * Keeping the binding pluggable means tests can pass an instance directly\n * without polluting global state.\n */\n\nimport type { Noydb } from '@noy-db/core'\n\nlet activeInstance: Noydb | null = null\n\n/** Bind a Noydb instance globally. Called by the Nuxt module / app plugin. */\nexport function setActiveNoydb(instance: Noydb | null): void {\n activeInstance = instance\n}\n\n/** Returns the globally bound Noydb instance, or null if none. */\nexport function getActiveNoydb(): Noydb | null {\n return activeInstance\n}\n\n/**\n * Resolve the Noydb instance to use for a store. Throws if no instance is\n * bound — the error message points the developer at the three options.\n */\nexport function resolveNoydb(explicit?: Noydb | null): Noydb {\n if (explicit) return explicit\n if (activeInstance) return activeInstance\n throw new Error(\n '@noy-db/pinia: no Noydb instance bound.\\n' +\n ' Option A — pass `noydb:` directly to defineNoydbStore({...})\\n' +\n ' Option B — call setActiveNoydb(instance) once at app startup\\n' +\n ' Option C — install the @noy-db/nuxt module (Nuxt 4+)',\n )\n}\n","/**\n * `createNoydbPiniaPlugin` — augmentation path for existing Pinia stores.\n *\n * Lets a developer take any existing `defineStore()` call and opt into NOYDB\n * persistence by adding a single `noydb:` option, without touching component\n * code. The plugin watches the chosen state key(s), encrypts on change, syncs\n * to a NOYDB collection, and rehydrates on store init.\n *\n * @example\n * ```ts\n * import { createPinia } from 'pinia';\n * import { createNoydbPiniaPlugin } from '@noy-db/pinia';\n * import { jsonFile } from '@noy-db/file';\n *\n * const pinia = createPinia();\n * pinia.use(createNoydbPiniaPlugin({\n * adapter: jsonFile({ dir: './data' }),\n * user: 'owner-01',\n * secret: () => promptPassphrase(),\n * }));\n *\n * // existing store — add one option, no component changes:\n * export const useClients = defineStore('clients', {\n * state: () => ({ list: [] as Client[] }),\n * noydb: { compartment: 'C101', collection: 'clients', persist: 'list' },\n * });\n * ```\n *\n * Design notes\n * ------------\n * - Each augmented store persists a SINGLE document at id `__state__`\n * containing the picked keys. We don't try to map state arrays onto\n * per-element records — that's `defineNoydbStore`'s territory.\n * - The Noydb instance is constructed lazily on first store-with-noydb\n * instantiation, then memoized for the lifetime of the Pinia app.\n * This means apps that don't actually use any noydb-augmented stores\n * pay zero crypto cost.\n * - `secret` is a function so the passphrase can come from a prompt,\n * biometric unlock, or session token — never stored in config.\n * - The plugin sets `store.$noydbReady` (a `Promise<void>`) and\n * `store.$noydbError` (an `Error | null`) on every augmented store\n * so components can await hydration and surface failures.\n */\n\nimport type { PiniaPluginContext, PiniaPlugin, StateTree } from 'pinia'\nimport { createNoydb, type Noydb, type NoydbOptions, type NoydbAdapter, type Compartment, type Collection } from '@noy-db/core'\n\n/**\n * Per-store NOYDB configuration. Attached to a Pinia store via the `noydb`\n * option inside `defineStore({ ..., noydb: {...} })`.\n *\n * `persist` selects which top-level state keys to mirror into NOYDB.\n * Pass a single key, an array of keys, or `'*'` to mirror the entire state.\n */\nexport interface StoreNoydbOptions<S extends StateTree = StateTree> {\n /** Compartment (tenant) name. */\n compartment: string\n /** Collection name within the compartment. */\n collection: string\n /**\n * Which state keys to persist. Defaults to `'*'` (the entire state object).\n * Pass a string or string[] to scope to specific keys.\n */\n persist?: keyof S | (keyof S)[] | '*'\n /**\n * Optional schema validator applied at the document level (the persisted\n * subset of state, not individual records). Throws if validation fails on\n * hydration — the store stays at its initial state and `$noydbError` is set.\n */\n schema?: { parse: (input: unknown) => unknown }\n}\n\n/**\n * Configuration for `createNoydbPiniaPlugin`. Mirrors `NoydbOptions` but\n * makes `secret` a function so the passphrase can come from a prompt\n * rather than being stored in config.\n */\nexport interface NoydbPiniaPluginOptions {\n /** The NOYDB adapter to use for persistence. */\n adapter: NoydbAdapter\n /** User identifier (matches the keyring file). */\n user: string\n /**\n * Passphrase provider. Called once on first noydb-augmented store\n * instantiation. Return a string or a Promise that resolves to one.\n */\n secret: () => string | Promise<string>\n /** Optional Noydb open-options forwarded to `createNoydb`. */\n noydbOptions?: Partial<Omit<NoydbOptions, 'adapter' | 'user' | 'secret'>>\n}\n\n// The fixed document id under which a store's persisted state lives. Using a\n// reserved prefix so it can't collide with any user-chosen record id.\nconst STATE_DOC_ID = '__state__'\n\n/**\n * Create a Pinia plugin that wires NOYDB persistence into any store\n * declaring a `noydb:` option.\n *\n * Returns a `PiniaPlugin` directly usable with `pinia.use(...)`.\n */\nexport function createNoydbPiniaPlugin(opts: NoydbPiniaPluginOptions): PiniaPlugin {\n // Single Noydb instance shared across all augmented stores in this Pinia\n // app. Created lazily on first use so apps that never instantiate a\n // noydb-augmented store pay zero crypto cost.\n let dbPromise: Promise<Noydb> | null = null\n function getDb(): Promise<Noydb> {\n if (!dbPromise) {\n dbPromise = (async (): Promise<Noydb> => {\n const secret = await opts.secret()\n return createNoydb({\n adapter: opts.adapter,\n user: opts.user,\n secret,\n ...opts.noydbOptions,\n })\n })()\n }\n return dbPromise\n }\n\n // Compartment cache so opening a compartment is a one-time cost per app.\n const compartmentCache = new Map<string, Promise<Compartment>>()\n function getCompartment(name: string): Promise<Compartment> {\n let p = compartmentCache.get(name)\n if (!p) {\n p = getDb().then((db) => db.openCompartment(name))\n compartmentCache.set(name, p)\n }\n return p\n }\n\n return (context: PiniaPluginContext) => {\n // Pinia stores can declare arbitrary options on `defineStore`, but the\n // plugin context only exposes them via `context.options`. Pull our\n // `noydb` option out and bail early if it's not present — that's\n // the \"store is untouched\" path for non-augmented stores.\n const noydbOption = (context.options as { noydb?: StoreNoydbOptions }).noydb\n if (!noydbOption) {\n // Mark the store as opted-out so devtools / consumers can detect it.\n context.store.$noydbAugmented = false\n return\n }\n\n context.store.$noydbAugmented = true\n context.store.$noydbError = null as Error | null\n\n // Track in-flight persistence promises so tests (and consumers) can\n // await deterministic flushes via `$noydbFlush()`. Plain Set-of-Promises\n // — entries auto-remove on settle.\n const pending = new Set<Promise<void>>()\n\n // Hydrate-then-subscribe. Both happen inside an async closure so the\n // store can be awaited via `$noydbReady`.\n const ready = (async (): Promise<void> => {\n try {\n const compartment = await getCompartment(noydbOption.compartment)\n const collection: Collection<StateTree> = compartment.collection<StateTree>(\n noydbOption.collection,\n )\n\n // 1. Hydration: read the persisted document (if any) and apply\n // the picked keys onto the store's current state. We use\n // `$patch` so reactivity fires correctly.\n const persisted = await collection.get(STATE_DOC_ID)\n if (persisted) {\n const validated = noydbOption.schema\n ? (noydbOption.schema.parse(persisted) as StateTree)\n : persisted\n const picked = pickKeys(validated, noydbOption.persist)\n context.store.$patch(picked)\n }\n\n // 2. Subscribe: every state mutation triggers an encrypted write\n // of the picked subset back to NOYDB. The subscription captures\n // `collection` so it doesn't re-resolve on every event.\n context.store.$subscribe(\n (_mutation, state) => {\n const subset = pickKeys(state, noydbOption.persist)\n const p = collection.put(STATE_DOC_ID, subset)\n .catch((err: unknown) => {\n context.store.$noydbError = err instanceof Error ? err : new Error(String(err))\n })\n .finally(() => {\n pending.delete(p)\n })\n pending.add(p)\n },\n { detached: true }, // outlive the component that triggered the mutation\n )\n } catch (err) {\n context.store.$noydbError = err instanceof Error ? err : new Error(String(err))\n }\n })()\n\n context.store.$noydbReady = ready\n /**\n * Wait for all in-flight persistence puts to settle. Use this in tests\n * to deterministically observe the encrypted state on the adapter, and\n * in app code before unmounting components that mutated the store.\n */\n context.store.$noydbFlush = async (): Promise<void> => {\n await ready\n // Snapshot the current pending set; new puts added during await\n // are picked up by the next $noydbFlush() call.\n while (pending.size > 0) {\n await Promise.all([...pending])\n }\n }\n }\n}\n\n/**\n * Pick the configured subset of keys from a state object.\n *\n * Behaviors:\n * - `undefined` or `'*'` → returns the entire state shallow-copied\n * - single key string → returns `{ [key]: state[key] }`\n * - key array → returns `{ [k1]: state[k1], [k2]: state[k2], ... }`\n *\n * The result is always a fresh object so callers can mutate it without\n * touching the store's reactive state.\n */\nfunction pickKeys(state: StateTree, persist: StoreNoydbOptions['persist']): StateTree {\n if (persist === undefined || persist === '*') {\n return { ...state }\n }\n if (typeof persist === 'string') {\n return { [persist]: state[persist] } as StateTree\n }\n if (Array.isArray(persist)) {\n const out: StateTree = {}\n for (const key of persist) {\n out[key as string] = state[key as string]\n }\n return out\n }\n // Should be unreachable thanks to the type, but defensive default.\n return { ...state }\n}\n\n// ─── Pinia module augmentation ─────────────────────────────────────\n//\n// Pinia exposes `DefineStoreOptionsBase` as the place where third-party\n// plugins are expected to attach their custom option types. Augmenting it\n// here means `defineStore('x', { ..., noydb: {...} })` autocompletes inside\n// the IDE and type-checks correctly without forcing users to import\n// anything from `@noy-db/pinia`.\n//\n// We also augment `PiniaCustomProperties` so the runtime fields we add to\n// every store (`$noydbReady`, `$noydbError`, `$noydbAugmented`) are typed.\n\ndeclare module 'pinia' {\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n export interface DefineStoreOptionsBase<S extends StateTree, Store> {\n /**\n * Opt this store into NOYDB persistence via the\n * `createNoydbPiniaPlugin` augmentation plugin.\n *\n * The chosen state keys are encrypted and persisted to the configured\n * compartment + collection on every mutation, and rehydrated on first\n * store access.\n */\n noydb?: StoreNoydbOptions<S>\n }\n\n export interface PiniaCustomProperties {\n /**\n * Resolves once this store has finished its initial hydration from\n * NOYDB. `undefined` for stores that don't declare a `noydb:` option.\n */\n $noydbReady?: Promise<void>\n /**\n * Set when hydration or persistence fails. `null` while healthy.\n * Plugins (and devtools) can poll this to surface storage errors.\n */\n $noydbError?: Error | null\n /**\n * `true` if this store opted into NOYDB persistence via the `noydb:`\n * option, `false` otherwise. Useful for debugging and devtools.\n */\n $noydbAugmented?: boolean\n /**\n * Wait for all in-flight encrypted persistence puts to complete.\n * Useful in tests for deterministic flushing, and in app code before\n * unmounting components that just mutated the store.\n */\n $noydbFlush?: () => Promise<void>\n }\n}\n"],"mappings":"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","names":["id"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@noy-db/pinia",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Pinia integration for noy-db — defineNoydbStore() and the augmentation plugin for existing stores",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "vLannaAi <vicio@lanna.ai>",
|
|
7
|
+
"homepage": "https://github.com/vLannaAi/noy-db/tree/main/packages/pinia#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/vLannaAi/noy-db.git",
|
|
11
|
+
"directory": "packages/pinia"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/vLannaAi/noy-db/issues"
|
|
15
|
+
},
|
|
16
|
+
"type": "module",
|
|
17
|
+
"sideEffects": false,
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"import": {
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"default": "./dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"require": {
|
|
25
|
+
"types": "./dist/index.d.cts",
|
|
26
|
+
"default": "./dist/index.cjs"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"main": "./dist/index.cjs",
|
|
31
|
+
"module": "./dist/index.js",
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"files": [
|
|
34
|
+
"dist",
|
|
35
|
+
"README.md",
|
|
36
|
+
"LICENSE"
|
|
37
|
+
],
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=20.0.0"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"pinia": "^2.1.0 || ^3.0.0",
|
|
43
|
+
"vue": "^3.4.0",
|
|
44
|
+
"@noy-db/core": "0.3.0"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@vue/test-utils": "^2.4.6",
|
|
48
|
+
"happy-dom": "^15.11.7",
|
|
49
|
+
"pinia": "^3.0.1",
|
|
50
|
+
"vue": "^3.5.32",
|
|
51
|
+
"@noy-db/core": "0.3.0"
|
|
52
|
+
},
|
|
53
|
+
"keywords": [
|
|
54
|
+
"noy-db",
|
|
55
|
+
"pinia",
|
|
56
|
+
"vue",
|
|
57
|
+
"vue3",
|
|
58
|
+
"nuxt",
|
|
59
|
+
"store",
|
|
60
|
+
"encryption",
|
|
61
|
+
"zero-knowledge",
|
|
62
|
+
"offline-first"
|
|
63
|
+
],
|
|
64
|
+
"scripts": {
|
|
65
|
+
"build": "tsup",
|
|
66
|
+
"test": "vitest run",
|
|
67
|
+
"lint": "eslint src/",
|
|
68
|
+
"typecheck": "tsc --noEmit"
|
|
69
|
+
}
|
|
70
|
+
}
|