pinia-plugin-subscription 0.2.0-beta.0 → 0.2.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.
@@ -0,0 +1,408 @@
1
+ # Plugin authoring guide
2
+
3
+ Guide for **plugin authors** building their own pinia plugin on top of
4
+ `pinia-plugin-subscription`.
5
+
6
+ If you only want to **use** compatible plugins in an application, read
7
+ [Using compatible plugins](using-plugins.md) instead.
8
+
9
+ > **Documentation convention:** JavaScript examples come first and are
10
+ > complete and runnable as-is. TypeScript versions follow, adding type
11
+ > annotations and generics. Types are always optional at runtime — they only
12
+ > add autocompletion and compile-time checks.
13
+
14
+ ## Mental model
15
+
16
+ - A **subscriber** is invoked by the central `PluginSubscription` engine
17
+ each time pinia registers a store.
18
+ - A subscriber can extend the store, register callbacks (mutation
19
+ subscriptions, action subscriptions, reset callbacks), and declare an
20
+ execution policy for SSR.
21
+ - `createPlugin([...subscribers])` aggregates subscribers into a single
22
+ pinia plugin.
23
+
24
+ Two authoring styles are available:
25
+
26
+ 1. **Plain object** implementing `PluginSubscriberInterface` — lightweight,
27
+ no classes.
28
+ 2. **`PluginSubscriber` abstract class + `Store` subclass** — recommended
29
+ for richer plugins: the `Store` subclass wraps the pinia store with
30
+ helpers (state extension, subscriptions, debug logging).
31
+
32
+ ## Your first subscriber: a plain object
33
+
34
+ ### JavaScript
35
+
36
+ ```js
37
+ export const myStoreSubscriber = {
38
+ name: 'my-plugin',
39
+
40
+ invoke: (context, debug) => {
41
+ // context contains `store`, `options`, `pinia`
42
+ console.log('store registered', context.store.$id)
43
+
44
+ return true
45
+ },
46
+
47
+ resetStoreCallback: (store) => {
48
+ console.log('store reset:', store.$id)
49
+ }
50
+ }
51
+ ```
52
+
53
+ ### TypeScript
54
+
55
+ ```ts
56
+ import type { PluginSubscriberInterface } from 'pinia-plugin-subscription'
57
+
58
+ export const myStoreSubscriber: PluginSubscriberInterface = {
59
+ name: 'my-plugin',
60
+
61
+ invoke: (context, debug) => {
62
+ console.log('store registered', context.store.$id)
63
+
64
+ return true
65
+ },
66
+
67
+ resetStoreCallback: (store) => {
68
+ console.log('store reset:', store.$id)
69
+ }
70
+ }
71
+ ```
72
+
73
+ The host app registers subscribers through `createPlugin()`:
74
+
75
+ ```js
76
+ import { createPlugin } from 'pinia-plugin-subscription'
77
+
78
+ pinia.use(createPlugin([myStoreSubscriber]))
79
+ ```
80
+
81
+ ## `PluginSubscriberInterface` reference
82
+
83
+ | Member | Type | Description |
84
+ | --- | --- | --- |
85
+ | `name` (required) | `string` | Unique plugin name; also used by the debug filter. |
86
+ | `invoke` (required) | `(context: PiniaPluginContext, debug: boolean) => boolean` | Called when a store is registered. Return `false` to skip the store. |
87
+ | `execution` | `PluginExecutionOptions` | Where/when the subscriber runs — see [Execution policy & SSR](#execution-policy--ssr). |
88
+ | `hydrationScheduler` | `(run: () => void) => void` | Custom scheduler used when hydration is deferred. |
89
+ | `hydrate` | `(context, debug) => void \| Promise<void>` | SSR-safe initialization hook; guaranteed to run before `afterHydration`. |
90
+ | `afterHydration` | `(context, debug) => void \| Promise<void>` | Post-hydration lifecycle hook. |
91
+ | `resetStoreCallback` | `(store?: Store) => void` | Custom logic executed when a registered store is reset. |
92
+ | `storeOnActionSubscription` | `{ store, callback }` (getter) | Action subscription forwarded to pinia's `$onAction`. |
93
+ | `storeMutationSubscription` | `{ store, callback }` (getter) | Mutation subscription forwarded to pinia's `$subscribe`. |
94
+ | `subscriptions` | `Record<string, PluginSubscription>` | Plugin-specific subscription functions exposed to other plugins. |
95
+ | `console` | `Console` | Custom console used for debug logging. |
96
+
97
+ All members except `name` and `invoke` are optional.
98
+
99
+ ## The `PluginSubscriber` abstract class
100
+
101
+ For class-based plugins, extend `PluginSubscriber<Instance>`. The subscriber
102
+ creates the instance through a factory — conventionally
103
+ `MyPlugin.customizeStore.bind(MyPlugin)`.
104
+
105
+ ### Type parameter (TypeScript only)
106
+
107
+ | Generic | Constraint | Role |
108
+ | --- | --- | --- |
109
+ | `Instance` | `extends Store` | The `Store` subclass your plugin creates for each registered store. |
110
+
111
+ The generic parameter is optional — JavaScript code and untyped TypeScript
112
+ plugins can extend the class without it. Providing it types the
113
+ `storeInstance` getter, so overridden hooks (`hydrate()`,
114
+ `afterHydration()`, `pluginCreated`) get full autocompletion and compile-time
115
+ checks when they call your plugin's custom methods.
116
+
117
+ Constructor:
118
+
119
+ ```ts
120
+ constructor(pluginName: string, createInstance: CreateInstance, pluginConsole?: Console)
121
+ ```
122
+
123
+ | Member | Kind | Description |
124
+ | --- | --- | --- |
125
+ | `execution` | public field | Execution policy (see below). |
126
+ | `hydrationScheduler` | public field | Custom scheduler used when hydration is deferred. |
127
+ | `pluginCreated` | protected field | Optional hook called with the pinia store after a successful `invoke`. |
128
+ | `pluginOptions` | getter/setter | Extra options merged into the options passed to your `Store` factory. |
129
+ | `storeInstance` | getter | The `Store` subclass instance created for the current store. |
130
+ | `hydrate()` / `afterHydration()` | methods | Lifecycle stubs to override for SSR-safe initialization. |
131
+ | `invoke()` | method | Implemented for you: creates the instance, wires `subscriptions`, `storeSubscribe` and `onAction`, then calls `pluginCreated`. Returns `false` when the factory creates no instance. |
132
+
133
+ ### JavaScript
134
+
135
+ ```js
136
+ import { PluginSubscriber, Store } from 'pinia-plugin-subscription'
137
+
138
+ class MyPlugin extends Store {
139
+ constructor(store, options, debug = false) {
140
+ super(store, options, debug)
141
+ this.doSomething()
142
+ }
143
+
144
+ doSomething() {
145
+ this.debugLog('doSomething', this.store.$id)
146
+ }
147
+ }
148
+
149
+ class MyPluginSubscriber extends PluginSubscriber {
150
+ constructor() {
151
+ super('my-plugin', (store, options, debug, customConsole) =>
152
+ new MyPlugin(store, options, debug, customConsole))
153
+ }
154
+ }
155
+
156
+ export const myStoreSubscriber = new MyPluginSubscriber()
157
+ ```
158
+
159
+ ### TypeScript
160
+
161
+ ```ts
162
+ import { PluginSubscriber, Store } from 'pinia-plugin-subscription'
163
+
164
+ class MyPlugin extends Store {
165
+ protected override _className: string = 'MyPlugin'
166
+ protected static override _requiredKeys?: string[] = ['my-plugin-option']
167
+
168
+ constructor(store, options, debug = false) {
169
+ super(store, options, debug)
170
+ this.doSomething()
171
+ }
172
+
173
+ doSomething(): void {
174
+ this.debugLog('doSomething', this.store.$id)
175
+ }
176
+ }
177
+
178
+ class MyPluginSubscriber extends PluginSubscriber<MyPlugin> {
179
+ constructor() {
180
+ super('my-plugin', MyPlugin.customizeStore.bind(MyPlugin))
181
+ }
182
+ }
183
+
184
+ export const myStoreSubscriber = new MyPluginSubscriber()
185
+ ```
186
+
187
+ > **Important contract:** `Store.customizeStore()` only creates an instance
188
+ > when the store defines `storeOptions` **and** every key listed in the
189
+ > static `_requiredKeys` is present in those options. When no instance is
190
+ > created, `invoke()` returns `false` and the store is skipped by your
191
+ > plugin. Declare `_requiredKeys = []` to accept every store that defines
192
+ > `storeOptions`.
193
+
194
+ ## The `Store` base class
195
+
196
+ `Store` wraps a pinia store (`PiniaStore`) and exposes:
197
+
198
+ **Properties:** `debug`, `options`, `state`, `store`.
199
+
200
+ **Methods:**
201
+
202
+ | Method | Description |
203
+ | --- | --- |
204
+ | `addToState(name, value?)` | Adds a property to the store state; exposed as a `Ref` for setup-style stores. |
205
+ | `addSubscription(pluginName, subscription, options?)` | Registers a plugin-specific subscription function. |
206
+ | `getSubscriptions()` | Returns registered subscriptions (`undefined` when empty). |
207
+ | `getOption(optionName)` | Reads one key from the store's `StoreOptions`. |
208
+ | `getStatePropertyValue(propertyName)` | Reads a state property, unwrapping refs. |
209
+ | `storeSubscribe` (getter/setter) | Factory for pinia's `$subscribe` (`{ store, callback }`). |
210
+ | `onAction` (getter/setter) | Factory for pinia's `$onAction` (`{ store, callback }`). |
211
+ | `static customizeStore(store, options, debug?)` | Recommended factory; guarded by `_requiredKeys` (see above). |
212
+ | `debugLog(message, args)` | Conditional logging, active in debug mode. |
213
+ | `stateHas(property)` / `storeHas(property)` | Safe `hasOwnProperty` checks on state / store. |
214
+ | `getValue(value)` | Unwraps a ref or returns the value as-is. |
215
+ | `isOptionApi()` | Whether the store uses the options API. |
216
+ | `hydrate()` / `afterHydration()` | Lifecycle stubs for SSR-safe initialization. |
217
+
218
+ ## Execution policy & SSR
219
+
220
+ The source of truth for hydration policy stays on the subscriber itself.
221
+ For example, a client-only plugin declares its execution directly:
222
+
223
+ ### JavaScript
224
+
225
+ ```js
226
+ import { nextTick } from 'vue'
227
+
228
+ export const persistedStateSubscriber = {
229
+ name: 'persisted-state',
230
+ execution: {
231
+ environment: 'client',
232
+ hydration: 'defer',
233
+ },
234
+ hydrationScheduler: (run) => {
235
+ nextTick(() => run())
236
+ },
237
+ invoke: (context, debug) => {
238
+ // ...
239
+ return true
240
+ }
241
+ }
242
+ ```
243
+
244
+ ### TypeScript
245
+
246
+ ```ts
247
+ import { nextTick } from 'vue'
248
+ import type { PluginSubscriberInterface } from 'pinia-plugin-subscription'
249
+
250
+ export const persistedStateSubscriber: PluginSubscriberInterface = {
251
+ name: 'persisted-state',
252
+ execution: {
253
+ environment: 'client',
254
+ hydration: 'defer',
255
+ },
256
+ hydrationScheduler: (run) => {
257
+ nextTick(() => run())
258
+ },
259
+ invoke: (context, debug) => {
260
+ // ...
261
+ return true
262
+ }
263
+ }
264
+ ```
265
+
266
+ Available execution options (`PluginExecutionOptions`):
267
+
268
+ - `environment: 'both' | 'client' | 'server'` — controls where the
269
+ subscriber is allowed to run.
270
+ - `hydration: 'immediate' | 'defer'` — on the client, `defer` schedules
271
+ execution after hydration.
272
+ - `hydrationScheduler` can be provided either on a subscriber or via
273
+ `createHydrationPlugin()` to override the execution timing when needed.
274
+
275
+ The class-based equivalent sets the same policy in the constructor and
276
+ overrides the lifecycle hooks:
277
+
278
+ ```ts
279
+ import { nextTick } from 'vue'
280
+ import { PluginSubscriber } from 'pinia-plugin-subscription'
281
+
282
+ class ExtendingStoreSubscriber extends PluginSubscriber<StoreExtension> {
283
+ constructor() {
284
+ super('extendsPiniaStore', StoreExtension.customizeStore.bind(StoreExtension))
285
+
286
+ // Controls when this subscriber may run and whether it should wait
287
+ // for hydration on the client.
288
+ this.execution = {
289
+ environment: 'client',
290
+ hydration: 'defer',
291
+ }
292
+
293
+ // Optional framework-specific scheduler for deferred execution.
294
+ this.hydrationScheduler = (run) => {
295
+ nextTick(() => run())
296
+ }
297
+
298
+ this.pluginCreated = addStore
299
+ }
300
+
301
+ override hydrate() {
302
+ if (typeof window === 'undefined') {
303
+ return
304
+ }
305
+
306
+ // Safe browser-only initialization, only called when the policy allows it.
307
+ return this.storeInstance?.hydrate?.()
308
+ }
309
+ }
310
+ ```
311
+
312
+ ### SSR / Nuxt best practices
313
+
314
+ `pinia-plugin-subscription` is SSR-safe by design, but the runtime policy
315
+ still needs to match the actual app lifecycle.
316
+
317
+ Use `hydration: 'defer'` when the subscriber depends on browser-only APIs,
318
+ client-side cookies, `localStorage`, DOM access, or a hydrated app state
319
+ that must exist only after the app is mounted.
320
+
321
+ Use `runtimeEnvironment: 'server'` (on `createHydrationPlugin()`) or
322
+ `execution.environment: 'server'` when a subscriber is only valid during
323
+ server-side rendering or should not touch browser-only objects:
324
+
325
+ ```js
326
+ pinia.use(createHydrationPlugin([serverOnlySubscriber], {
327
+ runtimeEnvironment: 'server'
328
+ }))
329
+ ```
330
+
331
+ Avoid browser-only code in stores during build-time SSR. In practice:
332
+
333
+ - do not access `window`, `document`, `localStorage`, or `matchMedia` at
334
+ module scope or during store setup when the code may run on the server;
335
+ - keep browser-specific bootstrap in `hydrate()` or `afterHydration()`;
336
+ - prefer environment-aware guards such as `typeof window !== 'undefined'`;
337
+ - avoid network access or hydration reads before the client is ready,
338
+ unless the code is explicitly intended to run on both environments.
339
+
340
+ A safe pattern:
341
+
342
+ ```js
343
+ const mySubscriber = {
344
+ name: 'safe-browser-access',
345
+ execution: {
346
+ environment: 'client',
347
+ hydration: 'defer',
348
+ },
349
+ hydrate: (context) => {
350
+ // safe browser access here, after hydration is allowed
351
+ const saved = localStorage.getItem('demo')
352
+ if (saved) {
353
+ context.store.$patch({ value: JSON.parse(saved) })
354
+ }
355
+ },
356
+ invoke: () => true,
357
+ }
358
+ ```
359
+
360
+ This keeps the server render deterministic and prevents browser-only
361
+ initialization from crashing Nuxt or SSR builds.
362
+
363
+ ## Typing per-store options: `StoreOptionsExtensions` (TypeScript)
364
+
365
+ This package is the only one allowed to augment pinia's
366
+ `DefineStoreOptionsBase`. A subscriber plugin must **not** redeclare it:
367
+ merging `storeOptions` with a different (even compatible) type raises
368
+ `TS2717` in consumer code.
369
+
370
+ Instead, per-store plugin options are centralized behind the
371
+ `StoreOptionsExtensions` interface. Augment it through interface merging to
372
+ add your own typed keys:
373
+
374
+ ```ts
375
+ // my-subscriber-plugin/types.d.ts (shipped with your plugin)
376
+ declare module 'pinia-plugin-subscription/types' {
377
+ interface StoreOptionsExtensions {
378
+ myPluginOption?: string
379
+ }
380
+ }
381
+ ```
382
+
383
+ The keys merge into `StoreOptions`, so store authors get type-checking
384
+ directly in the store definition:
385
+
386
+ ```ts
387
+ export const useMyStore = defineAStore('myStore', () => { /* ... */ }, {
388
+ rollbackAfterFailure: { save: 'all' }, // built-in option
389
+ myPluginOption: 'value', // typed via your augmentation
390
+ })
391
+ ```
392
+
393
+ Guidelines:
394
+
395
+ - Always declare **optional** keys with names specific enough to avoid
396
+ collisions (prefer `myPluginOption` over `options`).
397
+ - At runtime, merged options are delivered to your subscriber's
398
+ `invoke(context, debug)` through `context.options.storeOptions` — no
399
+ extra wiring needed.
400
+ - Never redeclare `DefineStoreOptionsBase` or `PiniaCustomProperties`; only
401
+ this package owns those augmentations.
402
+
403
+ ## Reference implementation
404
+
405
+ The demo app ships a complete plugin implementation in
406
+ [`src/demo/extending-pinia-store/`](../src/demo/extending-pinia-store/): a
407
+ `PluginSubscriber` subclass, a `Store` subclass, option typing and SSR
408
+ guards. Run `npm run dev` to explore it live.