Skip to content

v0.1.13

Choose a tag to compare

@github-actions github-actions released this 31 Aug 09:06
· 23 commits to main since this release

Prefix every error code with E_

NOVA_INVALID_SUBSCRIPTION, NOVA_MISSING_USER and NOVA_VAPID_NOT_CONFIGURED
stood alongside E_NOVA_UNKNOWN_STORE, E_NOVA_QUASAR_MISSING and the rest, so
matching on a nova code meant remembering which of two conventions a
particular failure happened to follow.

It also collapses a duplicate: the file store refused an unusable subscription
under E_NOVA_INVALID_SUBSCRIPTION while the subscribe endpoint answered the
identical condition with NOVA_INVALID_SUBSCRIPTION. One condition, one code.

The subscribe endpoint's 400 body carries the new code, which is a wire
change for a client matching on the old one.

Apply the subscribe endpoint's rules to the file it reads back

The store checked types and stopped there, so a hand-edited or corrupted
record reached the push layer as a truncated p256dh and failed in its
encryption, naming neither the file nor the endpoint. The rules that decide
whether a subscription is usable — https endpoint within the storage length,
base64url keys of the lengths a P-256 point and a 16-byte secret have, an
expirationTime that is null or a finite non-negative number — now live in one
module the subscribe endpoint and the file store both apply.

The store also applies them on the way in. Without that it could brick its own
file: one loose record written directly through save(), and every subscription
in the file becomes unreadable.

The file is written 0600. It holds the auth secret of every subscriber, which
is enough to send notifications as the application, and under a stock image's
umask it was readable by every other process in the container. The mode is
reasserted before the rename rather than only requested at creation, so a
leftover temporary from a crash cannot carry looser permissions across.

Validate the store file down to the record, not just its outer shape

The guard checked that version and subscriptions were present and that
subscriptions was an object. A version-2 file therefore passed, was read with
version-1 assumptions and written back mangled, which is the one thing
carrying a version is for. A half-written record passed too: listByUser
handed the push layer p256dh: undefined, and the failure surfaced far away as
an encryption error naming neither the file nor the endpoint.

Version and every record are checked now, and the refusal names what is wrong
and where. Also fixes a store-selection test that assumed quasar was absent —
it is a linked workspace peer here, so its own initialization message comes
through, which is the right behaviour and not one to mask; and types the
provider stub instead of casting it to any.

Declare quasar as the optional peer it is

It is reached at runtime and never imported, which is what keeps it optional —
but nothing said so, and npm and pnpm cannot warn about a dependency they have
not been told about. The same declaration bay, echo, ream and warden carry.

Configure the store the way AdonisJS configures one

Nova held a single store taking an instance, so a config file had to build
the connection itself and an application could not name its backend in the
environment.

The shape comes from AdonisJS, read from the published packages rather than
recalled: @adonisjs/session and @adonisjs/limiter both import stores beside
defineConfig, declare each backend as that namespace's factory result, and
select one by name from the environment — store: env.get('SESSION_DRIVER'),
default: env.get('LIMITER_STORE').

import { defineConfig, stores } from '@c9up/nova'

default: env.get('NOVA_STORE'),
stores: {
memory: stores.memory(),
file: stores.file({ path: 'storage/push_subscriptions.json' }),
sql: stores.sql({ connection: () => app.container.resolve('db') }),
redis: stores.redis({ connection: 'main' }),
}

default rather than store for the selector: both spellings are AdonisJS's,
and store already means the instance here.

Only the selected store is built, so naming a Redis store in a config that runs
on the file store opens no connection. A default naming nothing throws, listing
what exists: falling back to memory would look like it worked until a restart
lost every subscription.

stores.redis also takes a quasar connection NAME, resolved at first use
without a static import, so quasar stays an optional peer and its absence is
reported as such rather than as a module resolution error.

The single store instance still works.

Store subscriptions in a file, for an application that runs nothing else

The store contract was already the extension point; what was missing was an
option for the deployment that has no database and no Redis — one node, one
process, a self-hosted instance. Until now that meant the in-memory driver,
which loses every subscription on restart.

The care is in the failure modes, because a store that loses subscriptions
quietly is worse than one that has none:

  • writes are serialised, so twenty subscribes arriving together do not each
    read the same file and write their own version back;
  • the file is written to a temporary path and renamed over the real one, so a
    crash mid-write leaves the previous file rather than a truncated one;
  • a file that cannot be parsed is refused rather than treated as empty — the
    next subscribe would otherwise replace it, and the only symptom would be
    notifications that stop arriving.

One process, and the docs say so: nothing here takes a lock the operating
system enforces.

NovaError gains a cause, so the JSON parse failure keeps the byte it choked on.

Take a resolver, so a config file can name the connection

The documented wiring did not work: config/nova.ts is read before the
application boots, so there is no connection to hand the store yet — and the
snippet passed the Lucid-style db service, which answers query(options) with a
builder, not query(sql, params) with rows.

Both stores now accept the connection OR a function that answers with one,
resolved on the first push and kept. A test pins the Atlas connection shape —
execute, query, and the dialect spelled the way Atlas spells it — because a
renamed dialect would silently drop Postgres back to ? placeholders.

nova: 0.1.13

Ship the durable stores the migration was written for

configure wrote a push_subscriptions migration and a comment describing the
driver that would use it. The driver was never written, so every application
that followed the onboarding had the table, kept its subscriptions in memory,
and lost them all on restart.

Two now, because a subscription is a small record read by user and deleted by
endpoint — a table and a Redis key are both a fair answer, and an application
running Redis for its cache should not have to add a table for this:

SqlSubscriptionStore reads the table the migration creates
RedisSubscriptionStore keeps them under nova:push:*

Neither imports a database or a Redis package. The connection is taken
structurally — the SQL one needs query/execute and the dialect it reports, the
Redis one the same six commands echo and bay already take — so both optional
peers stay optional.

All three stores now follow the rule the in-memory one set: save() detaches the
endpoint from its previous owner. A push endpoint is globally unique per push
service, so a browser reused across a logout/login pair would otherwise stay on
both accounts, and the next notification for the old one would land on the new
user's screen. The SQL store is exercised against a real SQLite engine, not an
asserted string.


Changes since v0.1.12.