Compare commits
46
Commits
22caa1dceb
..
v0.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4919db6168 | ||
|
|
6e76293620 | ||
|
|
3b54a7f023 | ||
|
|
afaf3a853c | ||
|
|
1788d17f0a | ||
|
|
1caffc5f26 | ||
|
|
debb8df3ce | ||
|
|
98e59d4ba0 | ||
|
|
22404ea2d4 | ||
|
|
486356a01e | ||
|
|
1f30c86d4f | ||
|
|
d3c62e63bf | ||
|
|
57df2e15f2 | ||
|
|
4f1b1d68c7 | ||
|
|
9debe4e891 | ||
|
|
7ea2d7883f | ||
|
|
aaa7ebfd84 | ||
|
|
89d5d0f2da | ||
|
|
9426c83109 | ||
|
|
47e64c6f39 | ||
|
|
5803757153 | ||
|
|
44d385cd7f | ||
|
|
f53506b3f0 | ||
|
|
951453c735 | ||
|
|
99f145a80c | ||
|
|
104a5145c8 | ||
|
|
796ad4f16e | ||
|
|
b1e3244c1d | ||
|
|
85fae9e07e | ||
|
|
30f61094e6 | ||
|
|
1c7755d320 | ||
|
|
42acdeb9e8 | ||
|
|
70854d234c | ||
|
|
20ad1ae208 | ||
|
|
49b4d4165d | ||
|
|
a1cdcb3ad1 | ||
|
|
859675c3c7 | ||
|
|
4a3958c616 | ||
|
|
317a4ce410 | ||
|
|
6db50516b7 | ||
|
|
d43031997b | ||
|
|
11e2495484 | ||
|
|
759872834d | ||
|
|
9ddb5e2796 | ||
|
|
52a665502f | ||
|
|
9a328f647e |
@@ -6,3 +6,7 @@
|
||||
*.gif binary
|
||||
*.webp binary
|
||||
*.woff2 binary
|
||||
|
||||
# Reviewed license supplements are named by the SHA-256 of their exact bytes (ADR 0052), so Git must
|
||||
# never rewrite their line endings.
|
||||
packages/module-pack/license-supplements/** -text
|
||||
|
||||
@@ -128,6 +128,129 @@ jobs:
|
||||
if: always()
|
||||
run: bash scripts/ci-sweep.sh --job
|
||||
|
||||
package-release:
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
# What the gate proved, for the job that consumes its artifact to check it received exactly that.
|
||||
outputs:
|
||||
version: ${{ steps.release.outputs.version }}
|
||||
revision: ${{ steps.release.outputs.revision }}
|
||||
manifest_sha256: ${{ steps.release.outputs.manifest_sha256 }}
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# `actions/checkout` defaults to leaving the job token in `.git/config`, where any later
|
||||
# step — including one a pull request added to this file — could read or reuse it. Nothing
|
||||
# in this workflow pushes, so the credential is never needed after checkout.
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
|
||||
- name: Activate pinned pnpm
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare [email protected] --activate
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# ADR 0053's package release gate: build the public package train from this commit, build it
|
||||
# again from a second checkout and require byte-identical artifacts, then install exactly those
|
||||
# tarballs (offline, no registry) into a module outside this repository and prove it builds,
|
||||
# packages, and verifies. Nothing is published and no credential exists here. A future publish
|
||||
# job must depend on this job and publish only the artifacts it proved.
|
||||
- name: Build, reproduce, and consume the public package train
|
||||
run: pnpm test:package-release --keep package-train
|
||||
|
||||
- name: Describe the proven release train
|
||||
id: release
|
||||
run: |
|
||||
release="$(find package-train -mindepth 1 -maxdepth 1 -type d)"
|
||||
manifest="$release/ekklesia-package-train.json"
|
||||
{
|
||||
echo "version=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).version' "$manifest")"
|
||||
echo "revision=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).source.revision' "$manifest")"
|
||||
echo "manifest_sha256=$(sha256sum "$manifest" | cut -d' ' -f1)"
|
||||
} >>"$GITHUB_OUTPUT"
|
||||
cat "$manifest"
|
||||
|
||||
# The exact release artifacts, for the jobs that consume them, from a directory whose name does
|
||||
# not start with a dot: the artifact actions leave hidden files out. Version 3 of the artifact
|
||||
# actions: version 4 refuses any server that is not github.com, which this Gitea is not, while
|
||||
# Gitea implements the protocol version 3 uses. A consumer checks the files, not the wrapper.
|
||||
- name: Hand the release train on
|
||||
uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20
|
||||
with:
|
||||
name: package-train
|
||||
path: package-train/
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
# The publication tool, given exactly the artifact the package-release job proved, against a
|
||||
# disposable Gitea 1.27.2 (scripts/test-package-publication.sh). Never the real registry, and no
|
||||
# credential: the disposable registry's accounts are created and discarded inside the job.
|
||||
package-publication:
|
||||
needs: package-release
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
# Names this job's stacks, so its final step removes exactly these (scripts/lib/ci-project.sh).
|
||||
EKKLESIA_CI_JOB_KEY: package-publication
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# A job the runner killed (cancelled, or past the runner's own time limit) never ran its
|
||||
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
|
||||
- name: Sweep stale CI stacks from the runner
|
||||
run: bash scripts/ci-sweep.sh --stale
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
|
||||
- name: Activate pinned pnpm
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare [email protected] --activate
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Receive the release train the gate proved
|
||||
uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20
|
||||
with:
|
||||
name: package-train
|
||||
path: package-train/
|
||||
|
||||
- name: Check it is exactly that train
|
||||
env:
|
||||
EXPECTED_MANIFEST_SHA256: ${{ needs.package-release.outputs.manifest_sha256 }}
|
||||
run: |
|
||||
release="$(find package-train -mindepth 1 -maxdepth 1 -type d)"
|
||||
actual="$(sha256sum "$release/ekklesia-package-train.json" | cut -d' ' -f1)"
|
||||
if [[ -z "$EXPECTED_MANIFEST_SHA256" || "$actual" != "$EXPECTED_MANIFEST_SHA256" ]]; then
|
||||
echo "The received release manifest is not the one the package-release job proved." >&2
|
||||
exit 1
|
||||
fi
|
||||
(cd "$release" && sha256sum --check --strict SHA256SUMS)
|
||||
|
||||
- name: Publish it to a disposable Gitea, and prove the round trip and recovery
|
||||
run: bash scripts/test-package-publication.sh --release "$(find package-train -mindepth 1 -maxdepth 1 -type d)"
|
||||
|
||||
# Whatever happened above, this job's own stacks do not outlive it.
|
||||
- name: Remove this job's CI stacks
|
||||
if: always()
|
||||
run: bash scripts/ci-sweep.sh --job
|
||||
|
||||
image-build:
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
name: Publish packages
|
||||
|
||||
# Publishes one public @ekklesia/* package train (ADR 0053, docs/development/releases.md) to the
|
||||
# npm registry: exactly the artifacts this run's package-release gate proves, and nothing else.
|
||||
#
|
||||
# Only ever started by hand, on `main`, by someone with write access to this repository: no pull
|
||||
# request or push can start it. It needs the version-set commit of a release (`version` must equal
|
||||
# the platform version there), and the repository secret EKKLESIA_PACKAGES_PUBLISH_TOKEN, a token of
|
||||
# the dedicated publisher account with only the `write:package` scope. Packages are publicly
|
||||
# readable (ADR 0054); publishing is what this protects.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: The package train version to publish, equal to the platform version on main.
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Never cancel a publication part way, and never run two at once. A rerun resumes safely.
|
||||
concurrency:
|
||||
group: publish-packages
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
# The only registry-specific setting: where the train is published.
|
||||
EKKLESIA_PACKAGES_REGISTRY: https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/
|
||||
|
||||
jobs:
|
||||
package-release:
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
outputs:
|
||||
version: ${{ steps.release.outputs.version }}
|
||||
revision: ${{ steps.release.outputs.revision }}
|
||||
manifest_sha256: ${{ steps.release.outputs.manifest_sha256 }}
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
|
||||
- name: Activate pinned pnpm
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare [email protected] --activate
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# The same credential-free gate as CI: build, reproduce, and consume the train.
|
||||
- name: Build, reproduce, and consume the public package train
|
||||
run: pnpm test:package-release --keep package-train
|
||||
|
||||
- name: Describe the proven release train
|
||||
id: release
|
||||
run: |
|
||||
release="$(find package-train -mindepth 1 -maxdepth 1 -type d)"
|
||||
manifest="$release/ekklesia-package-train.json"
|
||||
{
|
||||
echo "version=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).version' "$manifest")"
|
||||
echo "revision=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).source.revision' "$manifest")"
|
||||
echo "manifest_sha256=$(sha256sum "$manifest" | cut -d' ' -f1)"
|
||||
} >>"$GITHUB_OUTPUT"
|
||||
|
||||
# Artifact actions version 3: version 4 refuses any server that is not github.com. The directory
|
||||
# name must not start with a dot: the artifact actions leave hidden files out.
|
||||
- name: Hand the release train on
|
||||
uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20
|
||||
with:
|
||||
name: package-train
|
||||
path: package-train/
|
||||
if-no-files-found: error
|
||||
retention-days: 90
|
||||
|
||||
publish:
|
||||
needs: package-release
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
# Only for the publication tool itself: nothing is built or installed in this job.
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
|
||||
- name: Receive the release train the gate proved
|
||||
uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20
|
||||
with:
|
||||
name: package-train
|
||||
path: package-train/
|
||||
|
||||
# The tool validates the whole train against its manifest, that manifest against the gate's,
|
||||
# its version against the request, and its revision against this commit, before it sends
|
||||
# anything; then publishes, verifies every package byte for byte, and moves `latest` last.
|
||||
- name: Publish the exact release train
|
||||
env:
|
||||
EKKLESIA_PACKAGES_PUBLISH_TOKEN: ${{ secrets.EKKLESIA_PACKAGES_PUBLISH_TOKEN }}
|
||||
TRAIN_VERSION: ${{ inputs.version }}
|
||||
TRAIN_REVISION: ${{ github.sha }}
|
||||
MANIFEST_SHA256: ${{ needs.package-release.outputs.manifest_sha256 }}
|
||||
run: |
|
||||
node scripts/publish-package-train.ts \
|
||||
--release "$(find package-train -mindepth 1 -maxdepth 1 -type d)" \
|
||||
--registry "$EKKLESIA_PACKAGES_REGISTRY" \
|
||||
--version "$TRAIN_VERSION" \
|
||||
--revision "$TRAIN_REVISION" \
|
||||
--manifest-sha256 "$MANIFEST_SHA256" \
|
||||
--record publication-record.json \
|
||||
--require-public-read
|
||||
|
||||
- name: Keep the publication record
|
||||
uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20
|
||||
with:
|
||||
name: package-train-publication-record
|
||||
path: publication-record.json
|
||||
if-no-files-found: error
|
||||
retention-days: 90
|
||||
+12
@@ -1,4 +1,8 @@
|
||||
node_modules/
|
||||
# pnpm's content-addressed package store. pnpm keeps it beside node_modules on the same file system,
|
||||
# so when the checkout is its own mount (a CI runner's workspace volume, a bind-mounted container),
|
||||
# `pnpm install` creates it here, at the checkout's root. A package cache, never source.
|
||||
.pnpm-store/
|
||||
.turbo/
|
||||
coverage/
|
||||
dist/
|
||||
@@ -16,3 +20,11 @@ generated/*
|
||||
|
||||
core/.identity-schema-reference.ts
|
||||
core/.identity-schema-check-*
|
||||
|
||||
# The packager tests copy fixture modules here (packages/module-pack/tests/support/fixture-module.ts).
|
||||
packages/module-pack/.fixtures/
|
||||
|
||||
# `pnpm packages:release` writes package release trains here (scripts/package-release.ts).
|
||||
.package-release/
|
||||
# CI keeps the proven release here to hand it on as a workflow artifact (scripts/test-package-release.sh --keep).
|
||||
/package-train/
|
||||
|
||||
@@ -78,6 +78,13 @@ ever disagree:
|
||||
- `pnpm test:migrations:integration` — live, disposable-PostgreSQL evidence: migration discovery,
|
||||
planning, and execution, plus the `apps/api` HTTP transport (signup, sign-in, principal
|
||||
resolution, sign-out). Requires Docker. Run this for any change touching either area.
|
||||
- `pnpm test:package-release` — builds the public `@ekklesia/*` package train, proves it
|
||||
reproducible, and proves a standalone module consumes exactly those tarballs. Publishes nothing.
|
||||
Run it for any change to the public packages or their release tooling.
|
||||
- `pnpm test:package-publication` — publishes a release train to a disposable Gitea (Docker) and
|
||||
proves the publication tool. Never the real registry. The real publication is the manual
|
||||
`Publish packages` workflow only; never run `scripts/publish-package-train.ts` against the real
|
||||
registry without the user's explicit intent.
|
||||
- `pnpm doctor` — read-only local readiness report. Never treat it as a substitute for `pnpm
|
||||
verify` or the integration suite.
|
||||
- `pnpm migrations:plan` / `pnpm migrations:apply` — read-only planning vs. mutating execution
|
||||
|
||||
@@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
@@ -48,8 +48,31 @@ migration history is retained and reported, and blocks no other migration. A mod
|
||||
as one **Ekklesia Module Package v1**
|
||||
([ADR 0049](docs/architecture/decisions/0049-module-package-v1.md)): a deterministic `.tar.gz` and
|
||||
its SHA-256, produced by the canonical packager (`@ekklesia/module-pack`) with the module's backend
|
||||
bundled, and checked by Core's verifier before it is installed. Only the portable `any` target exists
|
||||
so far. Purge, native-target packages, signing, and any installer or marketplace do not exist yet.
|
||||
bundled, and checked by Core's verifier before it is installed. Two targets exist:
|
||||
- the portable `any` target, for pure-JavaScript modules;
|
||||
- `linux-x64-glibc`, for modules with a native dependency such as `sharp`. Its native runtime
|
||||
packages are carried as real files under the package's `node_modules`, and the verifier checks the
|
||||
host before anything is loaded.
|
||||
|
||||
Every package records the module's validated SPDX expression and carries its explicitly
|
||||
supplied `LICENSE` bytes; dependency material remains separate in `THIRD_PARTY_NOTICES`. ABI-v1
|
||||
packages may bundle a module-owned Drizzle version without coupling it to Core;
|
||||
legacy-v8 packages retain their transitional exact-version check. Where a redistributed dependency
|
||||
omits licence material, the packager may add reviewed licence supplements
|
||||
([ADR 0052](docs/architecture/decisions/0052-reviewed-license-supplements.md)). Purge, other
|
||||
operating-system or CPU targets, signing, and any installer or marketplace do not exist yet.
|
||||
|
||||
A module can be built in its own repository from Ekklesia's public module-development packages
|
||||
([ADR 0053](docs/architecture/decisions/0053-standalone-module-development-packages.md)):
|
||||
a curated `@ekklesia/module-sdk`, `@ekklesia/module-database`, the UI packages, and the build and
|
||||
packaging tools. They are released as one coordinated train of tarballs with a release manifest,
|
||||
and CI proves on every run that two builds of a commit are byte-identical and that a module outside
|
||||
the repository installs, builds, packages, and verifies from those artifacts alone. A manual
|
||||
workflow, the only way to publish, publishes exactly those artifacts to the npm registry and
|
||||
verifies every package byte for byte. Anyone may read and install the packages before launch
|
||||
([ADR 0054](docs/architecture/decisions/0054-public-package-reads-before-launch.md)). The first train
|
||||
published will be the next platform release
|
||||
([Releases and the public package train](docs/development/releases.md)).
|
||||
|
||||
Phase 4A added Core-owned identity foundations: a Core-owned Better Auth database schema
|
||||
(`ekklesia_core_user`, `ekklesia_core_session`, `ekklesia_core_account`,
|
||||
@@ -506,8 +529,8 @@ reported `blocked · configuration_missing · bucket` — never imported, never
|
||||
from inside its own `start` hook — and its required dependents block behind it through ADR 0028's
|
||||
existing mechanism. Doctor and `pnpm modules:validate` report the same thing while still importing no
|
||||
module code. That change introduced `manifestVersion: 5` as a hard cutover; the current strict
|
||||
manifest is version 7 after ADR 0033 added the optional display declaration and ADR 0041 added
|
||||
browser contributions.
|
||||
manifest is version 8 after ADR 0050 added the optional database ABI declaration. Version 8 is a
|
||||
hard cutover; a version 7 source module or package must be updated and rebuilt.
|
||||
|
||||
**Ekklesia does not store secrets, and does not pretend to.** There is no encryption at rest here and
|
||||
no key management, so a `secret` key's value is supplied by the deployment
|
||||
@@ -841,7 +864,7 @@ is versioned with no dual acceptance; migrating is one `pnpm build`.
|
||||
|
||||
Phase 8 pass 3 lets one module contribute a browser renderer to a capability another module provides
|
||||
([ADR 0041](docs/architecture/decisions/0041-module-browser-contributions.md)) — the browser half of
|
||||
the capability system Core has had since Phase 1. `manifestVersion: 7` declares a namespaced id, its
|
||||
the capability system Core has had since Phase 1. `manifestVersion: 8` declares a namespaced id, its
|
||||
own version, a target capability, a compatible range, and one artifact, and nothing else: there is no
|
||||
payload schema, slot, ordering, or rendering hint anywhere, so Core cannot learn what any of it
|
||||
means. Publication requires that another eligible module provides the capability inside the declared
|
||||
@@ -1072,5 +1095,27 @@ environment.
|
||||
- [Module development](docs/module-development/README.md)
|
||||
- [Architecture decisions](docs/architecture/decisions/README.md)
|
||||
|
||||
No open-source license has been selected yet. A license should be added only after the project
|
||||
owner makes that decision.
|
||||
## License
|
||||
|
||||
Ekklesia is licensed under the **GNU Affero General Public License, version 3 or any later version**
|
||||
(SPDX `AGPL-3.0-or-later`). The full text of version 3 is in [LICENSE](LICENSE). Its source is
|
||||
available to you under the terms of that license.
|
||||
|
||||
> Ekklesia is free software: you can redistribute it and/or modify it under the terms of the GNU
|
||||
> Affero General Public License as published by the Free Software Foundation, either version 3 of
|
||||
> the License, or (at your option) any later version.
|
||||
>
|
||||
> Ekklesia is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
> the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
|
||||
> General Public License for more details.
|
||||
>
|
||||
> You should have received a copy of the GNU Affero General Public License along with Ekklesia. If
|
||||
> not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Section 13 of the license adds an obligation for network use: if you modify Ekklesia and run your
|
||||
modified version for users who interact with it remotely over a network, your version must offer
|
||||
those users the opportunity to receive its Corresponding Source.
|
||||
|
||||
Official Ekklesia modules, published by the Ekklesia project, are licensed under `AGPL-3.0-or-later`
|
||||
as well. Each module's own repository declares its license. The module template declares it in its
|
||||
`package.json`, so a module started from the template begins with the same license.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "@ekklesia/api",
|
||||
"version": "0.1.2",
|
||||
"version": "0.2.1",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"files": [
|
||||
|
||||
+6
-6
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
isAuthenticatedPrincipal,
|
||||
type ModuleRuntimeServices,
|
||||
type ModuleSecretSource,
|
||||
type RegisteredModuleHttpRoute,
|
||||
} from "@ekklesia/contracts";
|
||||
@@ -13,6 +12,7 @@ import type { DashboardStore } from "@ekklesia/core/dashboard";
|
||||
import type { DeviceStore } from "@ekklesia/core/devices";
|
||||
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
|
||||
import type {
|
||||
AnyModuleRuntimeServices,
|
||||
DiscoveredModule,
|
||||
ModuleConfigurationLookup,
|
||||
ModuleConfigurationSettings,
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
ModuleLifecycleSnapshot,
|
||||
ModuleLifecycleStore,
|
||||
ModuleRealtimeRegistry,
|
||||
ModuleRuntimeServicesLookup,
|
||||
ModuleStorageLookup,
|
||||
ModuleUiPublicationRegistry,
|
||||
} from "@ekklesia/core/modules";
|
||||
@@ -62,11 +63,10 @@ export interface CreateApiAppOptions {
|
||||
/** The started runtime's immutable module-owned realtime registry. Required explicitly even when
|
||||
* empty, for the same reason as module routes: application composition never guesses what ran. */
|
||||
readonly moduleRealtime: ModuleRealtimeRegistry;
|
||||
/** The same frozen services object the caller's started module runtime already passed to every
|
||||
* module's `start`/`stop` hooks (`apps/api/src/server.ts`) — reference-identical, never a fresh
|
||||
* object built here, so a module's HTTP handlers and lifecycle hooks share exactly one
|
||||
* `ModuleDatabase` for the lifetime of this API runtime. */
|
||||
readonly moduleServices: ModuleRuntimeServices;
|
||||
/** The same per-module service lookup the caller's started module runtime used for every
|
||||
* module's `start`/`stop` hooks (`apps/api/src/server.ts`) — each handler receives its module's
|
||||
* reference-identical service object, never a fresh facade or executor built per request. */
|
||||
readonly moduleServices: ModuleRuntimeServicesLookup | AnyModuleRuntimeServices;
|
||||
/** The platform's module UI publication policy — always the one Core builds from the exact
|
||||
* discovered inventory this runtime started (`createModuleUiPublicationRegistry`,
|
||||
* `apps/api/src/server.ts`). Required rather than defaulted, for the same reason `moduleRoutes`
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ModuleDatabase } from "@ekklesia/contracts";
|
||||
import type { ModuleDatabaseExecutor, ModuleId } from "@ekklesia/contracts";
|
||||
import {
|
||||
type AuthorizationDrizzleDatabase,
|
||||
createAuthorizationDrizzleView,
|
||||
@@ -12,9 +12,11 @@ import { createDeviceDrizzleView, type DeviceDrizzleDatabase } from "@ekklesia/c
|
||||
import { createIdentityDrizzleView, type IdentityDrizzleDatabase } from "@ekklesia/core/identity";
|
||||
import {
|
||||
createModuleConfigurationView,
|
||||
createModuleDatabaseExecutor,
|
||||
createModuleDatabaseView,
|
||||
createModuleLifecycleView,
|
||||
type ModuleConfigurationDrizzleDatabase,
|
||||
type ModuleDatabase,
|
||||
type ModuleLifecycleDrizzleDatabase,
|
||||
} from "@ekklesia/core/modules";
|
||||
|
||||
@@ -24,6 +26,8 @@ export interface ApiDatabaseHandle {
|
||||
readonly dashboard: DashboardDrizzleDatabase;
|
||||
readonly identity: IdentityDrizzleDatabase;
|
||||
readonly module: ModuleDatabase;
|
||||
/** Builds one ABI-v1 executor attributed to exactly one module, over this handle's shared pool. */
|
||||
readonly moduleExecutor: (moduleId: ModuleId) => ModuleDatabaseExecutor;
|
||||
readonly moduleConfiguration: ModuleConfigurationDrizzleDatabase;
|
||||
readonly moduleLifecycle: ModuleLifecycleDrizzleDatabase;
|
||||
/**
|
||||
@@ -76,6 +80,7 @@ export function createApiDatabase(
|
||||
dashboard: createDashboardDrizzleView(pool),
|
||||
identity: createIdentityDrizzleView(pool),
|
||||
module: createModuleDatabaseView(pool),
|
||||
moduleExecutor: (moduleId: ModuleId) => createModuleDatabaseExecutor(pool, { moduleId }),
|
||||
moduleConfiguration: createModuleConfigurationView(pool),
|
||||
moduleLifecycle: createModuleLifecycleView(pool),
|
||||
ping: async (statementTimeoutMs: number) => {
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import {
|
||||
EMPTY_MODULE_CONFIGURATION,
|
||||
EMPTY_MODULE_STORAGE,
|
||||
type ModuleDatabaseExecutor,
|
||||
type ModuleId,
|
||||
type ModuleRouteHandlerContext,
|
||||
type ModuleRuntimeServices,
|
||||
type RegisteredModuleHttpRoute,
|
||||
} from "@ekklesia/contracts";
|
||||
import type {
|
||||
AnyModuleRuntimeServices,
|
||||
ModuleConfigurationLookup,
|
||||
ModuleDatabase,
|
||||
ModuleRealtimeRegistry,
|
||||
ModuleRuntimeServicesLookup,
|
||||
ModuleStorageLookup,
|
||||
} from "@ekklesia/core/modules";
|
||||
import type { Context, Hono } from "hono";
|
||||
@@ -37,14 +40,14 @@ export interface ModuleHttpRoutesOptions extends AuthorizationMiddlewareOptions
|
||||
* received for this API runtime — never a fresh one built per request, and never touched for a
|
||||
* denied, trusted-origin-rejected, or `OPTIONS` request, since a handler is only ever invoked
|
||||
* after authorization succeeds. */
|
||||
readonly services: ModuleRuntimeServices;
|
||||
readonly services: ModuleRuntimeServicesLookup | AnyModuleRuntimeServices;
|
||||
/**
|
||||
* Per-module frozen configuration snapshots — the exact map the module runtime started from
|
||||
* (ADR 0029), so a handler and that module's `start`/`stop` hooks receive the identical object
|
||||
* rather than two independently resolved values. A module with no entry receives
|
||||
* `EMPTY_MODULE_CONFIGURATION`, so `context.config` is always present.
|
||||
*
|
||||
* Unlike `services`, which is one shared object, this is keyed by module id: a handler can only
|
||||
* Like `services`, this is keyed by module id: a handler can only
|
||||
* ever reach its own module's configuration, because that is the only entry looked up for it.
|
||||
*/
|
||||
readonly configurations: ModuleConfigurationLookup;
|
||||
@@ -232,6 +235,11 @@ export function mountModuleHttpRoutes(
|
||||
if (!options.registry.has(route.permission)) {
|
||||
throw new ModuleRouteConfigurationError();
|
||||
}
|
||||
const services =
|
||||
"get" in options.services ? options.services.get(route.moduleId) : options.services;
|
||||
if (!services) {
|
||||
throw new ModuleRouteConfigurationError();
|
||||
}
|
||||
|
||||
const requireRoutePermission = requirePermission(middlewareOptions, route.permission);
|
||||
// The route's own conscious body contract (`route.maxBodyBytes` — 0 means no body accepted at
|
||||
@@ -243,19 +251,24 @@ export function mountModuleHttpRoutes(
|
||||
const routeBodyLimit = platformBodyLimit(route.maxBodyBytes, route.bodyMode);
|
||||
const path = moduleRoutePath(route.moduleId, route.path);
|
||||
const finalHandler = async (c: Context): Promise<Response> => {
|
||||
const context: ModuleRouteHandlerContext = Object.freeze({
|
||||
const context = Object.freeze({
|
||||
config: options.configurations.get(route.moduleId) ?? EMPTY_MODULE_CONFIGURATION,
|
||||
moduleId: route.moduleId,
|
||||
storage: options.storages.get(route.moduleId) ?? EMPTY_MODULE_STORAGE,
|
||||
params: Object.freeze({ ...c.req.param() }),
|
||||
principal: getAuthorizedPrincipal(c),
|
||||
realtime: options.realtime.publisherFor(route.moduleId),
|
||||
services: options.services,
|
||||
});
|
||||
services,
|
||||
}) as ModuleRouteHandlerContext<ModuleDatabase | ModuleDatabaseExecutor>;
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await route.handler(c.req.raw, context);
|
||||
response = await (
|
||||
route.handler as (
|
||||
request: Request,
|
||||
context: ModuleRouteHandlerContext<ModuleDatabase | ModuleDatabaseExecutor>,
|
||||
) => PromiseLike<Response> | Response
|
||||
)(c.req.raw, context);
|
||||
} catch {
|
||||
return sanitizedModuleHandlerFailure(c);
|
||||
}
|
||||
|
||||
+15
-13
@@ -1,11 +1,7 @@
|
||||
import type { Socket } from "node:net";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
EMPTY_MODULE_SECRET_SOURCE,
|
||||
type ModuleRuntimeServices,
|
||||
type ModuleSecretSource,
|
||||
} from "@ekklesia/contracts";
|
||||
import { EMPTY_MODULE_SECRET_SOURCE, type ModuleSecretSource } from "@ekklesia/contracts";
|
||||
import {
|
||||
createAuthorizationEvaluator,
|
||||
createAuthorizationStore,
|
||||
@@ -21,12 +17,14 @@ import {
|
||||
} from "@ekklesia/core/identity";
|
||||
import { createSmtpMailProvider, type MailProvider } from "@ekklesia/core/mail";
|
||||
import {
|
||||
type AnyModuleRuntimeServices,
|
||||
createModuleConfigurationStore,
|
||||
createModuleLifecycleStore,
|
||||
createModuleUiPublicationRegistry,
|
||||
type DiscoveredModule,
|
||||
type ModuleConfigurationSettings,
|
||||
type ModuleEligibilityResolution,
|
||||
type ModuleRuntimeServicesLookup,
|
||||
resolveModuleEligibility,
|
||||
type StartedModuleRuntime,
|
||||
startDiscoveredModuleRuntime,
|
||||
@@ -393,16 +391,13 @@ function bindServer(
|
||||
* (`prepareStartupModuleInventory`, `./platform-permissions.js`).
|
||||
* 2. Construct the one shared PostgreSQL pool (identity, authorization, and the module database
|
||||
* facade each get a typed Drizzle view over it — see `./database.js`).
|
||||
* 3. Construct the frozen `ModuleRuntimeServices` (currently just `database`) from that same pool,
|
||||
* then the Core identity runtime, authorization store, and evaluator.
|
||||
* 3. Construct the Core identity runtime, authorization store, and evaluator from that pool.
|
||||
* 4. Read the stored module enablement configuration and resolve, from that exact same discovered
|
||||
* inventory, which modules are eligible to run (`resolveModuleEligibility`).
|
||||
* 5. Start the module runtime from the eligible subset (`startDiscoveredModuleRuntime`) — never
|
||||
* rediscovering modules a second time — passing it the same services object every module's
|
||||
* `start`/`stop` hook receives.
|
||||
* rediscovering modules a second time — passing the manifest-selected service map.
|
||||
* 6. Create the Hono application, mounting the started module runtime's published routes, publishing
|
||||
* UI for that same eligible set, and passing the exact same services object to every module HTTP
|
||||
* handler.
|
||||
* UI for that same eligible set, and passing the same per-module service map to HTTP handlers.
|
||||
* 7. Bind the Node HTTP server.
|
||||
*
|
||||
* Discovery and permission composition happen before any pool, Better Auth instance, module
|
||||
@@ -459,12 +454,10 @@ export async function startApi(
|
||||
const storageProvider = config.storage ? createS3StorageProvider(config.storage) : undefined;
|
||||
const mailProvider = config.mail ? createSmtpMailProvider(config.mail) : undefined;
|
||||
|
||||
let services: ModuleRuntimeServices;
|
||||
let runtime: ReturnType<typeof createIdentityRuntime>;
|
||||
let authorizationStore: ReturnType<typeof createAuthorizationStore>;
|
||||
let deviceStore: ReturnType<typeof createDeviceStore>;
|
||||
try {
|
||||
services = Object.freeze({ database: databaseHandle.module });
|
||||
runtime = createIdentityRuntime({
|
||||
config: config.identity,
|
||||
db: databaseHandle.identity,
|
||||
@@ -532,6 +525,15 @@ export async function startApi(
|
||||
throw error;
|
||||
}
|
||||
|
||||
const services: ModuleRuntimeServicesLookup = new Map<string, AnyModuleRuntimeServices>(
|
||||
eligibility.eligibleModules.map((module) => [
|
||||
module.manifest.id,
|
||||
module.manifest.database?.abi === 1
|
||||
? Object.freeze({ database: databaseHandle.moduleExecutor(module.manifest.id) })
|
||||
: Object.freeze({ database: databaseHandle.module }),
|
||||
]),
|
||||
);
|
||||
|
||||
// One scoped facade per eligible module, over the exact buckets that module's own manifest
|
||||
// declares. Built from the eligible set so a blocked module never receives storage at all.
|
||||
const moduleStorages = createModuleStorageLookup(
|
||||
|
||||
@@ -221,9 +221,9 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
const aggregate = await discoverAllMigrations({
|
||||
coreMigrationsDirectory,
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
moduleSdkVersion: "0.2.0",
|
||||
modulesDirectory: emptyModulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
platformVersion: "0.2.0",
|
||||
},
|
||||
});
|
||||
// Phase 4A's and Phase 4C pass 1's committed migrations must not have drifted while building
|
||||
@@ -339,10 +339,10 @@ async function createGatedRouteModule(
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify({
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
id,
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
name: `${id} module`,
|
||||
permissions: [{ baseline: "public", description: "Gated drain probe.", id: permissionId }],
|
||||
version: "0.1.0",
|
||||
@@ -386,11 +386,11 @@ async function createFixtureModule(
|
||||
): Promise<void> {
|
||||
const moduleDirectory = path.join(modulesDirectory, id);
|
||||
const manifest = {
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
||||
compatibility: { platform: "^0.2.0", moduleSdk: "^0.2.0" },
|
||||
permissions: [],
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
...manifestOverrides,
|
||||
@@ -521,10 +521,10 @@ async function createUiModule(
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify({
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
|
||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||
id,
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
name: `${id} module`,
|
||||
version,
|
||||
}),
|
||||
@@ -2592,9 +2592,9 @@ describe.sequential("live API — installed module permission end-to-end", () =>
|
||||
const report = await diagnoseEkklesia({
|
||||
coreMigrationsDirectory,
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
moduleSdkVersion: "0.2.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
platformVersion: "0.2.0",
|
||||
},
|
||||
runtimeRange: ">=0.0.0",
|
||||
runtimeVersion: process.versions.node,
|
||||
@@ -3906,9 +3906,9 @@ describe.sequential("live API — module lifecycle governs what actually runs",
|
||||
const aggregate = await discoverAllMigrations({
|
||||
coreMigrationsDirectory,
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
moduleSdkVersion: "0.2.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
platformVersion: "0.2.0",
|
||||
},
|
||||
});
|
||||
expect(aggregate.discoveredModules.map((module) => module.manifest.id)).toEqual(["calendar"]);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
createAuthenticatedPrincipal,
|
||||
type ModuleDatabase,
|
||||
type ModuleRuntimeServices,
|
||||
moduleIdSchema,
|
||||
parseEkklesiaUserId,
|
||||
parsePermissionId,
|
||||
@@ -20,6 +18,8 @@ import { IdentityRuntimeError } from "@ekklesia/core/identity";
|
||||
import {
|
||||
createModuleUiPublicationRegistry,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
type LegacyModuleRuntimeServices,
|
||||
type ModuleDatabase,
|
||||
type ModuleUiPublicationRegistry,
|
||||
StagedModuleRealtimeRegistry,
|
||||
} from "@ekklesia/core/modules";
|
||||
@@ -48,9 +48,9 @@ function fakeRuntime(overrides: Partial<EkklesiaIdentityRuntime> = {}): Ekklesia
|
||||
};
|
||||
}
|
||||
|
||||
/** A small typed fake `ModuleRuntimeServices` — these tests exercise `createApiApp`'s own wiring,
|
||||
/** A small typed fake `LegacyModuleRuntimeServices` — these tests exercise `createApiApp`'s own wiring,
|
||||
* never real database behavior, so no PostgreSQL is required. */
|
||||
function fakeModuleServices(): ModuleRuntimeServices {
|
||||
function fakeModuleServices(): LegacyModuleRuntimeServices {
|
||||
const database = Object.freeze({
|
||||
delete: vi.fn(),
|
||||
execute: vi.fn(),
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
type ModuleDatabase,
|
||||
type ModuleRouteHandler,
|
||||
type ModuleRuntimeServices,
|
||||
parsePermissionId,
|
||||
type RegisteredModuleHttpRoute,
|
||||
} from "@ekklesia/contracts";
|
||||
@@ -16,6 +14,8 @@ import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import {
|
||||
createModuleUiPublicationRegistry,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
type LegacyModuleRuntimeServices,
|
||||
type ModuleDatabase,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -74,7 +74,7 @@ function fakeStore(): AuthorizationStore {
|
||||
};
|
||||
}
|
||||
|
||||
function fakeModuleServices(): ModuleRuntimeServices {
|
||||
function fakeModuleServices(): LegacyModuleRuntimeServices {
|
||||
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@ import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
createAuthenticatedPrincipal,
|
||||
type EkklesiaPrincipal,
|
||||
type ModuleDatabase,
|
||||
type ModuleRuntimeServices,
|
||||
parseEkklesiaUserId,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
@@ -20,6 +18,8 @@ import {
|
||||
createModuleUiPublicationRegistry,
|
||||
discoverModules,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
type LegacyModuleRuntimeServices,
|
||||
type ModuleDatabase,
|
||||
} from "@ekklesia/core/modules";
|
||||
import type { Hono } from "hono";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -77,7 +77,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }),
|
||||
},
|
||||
id: fixture.id,
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
name: fixture.name,
|
||||
version: "1.0.0",
|
||||
}),
|
||||
@@ -147,7 +147,7 @@ async function install(options: InstallOptions = {}): Promise<{ app: Hono }> {
|
||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
moduleServices: Object.freeze({
|
||||
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||
}) as ModuleRuntimeServices,
|
||||
}) as LegacyModuleRuntimeServices,
|
||||
moduleUi: createModuleUiPublicationRegistry(modules),
|
||||
moduleLifecycle: {
|
||||
snapshot: moduleLifecycleSnapshotFor(modules),
|
||||
|
||||
@@ -118,13 +118,13 @@ async function createDisplayModuleDirectory(): Promise<string> {
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify({
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
|
||||
entrypoints: {
|
||||
display: { artifact: "./dist/ui/display.js", permission: displayPermission },
|
||||
server: "./dist/server/index.js",
|
||||
},
|
||||
id: "announcements",
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
name: "Announcements fixture",
|
||||
permissions: [
|
||||
{
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
type ModuleDatabase,
|
||||
type ModuleRouteHandler,
|
||||
type ModuleRuntimeServices,
|
||||
parsePermissionId,
|
||||
type RegisteredModuleHttpRoute,
|
||||
} from "@ekklesia/contracts";
|
||||
@@ -15,6 +13,8 @@ import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import {
|
||||
createModuleUiPublicationRegistry,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
type LegacyModuleRuntimeServices,
|
||||
type ModuleDatabase,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApiApp } from "../src/app.js";
|
||||
@@ -56,7 +56,7 @@ function fakeStore(): AuthorizationStore {
|
||||
};
|
||||
}
|
||||
|
||||
function fakeModuleServices(): ModuleRuntimeServices {
|
||||
function fakeModuleServices(): LegacyModuleRuntimeServices {
|
||||
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
|
||||
}
|
||||
|
||||
|
||||
@@ -104,11 +104,11 @@ async function createFixtureModule(
|
||||
): Promise<void> {
|
||||
const moduleDirectory = path.join(modulesDirectory, id);
|
||||
const manifest = {
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
||||
compatibility: { platform: "^0.2.0", moduleSdk: "^0.2.0" },
|
||||
permissions: [],
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
...manifestOverrides,
|
||||
@@ -267,10 +267,10 @@ function fakeDiscoveredModule(
|
||||
): DiscoveredModule {
|
||||
const manifest = parseModuleManifest({
|
||||
capabilities: [],
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
|
||||
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
||||
id,
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
name: `${id} module`,
|
||||
permissions,
|
||||
version: "0.1.0",
|
||||
@@ -1435,9 +1435,9 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
|
||||
const recorder = createEventRecorder();
|
||||
await createWorkingFixtureModule(modulesDirectory, "calendar", recorder, { withRoute: true });
|
||||
const discovered = await (await import("@ekklesia/core/modules")).discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
moduleSdkVersion: "0.2.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
platformVersion: "0.2.0",
|
||||
});
|
||||
mockPoolAndAuth();
|
||||
mockSuccessfulServe();
|
||||
|
||||
@@ -6,8 +6,6 @@ import {
|
||||
createAuthenticatedPrincipal,
|
||||
type EkklesiaPrincipal,
|
||||
type ModuleConfigurationDeclarationInput,
|
||||
type ModuleDatabase,
|
||||
type ModuleRuntimeServices,
|
||||
parseEkklesiaUserId,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
@@ -22,10 +20,12 @@ import {
|
||||
createModuleUiPublicationRegistry,
|
||||
type DiscoveredModule,
|
||||
discoverModules,
|
||||
type LegacyModuleRuntimeServices,
|
||||
type ModuleConfigurationRecord,
|
||||
type ModuleConfigurationStore,
|
||||
ModuleConfigurationStoreError,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
type ModuleDatabase,
|
||||
resolveModuleEligibility,
|
||||
} from "@ekklesia/core/modules";
|
||||
import type { Hono } from "hono";
|
||||
@@ -108,7 +108,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
configuration: fixture.configuration ?? [],
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
id: fixture.id,
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
name: `${fixture.id} module`,
|
||||
version: "1.0.0",
|
||||
}),
|
||||
@@ -213,7 +213,7 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
|
||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
moduleServices: Object.freeze({
|
||||
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||
}) as ModuleRuntimeServices,
|
||||
}) as LegacyModuleRuntimeServices,
|
||||
moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
createPostgresMigrationDatabase,
|
||||
discoverAllMigrations,
|
||||
} from "@ekklesia/core/migrations";
|
||||
import { moduleDatabaseExecutorModuleId } from "@ekklesia/core/modules";
|
||||
import { Client, type QueryResultRow } from "pg";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import { createApiRuntimeConfig } from "../src/config.js";
|
||||
@@ -211,9 +212,9 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
const aggregate = await discoverAllMigrations({
|
||||
coreMigrationsDirectory,
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
moduleSdkVersion: "0.2.0",
|
||||
modulesDirectory: emptyModulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
platformVersion: "0.2.0",
|
||||
},
|
||||
});
|
||||
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
|
||||
@@ -240,9 +241,9 @@ async function applyMigrationsWithModule(url: string): Promise<void> {
|
||||
const aggregate = await discoverAllMigrations({
|
||||
coreMigrationsDirectory,
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
moduleSdkVersion: "0.2.0",
|
||||
modulesDirectory: fixtureModulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
platformVersion: "0.2.0",
|
||||
},
|
||||
});
|
||||
// Core migrations aggregate first, exactly once each, and always before any module migration.
|
||||
@@ -441,6 +442,11 @@ describe.sequential("live API — module database explicit migration ownership",
|
||||
|
||||
const handle = createApiDatabase(database.url);
|
||||
expect(handle.module).toBeDefined();
|
||||
const alpha = handle.moduleExecutor("alpha");
|
||||
const beta = handle.moduleExecutor("beta");
|
||||
expect(alpha).not.toBe(beta);
|
||||
expect(moduleDatabaseExecutorModuleId(alpha)).toBe("alpha");
|
||||
expect(moduleDatabaseExecutorModuleId(beta)).toBe("beta");
|
||||
await handle.close();
|
||||
expect(await tableExists(database.url)).toBe(false);
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@ import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
createAuthenticatedPrincipal,
|
||||
type EkklesiaPrincipal,
|
||||
type ModuleDatabase,
|
||||
type ModuleRuntimeServices,
|
||||
parseEkklesiaUserId,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
@@ -21,6 +19,8 @@ import {
|
||||
type DiscoveredModule,
|
||||
discoverModules,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
type LegacyModuleRuntimeServices,
|
||||
type ModuleDatabase,
|
||||
} from "@ekklesia/core/modules";
|
||||
import type { Hono } from "hono";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -105,7 +105,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture = {
|
||||
: {}),
|
||||
},
|
||||
id,
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
name: `${id} module`,
|
||||
permissions: fixture.display
|
||||
? [
|
||||
@@ -155,7 +155,7 @@ interface Installation {
|
||||
readonly modules: readonly DiscoveredModule[];
|
||||
}
|
||||
|
||||
function fakeServices(): ModuleRuntimeServices {
|
||||
function fakeServices(): LegacyModuleRuntimeServices {
|
||||
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
createAuthenticatedPrincipal,
|
||||
type ModuleDatabase,
|
||||
type ModuleRouteHandler,
|
||||
type ModuleRouteHandlerContext,
|
||||
type ModuleRuntimeServices,
|
||||
moduleRoutesOverlap,
|
||||
parsePermissionId,
|
||||
type PermissionId,
|
||||
parsePermissionId,
|
||||
type RegisteredModuleHttpRoute,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
@@ -20,20 +18,23 @@ import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import {
|
||||
createModuleUiPublicationRegistry,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
type LegacyModuleRuntimeServices,
|
||||
type ModuleDatabase,
|
||||
type ModuleRuntimeServicesLookup,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
import { createApiApp } from "../src/app.js";
|
||||
import {
|
||||
canonicalizeVaryHeader,
|
||||
mergedVaryHeader,
|
||||
ModuleRouteConfigurationError,
|
||||
mergedVaryHeader,
|
||||
} from "../src/module-http-routes.js";
|
||||
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
||||
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
||||
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
|
||||
const trustedOrigin = "https://dashboard.example.test";
|
||||
const untrustedOrigin = "https://evil.example.test";
|
||||
@@ -73,11 +74,11 @@ function moduleRoute(
|
||||
return Object.freeze({ bodyMode: "buffered", maxBodyBytes: 1024, ...fields });
|
||||
}
|
||||
|
||||
/** A small typed fake `ModuleRuntimeServices` — these tests exercise route mounting,
|
||||
/** A small typed fake `LegacyModuleRuntimeServices` — these tests exercise route mounting,
|
||||
* authorization, and response sanitization, never real database behavior, so no PostgreSQL is
|
||||
* required. Each `database` method stays a distinct spy so a test can assert none of them were
|
||||
* ever called (denied, trusted-origin-rejected, and `OPTIONS` requests never reach a handler). */
|
||||
function fakeModuleServices(): ModuleRuntimeServices {
|
||||
function fakeModuleServices(): LegacyModuleRuntimeServices {
|
||||
const database = Object.freeze({
|
||||
delete: vi.fn(),
|
||||
execute: vi.fn(),
|
||||
@@ -91,7 +92,7 @@ function fakeModuleServices(): ModuleRuntimeServices {
|
||||
return Object.freeze({ database });
|
||||
}
|
||||
|
||||
function expectDatabaseFakeUntouched(services: ModuleRuntimeServices): void {
|
||||
function expectDatabaseFakeUntouched(services: LegacyModuleRuntimeServices): void {
|
||||
expect(vi.mocked(services.database.select)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.selectDistinct)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.selectDistinctOn)).not.toHaveBeenCalled();
|
||||
@@ -107,7 +108,7 @@ function buildModuleApp(options: {
|
||||
readonly registry: PermissionRegistry;
|
||||
readonly routes: readonly RegisteredModuleHttpRoute[];
|
||||
readonly runtime?: EkklesiaIdentityRuntime;
|
||||
readonly services?: ModuleRuntimeServices;
|
||||
readonly services?: LegacyModuleRuntimeServices | ModuleRuntimeServicesLookup;
|
||||
readonly trustedOrigins?: readonly string[];
|
||||
}) {
|
||||
const evaluator = options.evaluator ?? {
|
||||
@@ -145,6 +146,36 @@ function permissionRegistry(
|
||||
}
|
||||
|
||||
describe("module HTTP route mounting", () => {
|
||||
it("selects the owning module's stable service entry for its handler", async () => {
|
||||
const calendar = fakeModuleServices();
|
||||
const people = fakeModuleServices();
|
||||
const services: ModuleRuntimeServicesLookup = new Map([
|
||||
["calendar", calendar],
|
||||
["people", people],
|
||||
]);
|
||||
let received: unknown;
|
||||
const { app } = buildModuleApp({
|
||||
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler: (_request, context) => {
|
||||
received = context.services;
|
||||
return Response.json({ ok: true });
|
||||
},
|
||||
method: "GET",
|
||||
moduleId: "calendar",
|
||||
path: "/services",
|
||||
permission: readPermission,
|
||||
}),
|
||||
],
|
||||
services,
|
||||
});
|
||||
|
||||
expect((await app.request("/api/v1/modules/calendar/services")).status).toBe(200);
|
||||
expect(received).toBe(calendar);
|
||||
expect(received).not.toBe(people);
|
||||
});
|
||||
|
||||
it('mounts a "/" route at exactly the module root, never with an added trailing slash', async () => {
|
||||
const handler = vi.fn<ModuleRouteHandler>(async () => new Response("root", { status: 200 }));
|
||||
const { app } = buildModuleApp({
|
||||
@@ -508,7 +539,7 @@ describe("module route authorization", () => {
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expectDatabaseFakeUntouched(services);
|
||||
expectDatabaseFakeUntouched(services as LegacyModuleRuntimeServices);
|
||||
});
|
||||
|
||||
it("does not invoke the handler when principal resolution fails", async () => {
|
||||
@@ -637,7 +668,7 @@ describe("module route trusted-origin enforcement", () => {
|
||||
}
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||
expectDatabaseFakeUntouched(services);
|
||||
expectDatabaseFakeUntouched(services as LegacyModuleRuntimeServices);
|
||||
});
|
||||
|
||||
it("allows a trusted-origin state-changing request through to the handler", async () => {
|
||||
@@ -766,7 +797,7 @@ describe("module route CORS", () => {
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||
expectDatabaseFakeUntouched(services);
|
||||
expectDatabaseFakeUntouched(services as LegacyModuleRuntimeServices);
|
||||
});
|
||||
|
||||
it("never reflects an untrusted origin for a module route", async () => {
|
||||
|
||||
@@ -5,8 +5,6 @@ import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
createAuthenticatedPrincipal,
|
||||
type EkklesiaPrincipal,
|
||||
type ModuleDatabase,
|
||||
type ModuleRuntimeServices,
|
||||
parseEkklesiaUserId,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
@@ -20,6 +18,8 @@ import {
|
||||
createModuleUiPublicationRegistry,
|
||||
type DiscoveredModule,
|
||||
discoverModules,
|
||||
type LegacyModuleRuntimeServices,
|
||||
type ModuleDatabase,
|
||||
type ModuleLifecycleStore,
|
||||
ModuleLifecycleStoreError,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
@@ -79,7 +79,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}),
|
||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||
id: fixture.id,
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
name: `${fixture.id} module`,
|
||||
version: "1.0.0",
|
||||
}),
|
||||
@@ -189,7 +189,7 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
|
||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
moduleServices: Object.freeze({
|
||||
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||
}) as ModuleRuntimeServices,
|
||||
}) as LegacyModuleRuntimeServices,
|
||||
moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
|
||||
@@ -4,9 +4,7 @@ import path from "node:path";
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
createAuthenticatedPrincipal,
|
||||
type ModuleDatabase,
|
||||
type ModuleRealtimePublisher,
|
||||
type ModuleRuntimeServices,
|
||||
parseEkklesiaUserId,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
@@ -18,6 +16,8 @@ import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import {
|
||||
composePlatformPermissionRegistry,
|
||||
discoverModules,
|
||||
type LegacyModuleRuntimeServices,
|
||||
type ModuleDatabase,
|
||||
type ModuleRealtimeRegistry,
|
||||
type ModuleRealtimeSubscription,
|
||||
startDiscoveredModuleRuntime,
|
||||
@@ -188,7 +188,7 @@ async function createFixtureModule(control: FixtureControl): Promise<{
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
id: "announcements",
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
name: "Announcements fixture",
|
||||
permissions: [
|
||||
{
|
||||
@@ -226,7 +226,7 @@ async function createFixtureModule(control: FixtureControl): Promise<{
|
||||
return { directory, symbol };
|
||||
}
|
||||
|
||||
function services(): ModuleRuntimeServices {
|
||||
function services(): LegacyModuleRuntimeServices {
|
||||
return Object.freeze({ database: Object.freeze({}) as ModuleDatabase });
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@ import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
createAuthenticatedPrincipal,
|
||||
type EkklesiaPrincipal,
|
||||
type ModuleDatabase,
|
||||
type ModuleRuntimeServices,
|
||||
parseEkklesiaUserId,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
@@ -18,6 +16,8 @@ import {
|
||||
composePlatformPermissionRegistry,
|
||||
createModuleUiPublicationRegistry,
|
||||
discoverModules,
|
||||
type LegacyModuleRuntimeServices,
|
||||
type ModuleDatabase,
|
||||
startDiscoveredModuleRuntime,
|
||||
} from "@ekklesia/core/modules";
|
||||
import {
|
||||
@@ -129,7 +129,7 @@ async function createFixtureModule(
|
||||
: {}),
|
||||
},
|
||||
id,
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
name,
|
||||
permissions: [
|
||||
{
|
||||
@@ -241,7 +241,7 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
});
|
||||
const services: ModuleRuntimeServices = Object.freeze({
|
||||
const services: LegacyModuleRuntimeServices = Object.freeze({
|
||||
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||
});
|
||||
// The module's real runtime: its server entrypoint is imported, registered, and started, and the
|
||||
|
||||
@@ -5,8 +5,6 @@ import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
createAuthenticatedPrincipal,
|
||||
type EkklesiaPrincipal,
|
||||
type ModuleDatabase,
|
||||
type ModuleRuntimeServices,
|
||||
parseEkklesiaUserId,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
@@ -21,6 +19,8 @@ import {
|
||||
type DiscoveredModule,
|
||||
discoverModules,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
type LegacyModuleRuntimeServices,
|
||||
type ModuleDatabase,
|
||||
} from "@ekklesia/core/modules";
|
||||
import type { Hono } from "hono";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -99,7 +99,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
version: "1.0.0",
|
||||
})),
|
||||
id: fixture.id,
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
name: `${fixture.id} module`,
|
||||
permissions: (fixture.permissions ?? []).map((permission) => ({
|
||||
...permission,
|
||||
@@ -192,7 +192,7 @@ async function install(options: InstallOptions): Promise<Installation> {
|
||||
revokeGrant: vi.fn(),
|
||||
revokePlatformOwner: vi.fn(),
|
||||
} as unknown as AuthorizationStore;
|
||||
const services: ModuleRuntimeServices = Object.freeze({
|
||||
const services: LegacyModuleRuntimeServices = Object.freeze({
|
||||
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||
});
|
||||
const permissionRegistry = composePlatformPermissionRegistry(modules);
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
type ModuleDatabase,
|
||||
type ModuleRuntimeServices,
|
||||
} from "@ekklesia/contracts";
|
||||
import { ANONYMOUS_PRINCIPAL } from "@ekklesia/contracts";
|
||||
import type { AuthorizationEvaluator, AuthorizationStore } from "@ekklesia/core/authorization";
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import {
|
||||
@@ -14,6 +10,8 @@ import {
|
||||
type DiscoveredModule,
|
||||
discoverModules,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
type LegacyModuleRuntimeServices,
|
||||
type ModuleDatabase,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { loadModuleUi } from "@ekklesia/module-ui-host";
|
||||
import { MODULE_UI_ABI_VERSION, NO_MODULE_UI_CONTRIBUTIONS } from "@ekklesia/module-ui-sdk";
|
||||
@@ -139,7 +137,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
version: "1.0.0",
|
||||
})),
|
||||
id: fixture.id,
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
name: `${fixture.id} module`,
|
||||
permissions:
|
||||
fixture.display === undefined
|
||||
@@ -215,7 +213,7 @@ async function install(
|
||||
: modules.filter((module) => publicationModuleIds.includes(module.manifest.id));
|
||||
|
||||
const database = Object.freeze({}) as unknown as ModuleDatabase;
|
||||
const services: ModuleRuntimeServices = Object.freeze({ database });
|
||||
const services: LegacyModuleRuntimeServices = Object.freeze({ database });
|
||||
const runtime: EkklesiaIdentityRuntime = {
|
||||
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
|
||||
// Anonymous, and every authorization decision denied: whatever this route does, it does it
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "@ekklesia/core",
|
||||
"version": "0.1.2",
|
||||
"version": "0.2.1",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"files": [
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
import {
|
||||
type ModuleDatabaseField,
|
||||
ModuleDatabaseError,
|
||||
type ModuleDatabaseValue,
|
||||
} from "@ekklesia/contracts";
|
||||
|
||||
/**
|
||||
* The value contract of Module Database ABI v1 (ADR 0050): how parameters are encoded before they
|
||||
* reach PostgreSQL, and how result text is decoded after. Both are Core's, and neither consults
|
||||
* `pg`'s process-global type registry or any ORM, which module code could change or differ from.
|
||||
*
|
||||
* Every value travels in PostgreSQL's text format. The session settings the decoding depends on
|
||||
* (`DateStyle`, `IntervalStyle`, `TimeZone`, `bytea_output`, `extra_float_digits`) are pinned by the
|
||||
* executor for every ABI operation (`./database-abi.ts`).
|
||||
*/
|
||||
|
||||
/** PostgreSQL's limit on bound parameters in one statement (the protocol's 16-bit count). */
|
||||
export const MODULE_DATABASE_MAX_PARAMETERS = 65_535;
|
||||
/** PostgreSQL's limit on array dimensions (`MAXDIM`). */
|
||||
const maximumArrayDimensions = 6;
|
||||
|
||||
function parameterInvalid(): ModuleDatabaseError {
|
||||
return new ModuleDatabaseError("MODULE_DATABASE_PARAMETER_INVALID");
|
||||
}
|
||||
|
||||
function isScalar(value: unknown): boolean {
|
||||
return (
|
||||
value === null ||
|
||||
typeof value === "boolean" ||
|
||||
typeof value === "number" ||
|
||||
typeof value === "bigint" ||
|
||||
typeof value === "string" ||
|
||||
value instanceof Uint8Array
|
||||
);
|
||||
}
|
||||
|
||||
function scalarText(value: boolean | number | bigint | string): string {
|
||||
// `String` spells NaN, Infinity, and -Infinity exactly as PostgreSQL's float and numeric input
|
||||
// accepts them.
|
||||
return typeof value === "string" ? value : String(value);
|
||||
}
|
||||
|
||||
function byteaText(value: Uint8Array): string {
|
||||
return `\\x${Buffer.from(value.buffer, value.byteOffset, value.byteLength).toString("hex")}`;
|
||||
}
|
||||
|
||||
function quoteArrayElement(text: string): string {
|
||||
return `"${text.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates one array level and returns its PostgreSQL array literal. Every element of a level is
|
||||
* either a sub-array or a scalar, and sibling sub-arrays have equal lengths, because PostgreSQL
|
||||
* arrays are rectangular. Every non-null element is quoted, which PostgreSQL accepts for any element
|
||||
* type.
|
||||
*/
|
||||
function arrayLiteral(values: readonly unknown[], depth: number): string {
|
||||
if (depth > maximumArrayDimensions) {
|
||||
throw parameterInvalid();
|
||||
}
|
||||
const elements: string[] = [];
|
||||
let nestedLength: number | undefined;
|
||||
let nested: boolean | undefined;
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
if (!Object.hasOwn(values, index)) {
|
||||
throw parameterInvalid();
|
||||
}
|
||||
const element = values[index];
|
||||
const isNested = Array.isArray(element);
|
||||
if (nested === undefined) {
|
||||
nested = isNested;
|
||||
} else if (nested !== isNested) {
|
||||
throw parameterInvalid();
|
||||
}
|
||||
if (isNested) {
|
||||
if (nestedLength === undefined) {
|
||||
nestedLength = element.length;
|
||||
} else if (nestedLength !== element.length) {
|
||||
throw parameterInvalid();
|
||||
}
|
||||
elements.push(arrayLiteral(element, depth + 1));
|
||||
} else if (element === null) {
|
||||
elements.push("NULL");
|
||||
} else if (element instanceof Uint8Array) {
|
||||
elements.push(quoteArrayElement(byteaText(element)));
|
||||
} else if (isScalar(element)) {
|
||||
elements.push(quoteArrayElement(scalarText(element as boolean | number | bigint | string)));
|
||||
} else {
|
||||
throw parameterInvalid();
|
||||
}
|
||||
}
|
||||
return `{${elements.join(",")}}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates `values` against the ABI's parameter set and encodes each one into the only forms the
|
||||
* driver then passes through unchanged: `null`, a string, or a `Buffer` copy of a `Uint8Array`.
|
||||
* Anything else, including `undefined`, `Date`, plain objects, other class instances, functions,
|
||||
* symbols, sparse or ragged arrays, and arrays deeper than PostgreSQL allows, is refused before
|
||||
* anything reaches PostgreSQL. The error never names the value.
|
||||
*/
|
||||
export function encodeModuleDatabaseParameters(values: unknown): (string | Buffer | null)[] {
|
||||
if (values === undefined) {
|
||||
return [];
|
||||
}
|
||||
if (!Array.isArray(values) || values.length > MODULE_DATABASE_MAX_PARAMETERS) {
|
||||
throw parameterInvalid();
|
||||
}
|
||||
const encoded: (string | Buffer | null)[] = [];
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
if (!Object.hasOwn(values, index)) {
|
||||
throw parameterInvalid();
|
||||
}
|
||||
const value: unknown = values[index];
|
||||
if (value === null) {
|
||||
encoded.push(null);
|
||||
} else if (value instanceof Uint8Array) {
|
||||
// A copy: the driver serializes when the statement is sent, which may be after the caller
|
||||
// has changed its own bytes.
|
||||
encoded.push(Buffer.from(value));
|
||||
} else if (Array.isArray(value)) {
|
||||
encoded.push(arrayLiteral(value, 1));
|
||||
} else if (isScalar(value)) {
|
||||
encoded.push(scalarText(value as boolean | number | bigint | string));
|
||||
} else {
|
||||
throw parameterInvalid();
|
||||
}
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
|
||||
// --- Results -----------------------------------------------------------------------------------
|
||||
|
||||
type ScalarDecoder = (text: string) => ModuleDatabaseValue;
|
||||
|
||||
const asText: ScalarDecoder = (text) => text;
|
||||
const asBoolean: ScalarDecoder = (text) => text === "t";
|
||||
const asInteger: ScalarDecoder = (text) => Number.parseInt(text, 10);
|
||||
// `Number` reads PostgreSQL's NaN, Infinity, -Infinity, and exponent forms. With
|
||||
// `extra_float_digits` pinned to 1 the text is the shortest form that round-trips exactly.
|
||||
const asFloat: ScalarDecoder = (text) => Number(text);
|
||||
|
||||
function hexDigit(code: number): number {
|
||||
if (code >= 48 && code <= 57) {
|
||||
return code - 48;
|
||||
}
|
||||
if (code >= 97 && code <= 102) {
|
||||
return code - 87;
|
||||
}
|
||||
if (code >= 65 && code <= 70) {
|
||||
return code - 55;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* `bytea` text to bytes. The executor pins `bytea_output` to `hex`; the older `escape` form is read
|
||||
* too, so a transaction that changed the setting still decodes faithfully rather than failing.
|
||||
* Always a plain `Uint8Array`, never a `Buffer`.
|
||||
*/
|
||||
const asBytes: ScalarDecoder = (text) => {
|
||||
if (text.startsWith("\\x")) {
|
||||
const length = (text.length - 2) / 2;
|
||||
const bytes = new Uint8Array(length);
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const high = hexDigit(text.charCodeAt(2 + index * 2));
|
||||
const low = hexDigit(text.charCodeAt(3 + index * 2));
|
||||
bytes[index] = high * 16 + low;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
const bytes: number[] = [];
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const code = text.charCodeAt(index);
|
||||
if (code === 92 && text.charCodeAt(index + 1) === 92) {
|
||||
bytes.push(92);
|
||||
index += 1;
|
||||
} else if (code === 92) {
|
||||
bytes.push(Number.parseInt(text.slice(index + 1, index + 4), 8));
|
||||
index += 3;
|
||||
} else {
|
||||
bytes.push(code);
|
||||
}
|
||||
}
|
||||
return Uint8Array.from(bytes);
|
||||
};
|
||||
|
||||
/** Built-in scalar type OIDs with a decoding other than "PostgreSQL's text output, unchanged". Every
|
||||
* type not listed here, `int8`, `numeric`, date and time types, `json`, and `jsonb` included,
|
||||
* arrives as its exact text. */
|
||||
const scalarDecoders: ReadonlyMap<number, ScalarDecoder> = new Map([
|
||||
[16, asBoolean], // bool
|
||||
[17, asBytes], // bytea
|
||||
[21, asInteger], // int2
|
||||
[23, asInteger], // int4
|
||||
[26, asInteger], // oid
|
||||
[700, asFloat], // float4
|
||||
[701, asFloat], // float8
|
||||
]);
|
||||
|
||||
/** Built-in array type OIDs, each with its element type OID. An array of a type not listed here
|
||||
* (an enum, a domain, an extension type) arrives as its text, like any other type. */
|
||||
const arrayElementTypes: ReadonlyMap<number, number> = new Map([
|
||||
[1000, 16], // bool[]
|
||||
[1001, 17], // bytea[]
|
||||
[1002, 18], // "char"[]
|
||||
[1003, 19], // name[]
|
||||
[1005, 21], // int2[]
|
||||
[1007, 23], // int4[]
|
||||
[1009, 25], // text[]
|
||||
[1014, 1042], // bpchar[]
|
||||
[1015, 1043], // varchar[]
|
||||
[1016, 20], // int8[]
|
||||
[1021, 700], // float4[]
|
||||
[1022, 701], // float8[]
|
||||
[1028, 26], // oid[]
|
||||
[1115, 1114], // timestamp[]
|
||||
[1182, 1082], // date[]
|
||||
[1183, 1083], // time[]
|
||||
[1185, 1184], // timestamptz[]
|
||||
[1187, 1186], // interval[]
|
||||
[1231, 1700], // numeric[]
|
||||
[1270, 1266], // timetz[]
|
||||
[2951, 2950], // uuid[]
|
||||
[199, 114], // json[]
|
||||
[3807, 3802], // jsonb[]
|
||||
]);
|
||||
|
||||
class ArrayTextError extends Error {}
|
||||
|
||||
/**
|
||||
* Parses PostgreSQL's array text output into nested arrays, each element decoded by `decode`.
|
||||
* Unquoted `NULL` is null; a quoted `"NULL"` is the string. A non-default lower bound
|
||||
* (`[0:1]={…}`) is not represented: a JavaScript array is a zero-based list. Every element type this
|
||||
* decodes uses `,` as its delimiter.
|
||||
*/
|
||||
export function parsePostgresArrayText(text: string, decode: ScalarDecoder): ModuleDatabaseValue[] {
|
||||
let position = 0;
|
||||
if (text.startsWith("[")) {
|
||||
const equals = text.indexOf("=");
|
||||
if (equals === -1) {
|
||||
throw new ArrayTextError();
|
||||
}
|
||||
position = equals + 1;
|
||||
}
|
||||
|
||||
function parseArray(): ModuleDatabaseValue[] {
|
||||
if (text[position] !== "{") {
|
||||
throw new ArrayTextError();
|
||||
}
|
||||
position += 1;
|
||||
const items: ModuleDatabaseValue[] = [];
|
||||
if (text[position] === "}") {
|
||||
position += 1;
|
||||
return items;
|
||||
}
|
||||
for (;;) {
|
||||
const character = text[position];
|
||||
if (character === "{") {
|
||||
items.push(parseArray());
|
||||
} else if (character === '"') {
|
||||
position += 1;
|
||||
let element = "";
|
||||
for (;;) {
|
||||
const next = text[position];
|
||||
if (next === undefined) {
|
||||
throw new ArrayTextError();
|
||||
}
|
||||
if (next === "\\") {
|
||||
element += text[position + 1] ?? "";
|
||||
position += 2;
|
||||
} else if (next === '"') {
|
||||
position += 1;
|
||||
break;
|
||||
} else {
|
||||
element += next;
|
||||
position += 1;
|
||||
}
|
||||
}
|
||||
items.push(decode(element));
|
||||
} else {
|
||||
const start = position;
|
||||
while (position < text.length && text[position] !== "," && text[position] !== "}") {
|
||||
position += 1;
|
||||
}
|
||||
const element = text.slice(start, position);
|
||||
items.push(element.toUpperCase() === "NULL" ? null : decode(element));
|
||||
}
|
||||
if (text[position] === ",") {
|
||||
position += 1;
|
||||
} else if (text[position] === "}") {
|
||||
position += 1;
|
||||
return items;
|
||||
} else {
|
||||
throw new ArrayTextError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = parseArray();
|
||||
if (position !== text.length) {
|
||||
throw new ArrayTextError();
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function scalarDecoder(typeOid: number): ScalarDecoder {
|
||||
return scalarDecoders.get(typeOid) ?? asText;
|
||||
}
|
||||
|
||||
/** Decodes one non-null column value from its PostgreSQL text by the column's type OID. */
|
||||
export function decodeModuleDatabaseValue(typeOid: number, text: string): ModuleDatabaseValue {
|
||||
const elementType = arrayElementTypes.get(typeOid);
|
||||
if (elementType === undefined) {
|
||||
return scalarDecoder(typeOid)(text);
|
||||
}
|
||||
try {
|
||||
return parsePostgresArrayText(text, scalarDecoder(elementType));
|
||||
} catch {
|
||||
// PostgreSQL produced this text, so it cannot be malformed; should it ever be, the column still
|
||||
// arrives faithfully, as text, rather than as a guess.
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `types` object handed to `pg` for every ABI statement. Its parser returns the raw text for
|
||||
* every type, so the driver's global registry, which module code could change, is never consulted
|
||||
* and the ABI decodes from text itself. It is an empty array as well because the driver also reads
|
||||
* the same option as the Parse message's parameter-type list: empty, so PostgreSQL infers every
|
||||
* parameter type from the statement.
|
||||
*/
|
||||
export const rawTextTypes: { getTypeParser: () => (text: string) => string } & never[] =
|
||||
Object.freeze(
|
||||
Object.assign([] as never[], {
|
||||
getTypeParser: () => (text: string) => text,
|
||||
}),
|
||||
);
|
||||
|
||||
/** Decodes one positional row of raw driver text. */
|
||||
export function decodeModuleDatabaseRow(
|
||||
fields: readonly ModuleDatabaseField[],
|
||||
raw: readonly unknown[],
|
||||
): ModuleDatabaseValue[] {
|
||||
return fields.map((field, index) => {
|
||||
const value = raw[index];
|
||||
return typeof value === "string" ? decodeModuleDatabaseValue(field.typeOid, value) : null;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,596 @@
|
||||
import {
|
||||
MODULE_DATABASE_ABI_VERSION,
|
||||
type ModuleDatabaseArrayRow,
|
||||
ModuleDatabaseError,
|
||||
type ModuleDatabaseErrorCode,
|
||||
type ModuleDatabaseExecutor,
|
||||
type ModuleDatabaseField,
|
||||
type ModuleDatabaseObjectRow,
|
||||
type ModuleDatabaseQueryable,
|
||||
type ModuleDatabaseQueryResult,
|
||||
type ModuleDatabaseTransaction,
|
||||
type ModuleId,
|
||||
moduleIdSchema,
|
||||
} from "@ekklesia/contracts";
|
||||
import { DatabaseError, type Pool, type PoolClient, type QueryArrayConfig } from "pg";
|
||||
import {
|
||||
decodeModuleDatabaseRow,
|
||||
encodeModuleDatabaseParameters,
|
||||
rawTextTypes,
|
||||
} from "./database-abi-values.js";
|
||||
|
||||
/**
|
||||
* Core's Module Database ABI v1 executor (ADR 0050).
|
||||
*
|
||||
* A module hands Core SQL text, ordered parameter values, and a row mode; Core hands back decoded
|
||||
* rows, column metadata, and stable errors. No ORM, driver, pool, client, or connection object ever
|
||||
* crosses in either direction. The executor is built over the one pool the API already owns
|
||||
* (ADR 0016) and never closes it.
|
||||
*
|
||||
* This is a correctness and compatibility boundary, **not a security sandbox**: a trusted module can
|
||||
* still send any SQL its database role permits. Nothing here inspects table names.
|
||||
*
|
||||
* ### Connections and session state
|
||||
*
|
||||
* Every ABI operation, a top-level statement or a whole transaction, borrows a connection from the
|
||||
* pool, pins the session settings the ABI's decoding depends on, does its work, and resets the
|
||||
* session with `DISCARD ALL` before returning the connection. So no setting, temporary table,
|
||||
* listener, advisory lock, prepared statement, or role change a module makes survives the operation
|
||||
* that made it, and nothing the ABI pinned reaches the pool's other users. A connection that cannot
|
||||
* be reset cleanly, or that is not idle when the operation ends, is destroyed rather than returned.
|
||||
* `DISCARD ALL` also deallocates named prepared statements; Core uses none on the shared pool.
|
||||
*
|
||||
* ### One statement per `query`
|
||||
*
|
||||
* Every ABI statement is sent with PostgreSQL's extended query protocol, forced with the driver's
|
||||
* `queryMode: "extended"` even when there are no parameters. PostgreSQL itself refuses a Parse
|
||||
* message holding more than one statement (SQLSTATE 42601), using its own SQL grammar, so comments,
|
||||
* quoted literals, and semicolons inside them are never mistaken for a second statement. Core does
|
||||
* not parse SQL. A statement text that holds no statement at all is refused after PostgreSQL reports
|
||||
* it empty.
|
||||
*
|
||||
* ### Transaction control belongs to the callbacks
|
||||
*
|
||||
* Transaction-control SQL (`BEGIN`, `START TRANSACTION`, `COMMIT`, `ROLLBACK`, `SAVEPOINT`,
|
||||
* `RELEASE SAVEPOINT`, `PREPARE TRANSACTION`, and their aliases) is forbidden through `query`: a
|
||||
* conformance rule for trusted module code, not a security guarantee. Core cannot refuse such a
|
||||
* statement beforehand without parsing SQL, which it does not do, and it cannot undo one PostgreSQL
|
||||
* has already executed: a `COMMIT` sent through a transaction's `query` has committed by the time
|
||||
* Core sees it. What Core does is detect the violation afterwards, from what PostgreSQL itself
|
||||
* reports (the statement's command tag, and the protocol's transaction status), and fail safely:
|
||||
* the call rejects, an enclosing transaction is never committed by Core and rejects too, and the
|
||||
* connection is destroyed rather than returned to the pool. The commit and rollback guarantees of
|
||||
* the transaction API apply to conforming use.
|
||||
*/
|
||||
|
||||
/** The settings the ABI's decoding and determinism depend on, pinned for every ABI operation. */
|
||||
const pinSessionStatement =
|
||||
"SET TimeZone TO 'UTC'; SET DateStyle TO 'ISO, MDY'; SET IntervalStyle TO 'postgres'; " +
|
||||
"SET bytea_output TO 'hex'; SET extra_float_digits TO 1";
|
||||
const resetSessionStatement = "DISCARD ALL";
|
||||
|
||||
const transactionControlCommands: ReadonlySet<string> = new Set([
|
||||
"BEGIN",
|
||||
"COMMIT",
|
||||
"PREPARE",
|
||||
"RELEASE",
|
||||
"ROLLBACK",
|
||||
"SAVEPOINT",
|
||||
"START",
|
||||
]);
|
||||
|
||||
const isolationStatements: ReadonlyMap<unknown, string> = new Map([
|
||||
["read committed", "READ COMMITTED"],
|
||||
["repeatable read", "REPEATABLE READ"],
|
||||
["serializable", "SERIALIZABLE"],
|
||||
]);
|
||||
|
||||
/** `pg` 8.22 reports the protocol's ReadyForQuery transaction status through this method, which
|
||||
* its type declarations omit. `acquire` refuses a client without it rather than guessing. */
|
||||
type AbiClient = PoolClient & { getTransactionStatus(): string | null };
|
||||
|
||||
interface PreparedQuery {
|
||||
readonly config: QueryArrayConfig;
|
||||
readonly rowMode: "array" | "object";
|
||||
}
|
||||
|
||||
interface RawResult {
|
||||
readonly command: string | null;
|
||||
readonly fields: readonly { readonly dataTypeID: number; readonly name: string }[];
|
||||
readonly rowCount: number | null;
|
||||
readonly rows: readonly unknown[][];
|
||||
}
|
||||
|
||||
function databaseError(code: ModuleDatabaseErrorCode, cause?: unknown): ModuleDatabaseError {
|
||||
// Only PostgreSQL's own SQLSTATE survives. The driver's message, detail, hint, position, and
|
||||
// object names, and every non-PostgreSQL error, are dropped here.
|
||||
if (cause instanceof DatabaseError && typeof cause.code === "string") {
|
||||
return new ModuleDatabaseError(code, { sqlState: cause.code });
|
||||
}
|
||||
return new ModuleDatabaseError(code);
|
||||
}
|
||||
|
||||
/** A PostgreSQL error leaves the connection usable; anything else (a dropped connection, a
|
||||
* client-side timeout) may leave it mid-protocol, so it must be destroyed. */
|
||||
function connectionSurvives(error: unknown): boolean {
|
||||
return error instanceof DatabaseError;
|
||||
}
|
||||
|
||||
function prepareQuery(request: unknown): PreparedQuery {
|
||||
if (typeof request !== "object" || request === null || Array.isArray(request)) {
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
||||
}
|
||||
for (const key of Object.keys(request)) {
|
||||
if (key !== "text" && key !== "values" && key !== "rowMode") {
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
||||
}
|
||||
}
|
||||
const { rowMode, text, values } = request as Record<string, unknown>;
|
||||
if (typeof text !== "string" || text.length === 0) {
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
||||
}
|
||||
if (rowMode !== undefined && rowMode !== "object" && rowMode !== "array") {
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
||||
}
|
||||
const config = {
|
||||
queryMode: "extended",
|
||||
rowMode: "array",
|
||||
text,
|
||||
types: rawTextTypes,
|
||||
values: encodeModuleDatabaseParameters(values),
|
||||
};
|
||||
return { config: config as unknown as QueryArrayConfig, rowMode: rowMode ?? "object" };
|
||||
}
|
||||
|
||||
function beginStatement(options: unknown): string {
|
||||
if (options === undefined) {
|
||||
return "BEGIN ISOLATION LEVEL READ COMMITTED READ WRITE";
|
||||
}
|
||||
if (typeof options !== "object" || options === null || Array.isArray(options)) {
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_OPTION_INVALID");
|
||||
}
|
||||
for (const key of Object.keys(options)) {
|
||||
if (key !== "isolation" && key !== "readOnly") {
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_OPTION_INVALID");
|
||||
}
|
||||
}
|
||||
const { isolation, readOnly } = options as Record<string, unknown>;
|
||||
const level = isolation === undefined ? "READ COMMITTED" : isolationStatements.get(isolation);
|
||||
if (level === undefined || (readOnly !== undefined && typeof readOnly !== "boolean")) {
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_OPTION_INVALID");
|
||||
}
|
||||
return `BEGIN ISOLATION LEVEL ${level} ${readOnly === true ? "READ ONLY" : "READ WRITE"}`;
|
||||
}
|
||||
|
||||
function buildResult(
|
||||
raw: RawResult,
|
||||
rowMode: "array" | "object",
|
||||
): ModuleDatabaseQueryResult<ModuleDatabaseArrayRow | ModuleDatabaseObjectRow> {
|
||||
if (typeof raw.command !== "string" || raw.command === "") {
|
||||
// PostgreSQL answered with EmptyQueryResponse: the text held no statement.
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
||||
}
|
||||
const fields: readonly ModuleDatabaseField[] = Object.freeze(
|
||||
raw.fields.map((field) => Object.freeze({ name: field.name, typeOid: field.dataTypeID })),
|
||||
);
|
||||
const decoded = raw.rows.map((row) => decodeModuleDatabaseRow(fields, row));
|
||||
let rows: readonly (ModuleDatabaseArrayRow | ModuleDatabaseObjectRow)[];
|
||||
if (rowMode === "array") {
|
||||
rows = decoded;
|
||||
} else {
|
||||
if (new Set(fields.map((field) => field.name)).size !== fields.length) {
|
||||
// Object rows cannot hold two columns with one name; refusing beats dropping one.
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
||||
}
|
||||
// `Object.fromEntries` defines properties, so a column named `__proto__` stays a column.
|
||||
rows = decoded.map((row) =>
|
||||
Object.fromEntries(fields.map((field, index) => [field.name, row[index] ?? null])),
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
command: raw.command,
|
||||
fields,
|
||||
rowCount: raw.rowCount ?? null,
|
||||
rows: Object.freeze(rows),
|
||||
});
|
||||
}
|
||||
|
||||
/** A borrowed client's `error` events are already reported through the statement they interrupt.
|
||||
* The pool only listens to idle clients, so without this a connection the server terminates while
|
||||
* borrowed would surface as an unhandled `error` event. */
|
||||
function ignoreClientError(): void {}
|
||||
|
||||
function destroy(client: AbiClient): void {
|
||||
client.removeListener("error", ignoreClientError);
|
||||
client.release(true);
|
||||
}
|
||||
|
||||
async function acquire(pool: Pool, failure: ModuleDatabaseErrorCode): Promise<AbiClient> {
|
||||
let client: AbiClient;
|
||||
try {
|
||||
client = (await pool.connect()) as AbiClient;
|
||||
} catch (error) {
|
||||
throw databaseError(failure, error);
|
||||
}
|
||||
client.on("error", ignoreClientError);
|
||||
if (typeof client.getTransactionStatus !== "function") {
|
||||
destroy(client);
|
||||
throw new ModuleDatabaseError(failure);
|
||||
}
|
||||
try {
|
||||
await client.query(pinSessionStatement);
|
||||
} catch (error) {
|
||||
destroy(client);
|
||||
throw databaseError(failure, error);
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
/** Returns a connection to the pool only when it is idle and resets cleanly; destroys it
|
||||
* otherwise. Never throws. */
|
||||
async function relinquish(client: AbiClient, healthy: boolean): Promise<void> {
|
||||
if (!healthy || client.getTransactionStatus() !== "I") {
|
||||
destroy(client);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await client.query(resetSessionStatement);
|
||||
} catch {
|
||||
destroy(client);
|
||||
return;
|
||||
}
|
||||
client.removeListener("error", ignoreClientError);
|
||||
client.release();
|
||||
}
|
||||
|
||||
/** One open scope: a transaction or a savepoint inside it. */
|
||||
interface Scope {
|
||||
activeChild: Scope | undefined;
|
||||
readonly children: Set<Scope>;
|
||||
closed: boolean;
|
||||
/** Operations started through this scope's executor that have not settled yet. */
|
||||
pending: number;
|
||||
}
|
||||
|
||||
interface TransactionState {
|
||||
readonly client: AbiClient;
|
||||
healthy: boolean;
|
||||
/** Set when the transaction can no longer be committed: transaction control sent through
|
||||
* `query`, or a savepoint that could not be undone. */
|
||||
failure: ModuleDatabaseError | undefined;
|
||||
finished: boolean;
|
||||
/** Settled-never-rejecting shadows of every statement sent on the connection. */
|
||||
readonly inflight: Set<Promise<void>>;
|
||||
savepoints: number;
|
||||
}
|
||||
|
||||
function newScope(parent?: Scope): Scope {
|
||||
const scope: Scope = { activeChild: undefined, children: new Set(), closed: false, pending: 0 };
|
||||
parent?.children.add(scope);
|
||||
return scope;
|
||||
}
|
||||
|
||||
function closeScope(scope: Scope): void {
|
||||
scope.closed = true;
|
||||
for (const child of scope.children) {
|
||||
closeScope(child);
|
||||
}
|
||||
}
|
||||
|
||||
function assertActive(scope: Scope): void {
|
||||
if (scope.closed) {
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_CLOSED");
|
||||
}
|
||||
if (scope.activeChild !== undefined) {
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_INACTIVE");
|
||||
}
|
||||
}
|
||||
|
||||
/** Once a transaction can no longer be committed, its scopes send nothing more. After a
|
||||
* transaction-control violation in particular, a later statement would otherwise run outside any
|
||||
* transaction, as its own autocommitted statement. */
|
||||
function assertUsable(state: TransactionState): void {
|
||||
if (state.failure !== undefined) {
|
||||
throw new ModuleDatabaseError(state.failure.code);
|
||||
}
|
||||
}
|
||||
|
||||
function track<T>(state: TransactionState, promise: Promise<T>): Promise<T> {
|
||||
const shadow = promise.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
state.inflight.add(shadow);
|
||||
void shadow.then(() => state.inflight.delete(shadow));
|
||||
return promise;
|
||||
}
|
||||
|
||||
/** Waits until every statement already sent has settled, including any sent while waiting. */
|
||||
async function drain(state: TransactionState): Promise<void> {
|
||||
while (state.inflight.size > 0) {
|
||||
await Promise.all([...state.inflight]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs one of Core's own transaction-control statements. Refuses, without sending anything, once
|
||||
* the transaction has finished. */
|
||||
async function control(state: TransactionState, statement: string): Promise<void> {
|
||||
if (state.finished) {
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_CLOSED");
|
||||
}
|
||||
try {
|
||||
await track(state, state.client.query(statement));
|
||||
} catch (error) {
|
||||
if (!connectionSurvives(error)) {
|
||||
state.healthy = false;
|
||||
}
|
||||
throw databaseError("MODULE_DATABASE_TRANSACTION_FAILED", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function rollback(state: TransactionState): Promise<void> {
|
||||
try {
|
||||
await state.client.query("ROLLBACK");
|
||||
} catch {
|
||||
state.healthy = false;
|
||||
}
|
||||
}
|
||||
|
||||
type Outcome<T> = { readonly value: T } | { readonly error: unknown };
|
||||
|
||||
async function settle<T>(run: () => Promise<T> | T): Promise<Outcome<T>> {
|
||||
try {
|
||||
return { value: await run() };
|
||||
} catch (error) {
|
||||
return { error };
|
||||
}
|
||||
}
|
||||
|
||||
const moduleByExecutor = new WeakMap<object, ModuleId>();
|
||||
|
||||
/** Which module an ABI executor, or a transaction or savepoint executor derived from it, belongs
|
||||
* to. For Core's own logging, diagnostics, and attribution; never exposed to modules. */
|
||||
export function moduleDatabaseExecutorModuleId(executor: object): ModuleId | undefined {
|
||||
return moduleByExecutor.get(executor);
|
||||
}
|
||||
|
||||
export interface ModuleDatabaseExecutorOptions {
|
||||
/** The module this executor serves. Carried for attribution only: the ABI enforces no table
|
||||
* ownership at run time (ADR 0050). */
|
||||
readonly moduleId: ModuleId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates one module's ABI v1 executor over the API's existing pool. Synchronous and free of I/O:
|
||||
* nothing touches the database until the module calls it. The returned object, and every
|
||||
* transaction and savepoint executor it hands out, is frozen and holds exactly the ABI's members.
|
||||
*/
|
||||
export function createModuleDatabaseExecutor(
|
||||
pool: Pool,
|
||||
options: ModuleDatabaseExecutorOptions,
|
||||
): ModuleDatabaseExecutor {
|
||||
const parsedModuleId = moduleIdSchema.safeParse(options.moduleId);
|
||||
if (!parsedModuleId.success) {
|
||||
throw new TypeError("A module database executor needs a valid module ID.");
|
||||
}
|
||||
const moduleId = parsedModuleId.data;
|
||||
|
||||
async function query(
|
||||
request: unknown,
|
||||
): Promise<ModuleDatabaseQueryResult<ModuleDatabaseArrayRow | ModuleDatabaseObjectRow>> {
|
||||
const prepared = prepareQuery(request);
|
||||
const client = await acquire(pool, "MODULE_DATABASE_QUERY_FAILED");
|
||||
let healthy = true;
|
||||
try {
|
||||
let raw: RawResult;
|
||||
try {
|
||||
raw = (await client.query(prepared.config)) as unknown as RawResult;
|
||||
} catch (error) {
|
||||
healthy = connectionSurvives(error);
|
||||
throw databaseError("MODULE_DATABASE_QUERY_FAILED", error);
|
||||
}
|
||||
if (
|
||||
(raw.command !== null && transactionControlCommands.has(raw.command)) ||
|
||||
client.getTransactionStatus() !== "I"
|
||||
) {
|
||||
// A transaction-control violation. PostgreSQL has already run it; the connection is
|
||||
// destroyed below rather than returned in a state Core did not choose.
|
||||
healthy = false;
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
||||
}
|
||||
return buildResult(raw, prepared.rowMode);
|
||||
} finally {
|
||||
await relinquish(client, healthy);
|
||||
}
|
||||
}
|
||||
|
||||
function scopeExecutor(state: TransactionState, scope: Scope): ModuleDatabaseTransaction {
|
||||
async function scopeQuery(
|
||||
request: unknown,
|
||||
): Promise<ModuleDatabaseQueryResult<ModuleDatabaseArrayRow | ModuleDatabaseObjectRow>> {
|
||||
assertActive(scope);
|
||||
assertUsable(state);
|
||||
const prepared = prepareQuery(request);
|
||||
scope.pending += 1;
|
||||
try {
|
||||
let raw: RawResult;
|
||||
try {
|
||||
raw = (await track(state, state.client.query(prepared.config))) as unknown as RawResult;
|
||||
} catch (error) {
|
||||
if (!connectionSurvives(error)) {
|
||||
state.healthy = false;
|
||||
}
|
||||
throw databaseError("MODULE_DATABASE_QUERY_FAILED", error);
|
||||
}
|
||||
if (
|
||||
(raw.command !== null && transactionControlCommands.has(raw.command)) ||
|
||||
state.client.getTransactionStatus() === "I"
|
||||
) {
|
||||
// A transaction-control violation. PostgreSQL has already run it, and Core cannot undo
|
||||
// that; it can only never commit this transaction itself, and destroy the connection.
|
||||
state.failure ??= new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
||||
state.healthy = false;
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
||||
}
|
||||
return buildResult(raw, prepared.rowMode);
|
||||
} finally {
|
||||
scope.pending -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
async function savepoint<T>(
|
||||
callback: (savepoint: ModuleDatabaseTransaction) => Promise<T> | T,
|
||||
): Promise<T> {
|
||||
assertActive(scope);
|
||||
assertUsable(state);
|
||||
if (typeof callback !== "function") {
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
||||
}
|
||||
scope.pending += 1;
|
||||
const child = newScope(scope);
|
||||
scope.activeChild = child;
|
||||
state.savepoints += 1;
|
||||
// Core's own identifier: a fixed prefix and a counter, never anything a module supplied.
|
||||
const name = `ekklesia_savepoint_${state.savepoints}`;
|
||||
try {
|
||||
await control(state, `SAVEPOINT ${name}`);
|
||||
if (child.closed) {
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_CLOSED");
|
||||
}
|
||||
const outcome = await settle(() => callback(scopeExecutor(state, child)));
|
||||
const outstanding = child.pending > 0;
|
||||
closeScope(child);
|
||||
await drain(state);
|
||||
if (scope.closed) {
|
||||
// The enclosing transaction finished first; it rolls back, and nothing more is sent.
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_CLOSED");
|
||||
}
|
||||
if ("error" in outcome) {
|
||||
// A rejected callback's own error stays the error, even if sibling work it started was
|
||||
// still settling: that work has been drained, and is undone here with everything else.
|
||||
try {
|
||||
await control(state, `ROLLBACK TO SAVEPOINT ${name}`);
|
||||
await control(state, `RELEASE SAVEPOINT ${name}`);
|
||||
} catch (error) {
|
||||
state.failure ??= new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_FAILED");
|
||||
throw error;
|
||||
}
|
||||
throw outcome.error;
|
||||
}
|
||||
if (outstanding) {
|
||||
// A callback that resolved with work outstanding forgot an await. Roll back to the
|
||||
// savepoint and never release it.
|
||||
try {
|
||||
await control(state, `ROLLBACK TO SAVEPOINT ${name}`);
|
||||
} catch (error) {
|
||||
state.failure ??= new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_FAILED");
|
||||
throw error;
|
||||
}
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_WORK_OUTSTANDING");
|
||||
}
|
||||
try {
|
||||
await control(state, `RELEASE SAVEPOINT ${name}`);
|
||||
} catch (releaseError) {
|
||||
// Typically a failed statement left the transaction aborted. Undo the savepoint so the
|
||||
// transaction stays usable, and report that this savepoint did not complete.
|
||||
try {
|
||||
await control(state, `ROLLBACK TO SAVEPOINT ${name}`);
|
||||
await control(state, `RELEASE SAVEPOINT ${name}`);
|
||||
} catch {
|
||||
state.failure ??= new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_FAILED");
|
||||
}
|
||||
throw releaseError;
|
||||
}
|
||||
return (outcome as { readonly value: T }).value;
|
||||
} finally {
|
||||
closeScope(child);
|
||||
if (scope.activeChild === child) {
|
||||
scope.activeChild = undefined;
|
||||
}
|
||||
scope.pending -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
const executor = Object.freeze({
|
||||
abiVersion: MODULE_DATABASE_ABI_VERSION,
|
||||
query: scopeQuery as ModuleDatabaseQueryable["query"],
|
||||
savepoint,
|
||||
});
|
||||
moduleByExecutor.set(executor, moduleId);
|
||||
return executor;
|
||||
}
|
||||
|
||||
async function transaction<T>(
|
||||
callback: (transaction: ModuleDatabaseTransaction) => Promise<T> | T,
|
||||
options?: unknown,
|
||||
): Promise<T> {
|
||||
if (typeof callback !== "function") {
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
||||
}
|
||||
const begin = beginStatement(options);
|
||||
const client = await acquire(pool, "MODULE_DATABASE_TRANSACTION_FAILED");
|
||||
const state: TransactionState = {
|
||||
client,
|
||||
failure: undefined,
|
||||
finished: false,
|
||||
healthy: true,
|
||||
inflight: new Set(),
|
||||
savepoints: 0,
|
||||
};
|
||||
try {
|
||||
try {
|
||||
await client.query(begin);
|
||||
} catch (error) {
|
||||
state.healthy = connectionSurvives(error);
|
||||
throw databaseError("MODULE_DATABASE_TRANSACTION_FAILED", error);
|
||||
}
|
||||
const scope = newScope();
|
||||
const outcome = await settle(() => callback(scopeExecutor(state, scope)));
|
||||
const outstanding = scope.pending > 0;
|
||||
closeScope(scope);
|
||||
await drain(state);
|
||||
state.finished = true;
|
||||
|
||||
if ("error" in outcome) {
|
||||
// The callback's own error stays primary, even when sibling work it started (say, the
|
||||
// other half of a `Promise.all`) was still settling. That work has been drained, and rolls
|
||||
// back with the rest; outstanding work never leads to a commit.
|
||||
await rollback(state);
|
||||
throw outcome.error;
|
||||
}
|
||||
if (outstanding) {
|
||||
// The callback resolved with work it never awaited: a forgotten `await`.
|
||||
await rollback(state);
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_WORK_OUTSTANDING");
|
||||
}
|
||||
if (state.failure !== undefined) {
|
||||
await rollback(state);
|
||||
throw state.failure;
|
||||
}
|
||||
let committed: { readonly command: string | null };
|
||||
try {
|
||||
committed = await client.query("COMMIT");
|
||||
} catch (error) {
|
||||
state.healthy = connectionSurvives(error);
|
||||
throw databaseError("MODULE_DATABASE_TRANSACTION_FAILED", error);
|
||||
}
|
||||
if (committed.command !== "COMMIT") {
|
||||
// PostgreSQL answers COMMIT with ROLLBACK when a failed statement had aborted the
|
||||
// transaction. Nothing was committed, and PostgreSQL supplied no SQLSTATE to report.
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_FAILED");
|
||||
}
|
||||
return outcome.value;
|
||||
} finally {
|
||||
state.finished = true;
|
||||
await relinquish(client, state.healthy);
|
||||
}
|
||||
}
|
||||
|
||||
const executor = Object.freeze({
|
||||
abiVersion: MODULE_DATABASE_ABI_VERSION,
|
||||
query: query as ModuleDatabaseQueryable["query"],
|
||||
transaction: transaction as ModuleDatabaseExecutor["transaction"],
|
||||
});
|
||||
moduleByExecutor.set(executor, moduleId);
|
||||
return executor;
|
||||
}
|
||||
@@ -1,7 +1,54 @@
|
||||
import type { ModuleDatabase } from "@ekklesia/contracts";
|
||||
import { drizzle } from "drizzle-orm/node-postgres";
|
||||
import type { ModuleDatabaseAbiRuntimeServices, ModuleRuntimeServices } from "@ekklesia/contracts";
|
||||
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||
import type { Pool } from "pg";
|
||||
|
||||
/**
|
||||
* A trusted backend module's typed Drizzle query surface — the same query-building methods a
|
||||
* platform-owned Drizzle instance exposes, restricted to exactly the operations a module needs to
|
||||
* read and write its own tables. Derived with `Pick` from the real `drizzle-orm/node-postgres`
|
||||
* database type rather than hand-declared, so every method keeps Drizzle's own table-aware
|
||||
* argument and result inference exactly as-is — a module calling `database.select().from(table)`
|
||||
* or `database.insert(table).values(...)` gets the identical typed builder a direct Drizzle caller
|
||||
* would, with no `any`/`unknown` erasure anywhere in the chain.
|
||||
*
|
||||
* Deliberately excludes everything `NodePgDatabase` or the `drizzle(...)` factory otherwise
|
||||
* exposes: `$client` (the raw `pg` client/pool), `_` (internal session/dialect/schema state),
|
||||
* `query` (a relational-query builder tied to one schema, which would have to span every
|
||||
* installed module's tables to exist here at all), `$cache`, `$count`, `$with`, `with`, and
|
||||
* `refreshMaterializedView`. There is no connection string, pool, session, or dialect object
|
||||
* reachable through this type. Carries no schema generic — a module brings its own Drizzle table
|
||||
* objects to each call, so there is no global relational schema spanning every installed module for
|
||||
* this type to leak.
|
||||
*
|
||||
* Core's own, private type (ADR 0053): it moved here from `@ekklesia/contracts` so no published
|
||||
* package references Drizzle. New modules use the Module Database ABI v1 instead (ADR 0050), and
|
||||
* the Module SDK never exposes this facade's type.
|
||||
*
|
||||
* This is an integration boundary, not a security sandbox: modules are trusted, compiled backend
|
||||
* plugins that already run arbitrary code in this process, exactly like every other lifecycle hook
|
||||
* and HTTP handler they define. `ModuleDatabase` exists to keep a module's code Drizzle-typed and
|
||||
* decoupled from how the platform physically connects to PostgreSQL — not to defend against a
|
||||
* malicious module, which this facade cannot do and does not attempt to do.
|
||||
*/
|
||||
export type ModuleDatabase = Pick<
|
||||
NodePgDatabase,
|
||||
| "select"
|
||||
| "selectDistinct"
|
||||
| "selectDistinctOn"
|
||||
| "insert"
|
||||
| "update"
|
||||
| "delete"
|
||||
| "execute"
|
||||
| "transaction"
|
||||
>;
|
||||
|
||||
/** The services a legacy module's hooks and handlers receive: the legacy facade. */
|
||||
export type LegacyModuleRuntimeServices = ModuleRuntimeServices<ModuleDatabase>;
|
||||
/** The services Core hands any started module: the legacy facade or the ABI v1 executor. */
|
||||
export type AnyModuleRuntimeServices =
|
||||
| LegacyModuleRuntimeServices
|
||||
| ModuleDatabaseAbiRuntimeServices;
|
||||
|
||||
/**
|
||||
* A typed `ModuleDatabase` view over an externally owned pool — the module-facing counterpart to
|
||||
* `createIdentityDrizzleView` (`core/src/identity/database.ts`) and
|
||||
|
||||
@@ -2,12 +2,14 @@ export * from "./configuration.js";
|
||||
export * from "./configuration-schema.js";
|
||||
export * from "./configuration-store.js";
|
||||
export * from "./database.js";
|
||||
export * from "./database-abi.js";
|
||||
export * from "./discovery.js";
|
||||
export * from "./lifecycle.js";
|
||||
export * from "./lifecycle-schema.js";
|
||||
export * from "./lifecycle-store.js";
|
||||
export * from "./migration-discovery.js";
|
||||
export * from "./modules-directory.js";
|
||||
export * from "./module-package-host.js";
|
||||
export * from "./module-package-verification.js";
|
||||
export * from "./permissions.js";
|
||||
export * from "./realtime.js";
|
||||
|
||||
+10
-31
@@ -1,19 +1,15 @@
|
||||
/**
|
||||
* The legacy module schema definition (ADR 0007): a v8 module that declares no `database` default-
|
||||
* exports `{ moduleId, tables }` with its Drizzle table objects themselves. Core alone validates it,
|
||||
* for the legacy facade's deprecation window (ADR 0050). It lives here, beside Core's own Drizzle
|
||||
* dependency, so that no package a module installs needs Drizzle at run time (ADR 0053).
|
||||
*
|
||||
* Erasable TypeScript with no relative imports: the verifier's probe imports this source file
|
||||
* directly in tests (`module-package-probe.ts`).
|
||||
*/
|
||||
import { is } from "drizzle-orm";
|
||||
import { type AnyPgTable, PgTable } from "drizzle-orm/pg-core";
|
||||
import { z } from "zod";
|
||||
import { type ModuleId, moduleIdSchema } from "./identifiers.js";
|
||||
|
||||
export const POSTGRES_IDENTIFIER_MAX_LENGTH = 63;
|
||||
|
||||
export const moduleTableLocalNameSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(/^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/u, "Must be a lowercase snake-case table name.");
|
||||
|
||||
export const physicalModuleTableNameSchema = moduleTableLocalNameSchema.max(
|
||||
POSTGRES_IDENTIFIER_MAX_LENGTH,
|
||||
`Must not exceed ${POSTGRES_IDENTIFIER_MAX_LENGTH} characters.`,
|
||||
);
|
||||
import { type ModuleId, moduleIdSchema } from "@ekklesia/contracts";
|
||||
|
||||
export interface ModuleSchemaDefinition<
|
||||
Tables extends Readonly<Record<string, AnyPgTable>> = Readonly<Record<string, AnyPgTable>>,
|
||||
@@ -35,23 +31,6 @@ function hasOnlyStringKeys(value: object, allowedKeys?: ReadonlySet<string>): bo
|
||||
);
|
||||
}
|
||||
|
||||
export function moduleTableNamespace(moduleId: ModuleId): string {
|
||||
return moduleIdSchema.parse(moduleId).replaceAll("-", "_");
|
||||
}
|
||||
|
||||
export function moduleTablePrefix(moduleId: ModuleId): string {
|
||||
return `${moduleTableNamespace(moduleId)}_`;
|
||||
}
|
||||
|
||||
export function moduleTableName(moduleId: ModuleId, localName: string): string {
|
||||
const parsedLocalName = moduleTableLocalNameSchema.parse(localName);
|
||||
const tableName = `${moduleTablePrefix(moduleId)}${parsedLocalName}`;
|
||||
if (!physicalModuleTableNameSchema.safeParse(tableName).success) {
|
||||
throw new RangeError("The namespaced PostgreSQL table name is invalid or too long.");
|
||||
}
|
||||
return tableName;
|
||||
}
|
||||
|
||||
export function isModuleSchemaDefinition(value: unknown): value is ModuleSchemaDefinition {
|
||||
try {
|
||||
if (
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* The facts about this host that decide whether a native Module Package v1 can run on it
|
||||
* (ADR 0049): operating system, CPU, C library family and version, and Node-API version.
|
||||
*
|
||||
* Everything comes from Node.js itself (`process.platform`, `process.arch`, `process.versions`, and
|
||||
* the diagnostic report's `glibcVersionRuntime`), never from running a system command. A fact Node
|
||||
* cannot report is left undefined, and every check that needs it fails closed.
|
||||
*/
|
||||
export interface ModulePackageHost {
|
||||
readonly platform: string;
|
||||
readonly arch: string;
|
||||
/** The C library on Linux: glibc with its runtime version, or undefined when it cannot be proven
|
||||
* to be glibc (musl reports none). */
|
||||
readonly libc?: { readonly family: "glibc"; readonly version: string };
|
||||
/** `process.versions.napi`: the highest Node-API version this Node.js provides. */
|
||||
readonly nodeApi?: number;
|
||||
/** `process.versions.modules`: the ABI an ABI-specific addon must have been built for. */
|
||||
readonly nodeAbi?: string;
|
||||
}
|
||||
|
||||
function glibcVersion(): string | undefined {
|
||||
try {
|
||||
const header = (process.report?.getReport() as { header?: { glibcVersionRuntime?: unknown } })
|
||||
?.header;
|
||||
const version = header?.glibcVersionRuntime;
|
||||
return typeof version === "string" && /^\d+\.\d+(?:\.\d+)?$/u.test(version)
|
||||
? version
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function currentModulePackageHost(): ModulePackageHost {
|
||||
const libc = process.platform === "linux" ? glibcVersion() : undefined;
|
||||
const nodeApi = Number(process.versions.napi);
|
||||
return Object.freeze({
|
||||
arch: process.arch,
|
||||
...(libc === undefined
|
||||
? {}
|
||||
: { libc: Object.freeze({ family: "glibc" as const, version: libc }) }),
|
||||
...(Number.isInteger(nodeApi) && nodeApi > 0 ? { nodeApi } : {}),
|
||||
...(typeof process.versions.modules === "string" ? { nodeAbi: process.versions.modules } : {}),
|
||||
platform: process.platform,
|
||||
});
|
||||
}
|
||||
|
||||
/** The native targets this platform can verify, with what each requires of a host. */
|
||||
export const MODULE_PACKAGE_NATIVE_TARGETS: ReadonlyMap<
|
||||
string,
|
||||
{
|
||||
readonly platform: string;
|
||||
readonly arch: string;
|
||||
readonly libc: "glibc";
|
||||
readonly elfMachine: number;
|
||||
}
|
||||
> = new Map([
|
||||
["linux-x64-glibc", { arch: "x64", elfMachine: 62, libc: "glibc", platform: "linux" }],
|
||||
]);
|
||||
@@ -9,12 +9,18 @@
|
||||
// The result travels over the IPC channel, never stdout, so nothing a module prints can forge it.
|
||||
// It carries fixed role and reason names only: never an error message, a path, or module output.
|
||||
//
|
||||
// Written as erasable TypeScript with no relative imports, so Node can run the source file directly
|
||||
// in tests as well as the compiled file in the image.
|
||||
// Written as erasable TypeScript, so Node can run the source file directly in tests as well as the
|
||||
// compiled file in the image. Its one relative import names the source file's own extension, which
|
||||
// the build rewrites to `.js` (`rewriteRelativeImportExtensions`); that file follows the same rules.
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { parseModuleRuntimeDefinition, parseModuleSchemaDefinition } from "@ekklesia/contracts";
|
||||
import {
|
||||
parseModuleRuntimeDefinition,
|
||||
parseModuleSchemaMetadataDefinition,
|
||||
} from "@ekklesia/contracts";
|
||||
import { parseModuleSchemaDefinition } from "./legacy-schema-definition.ts";
|
||||
|
||||
interface ProbeRequest {
|
||||
readonly databaseAbi?: 1;
|
||||
readonly moduleId: string;
|
||||
readonly version: string;
|
||||
readonly entrypoints: Readonly<Record<string, string>>;
|
||||
@@ -22,7 +28,7 @@ interface ProbeRequest {
|
||||
|
||||
interface ProbeFailure {
|
||||
readonly role: string;
|
||||
readonly reason: "export" | "identity" | "load";
|
||||
readonly reason: "contract" | "export" | "identity" | "load";
|
||||
}
|
||||
|
||||
function defaultExport(namespace: unknown): unknown {
|
||||
@@ -51,8 +57,14 @@ async function probe(request: ProbeRequest): Promise<readonly ProbeFailure[]> {
|
||||
if (definition.id !== request.moduleId || definition.version !== request.version) {
|
||||
failures.push({ reason: "identity", role });
|
||||
}
|
||||
if ((definition.database?.abi === 1) !== (request.databaseAbi === 1)) {
|
||||
failures.push({ reason: "contract", role });
|
||||
}
|
||||
} else if (role === "schema") {
|
||||
const definition = parseModuleSchemaDefinition(defaultExport(namespace));
|
||||
const definition =
|
||||
request.databaseAbi === 1
|
||||
? parseModuleSchemaMetadataDefinition(defaultExport(namespace))
|
||||
: parseModuleSchemaDefinition(defaultExport(namespace));
|
||||
if (definition.moduleId !== request.moduleId) {
|
||||
failures.push({ reason: "identity", role });
|
||||
}
|
||||
|
||||
@@ -6,16 +6,23 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
isVersionCompatible,
|
||||
type ModuleManifest,
|
||||
safeParseModuleManifest,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
MODULE_PACKAGE_ANY_TARGET,
|
||||
MODULE_PACKAGE_FORMAT,
|
||||
MODULE_PACKAGE_METADATA_FILENAME,
|
||||
type ModuleManifest,
|
||||
type ModulePackageMetadata,
|
||||
safeParseModuleManifest,
|
||||
safeParseModulePackageMetadata,
|
||||
safeParseModulePackageRuntimePackage,
|
||||
} from "@ekklesia/contracts";
|
||||
} from "@ekklesia/contracts/module-package";
|
||||
import { discoverModules, MODULE_MANIFEST_FILENAME, ModuleDiscoveryError } from "./discovery.js";
|
||||
import {
|
||||
currentModulePackageHost,
|
||||
MODULE_PACKAGE_NATIVE_TARGETS,
|
||||
type ModulePackageHost,
|
||||
} from "./module-package-host.js";
|
||||
import { discoverModuleMigrations, ModuleMigrationDiscoveryError } from "./migration-discovery.js";
|
||||
import {
|
||||
type ModulePackageArchive,
|
||||
@@ -45,6 +52,7 @@ import { discoverModuleSchemas, ModuleSchemaDiscoveryError } from "./schema-disc
|
||||
export type ModulePackageVerificationErrorCode =
|
||||
| ModulePackageArchiveErrorCode
|
||||
| "MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE"
|
||||
| "MODULE_PACKAGE_DATABASE_RUNTIME_UNEXPECTED"
|
||||
| "MODULE_PACKAGE_DIGEST_MISMATCH"
|
||||
| "MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED"
|
||||
| "MODULE_PACKAGE_EXTRACTION_FAILED"
|
||||
@@ -53,11 +61,14 @@ export type ModulePackageVerificationErrorCode =
|
||||
| "MODULE_PACKAGE_FILE_UNLISTED"
|
||||
| "MODULE_PACKAGE_FORMAT_UNSUPPORTED"
|
||||
| "MODULE_PACKAGE_IDENTITY_MISMATCH"
|
||||
| "MODULE_PACKAGE_LIBC_INCOMPATIBLE"
|
||||
| "MODULE_PACKAGE_MANIFEST_INVALID"
|
||||
| "MODULE_PACKAGE_METADATA_INVALID"
|
||||
| "MODULE_PACKAGE_METADATA_MISSING"
|
||||
| "MODULE_PACKAGE_MODULE_INVALID"
|
||||
| "MODULE_PACKAGE_NATIVE_FILE"
|
||||
| "MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE"
|
||||
| "MODULE_PACKAGE_NODE_API_INCOMPATIBLE"
|
||||
| "MODULE_PACKAGE_RUNTIME_INCOMPATIBLE"
|
||||
| "MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID"
|
||||
| "MODULE_PACKAGE_SDK_INCOMPATIBLE"
|
||||
@@ -67,6 +78,8 @@ export type ModulePackageVerificationErrorCode =
|
||||
const messages: Readonly<Record<ModulePackageVerificationErrorCode, string>> = Object.freeze({
|
||||
MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE:
|
||||
"The package's bundled database runtime does not match this platform's (a transitional constraint).",
|
||||
MODULE_PACKAGE_DATABASE_RUNTIME_UNEXPECTED:
|
||||
"A Module Database ABI package must not carry a transitional database runtime.",
|
||||
MODULE_PACKAGE_DIGEST_MISMATCH: "The archive does not match the SHA-256 it was given.",
|
||||
MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED:
|
||||
"A backend entrypoint could not be loaded from the package alone.",
|
||||
@@ -80,12 +93,19 @@ const messages: Readonly<Record<ModulePackageVerificationErrorCode, string>> = O
|
||||
"The package metadata, runtime package.json, and manifest disagree about the module's identity.",
|
||||
MODULE_PACKAGE_LAYOUT_INVALID:
|
||||
"The archive must contain exactly one top-level module directory, with each path once.",
|
||||
MODULE_PACKAGE_LIBC_INCOMPATIBLE:
|
||||
"The package needs a newer C library than this host provides, or one it cannot establish.",
|
||||
MODULE_PACKAGE_LINK: "The archive contains a link; module packages may contain none.",
|
||||
MODULE_PACKAGE_MANIFEST_INVALID: "The package's module manifest is missing or invalid.",
|
||||
MODULE_PACKAGE_METADATA_INVALID: "The package metadata is invalid.",
|
||||
MODULE_PACKAGE_METADATA_MISSING: "The archive contains no package metadata.",
|
||||
MODULE_PACKAGE_MODULE_INVALID: "The packaged module failed module validation.",
|
||||
MODULE_PACKAGE_NATIVE_FILE: "An any-target package contains a native file.",
|
||||
MODULE_PACKAGE_NATIVE_FILE:
|
||||
"An any-target package contains a native file or a node_modules directory.",
|
||||
MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE:
|
||||
"A native file in the package is not built for the package's target.",
|
||||
MODULE_PACKAGE_NODE_API_INCOMPATIBLE:
|
||||
"The package's addons need a Node-API version or Node.js ABI this host does not provide.",
|
||||
MODULE_PACKAGE_RUNTIME_INCOMPATIBLE:
|
||||
"The package needs a Node.js version this host does not run.",
|
||||
MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID:
|
||||
@@ -126,6 +146,8 @@ export interface ModulePackagePlatform {
|
||||
readonly databaseRuntime: Readonly<Record<string, string>>;
|
||||
/** Defaults to the running process's Node.js version. */
|
||||
readonly nodeVersion?: string;
|
||||
/** Defaults to this process's own host (`currentModulePackageHost`). */
|
||||
readonly host?: ModulePackageHost;
|
||||
}
|
||||
|
||||
export interface VerifyModulePackageOptions {
|
||||
@@ -261,12 +283,19 @@ function checkCompatibility(
|
||||
files: ReadonlyMap<string, Buffer>,
|
||||
platform: ModulePackagePlatform,
|
||||
): void {
|
||||
// This verifier implements the portable target only. Native targets are a later slice.
|
||||
if (metadata.target !== MODULE_PACKAGE_ANY_TARGET) {
|
||||
fail("MODULE_PACKAGE_TARGET_INCOMPATIBLE");
|
||||
}
|
||||
if ([...files.keys()].some((filePath) => filePath.toLowerCase().endsWith(".node"))) {
|
||||
fail("MODULE_PACKAGE_NATIVE_FILE");
|
||||
if (metadata.target === MODULE_PACKAGE_ANY_TARGET) {
|
||||
// An any-target package bundles everything: no addon, no ELF object, no node_modules.
|
||||
for (const [filePath, data] of files) {
|
||||
if (
|
||||
filePath.toLowerCase().endsWith(".node") ||
|
||||
filePath.startsWith("node_modules/") ||
|
||||
isElf(data)
|
||||
) {
|
||||
fail("MODULE_PACKAGE_NATIVE_FILE");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
checkNativeTarget(metadata, files, platform.host ?? currentModulePackageHost());
|
||||
}
|
||||
if (!isVersionCompatible(platform.nodeVersion ?? process.versions.node, metadata.runtime.node)) {
|
||||
fail("MODULE_PACKAGE_RUNTIME_INCOMPATIBLE");
|
||||
@@ -275,9 +304,15 @@ function checkCompatibility(
|
||||
if (sdk !== undefined && !isVersionCompatible(sdk, manifest.compatibility.moduleSdk)) {
|
||||
fail("MODULE_PACKAGE_SDK_INCOMPATIBLE");
|
||||
}
|
||||
// Transitional (ADR 0049): today a module's Drizzle copy must be interchangeable with the
|
||||
// platform's, because Core queries the module's tables with its own Drizzle instance. This is an
|
||||
// implementation constraint pending a Module Database ABI, not that ABI.
|
||||
if (manifest.database?.abi === 1) {
|
||||
if (metadata.transitional?.databaseRuntime !== undefined) {
|
||||
fail("MODULE_PACKAGE_DATABASE_RUNTIME_UNEXPECTED");
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Transitional legacy-v8 path (ADR 0049): Core queries the module's Drizzle tables with its own
|
||||
// Drizzle instance, so a recorded module runtime must match. ABI-v1 packages return above: their
|
||||
// bundled ORM is a private implementation detail and is never compared with Core's.
|
||||
for (const [runtime, version] of Object.entries(metadata.transitional?.databaseRuntime ?? {})) {
|
||||
if (platform.databaseRuntime[runtime] !== version) {
|
||||
fail("MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE");
|
||||
@@ -285,6 +320,74 @@ function checkCompatibility(
|
||||
}
|
||||
}
|
||||
|
||||
function isElf(data: Uint8Array): boolean {
|
||||
return (
|
||||
data.length >= 4 && data[0] === 0x7f && data[1] === 0x45 && data[2] === 0x4c && data[3] === 0x46
|
||||
);
|
||||
}
|
||||
|
||||
/** `2.41` → `2.41.0`, for comparison against a semantic-version range. */
|
||||
function asSemver(version: string): string {
|
||||
const parts = version.split(".");
|
||||
return [...parts, "0", "0"].slice(0, 3).join(".");
|
||||
}
|
||||
|
||||
/**
|
||||
* A native target (ADR 0049): accepted only for a target this platform implements, on a host that
|
||||
* provably matches it. Platform, CPU, C library family and minimum version, and the Node-API version
|
||||
* or ABI the addons need are all checked; a fact the host cannot establish fails closed. Every ELF
|
||||
* object in the package must be built for the target's machine, and every `.node` addon must be one.
|
||||
*/
|
||||
function checkNativeTarget(
|
||||
metadata: ModulePackageMetadata,
|
||||
files: ReadonlyMap<string, Buffer>,
|
||||
host: ModulePackageHost,
|
||||
): void {
|
||||
const target = MODULE_PACKAGE_NATIVE_TARGETS.get(metadata.target);
|
||||
if (
|
||||
target === undefined ||
|
||||
host.platform !== target.platform ||
|
||||
host.arch !== target.arch ||
|
||||
host.libc?.family !== target.libc
|
||||
) {
|
||||
fail("MODULE_PACKAGE_TARGET_INCOMPATIBLE");
|
||||
}
|
||||
const required = metadata.runtime.libcVersion;
|
||||
if (
|
||||
required === undefined ||
|
||||
host.libc === undefined ||
|
||||
!isVersionCompatible(asSemver(host.libc.version), required)
|
||||
) {
|
||||
fail("MODULE_PACKAGE_LIBC_INCOMPATIBLE");
|
||||
}
|
||||
const { nodeAbi, nodeApi } = metadata.runtime;
|
||||
if (
|
||||
(nodeApi === undefined && nodeAbi === undefined) ||
|
||||
(nodeApi !== undefined && (host.nodeApi === undefined || host.nodeApi < nodeApi)) ||
|
||||
(nodeAbi !== undefined && host.nodeAbi !== nodeAbi)
|
||||
) {
|
||||
fail("MODULE_PACKAGE_NODE_API_INCOMPATIBLE");
|
||||
}
|
||||
for (const [filePath, data] of files) {
|
||||
const addon = filePath.toLowerCase().endsWith(".node");
|
||||
if (!isElf(data)) {
|
||||
if (addon) {
|
||||
fail("MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// ELF64, little-endian, for the target's machine (e_machine at offset 18).
|
||||
if (
|
||||
data.length < 20 ||
|
||||
data[4] !== 2 ||
|
||||
data[5] !== 1 ||
|
||||
data.readUInt16LE(18) !== target.elfMachine
|
||||
) {
|
||||
fail("MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes the archive's directories (`0755`) and files (`0644`) under `root`, which must not exist.
|
||||
* Safe by construction: every path was validated as relative and non-escaping, and only directories
|
||||
* and regular files exist. Ownership is the writing process's, never the archive's. */
|
||||
@@ -314,8 +417,7 @@ function probeEntrypointPath(): string {
|
||||
/** Loads every backend entrypoint in a short-lived child with an empty environment, and reports
|
||||
* only whether each loaded and exported a definition naming this module. */
|
||||
async function loadEntrypoints(
|
||||
moduleId: string,
|
||||
version: string,
|
||||
manifest: Pick<ModuleManifest, "database" | "id" | "version">,
|
||||
entrypoints: Readonly<Record<string, string>>,
|
||||
timeoutMs: number,
|
||||
): Promise<void> {
|
||||
@@ -332,13 +434,24 @@ async function loadEntrypoints(
|
||||
}
|
||||
};
|
||||
try {
|
||||
child = fork(probeEntrypointPath(), [JSON.stringify({ entrypoints, moduleId, version })], {
|
||||
cwd: tmpdir(),
|
||||
env: {},
|
||||
execArgv: [],
|
||||
serialization: "json",
|
||||
stdio: ["ignore", "ignore", "ignore", "ipc"],
|
||||
});
|
||||
child = fork(
|
||||
probeEntrypointPath(),
|
||||
[
|
||||
JSON.stringify({
|
||||
...(manifest.database?.abi === 1 ? { databaseAbi: 1 } : {}),
|
||||
entrypoints,
|
||||
moduleId: manifest.id,
|
||||
version: manifest.version,
|
||||
}),
|
||||
],
|
||||
{
|
||||
cwd: tmpdir(),
|
||||
env: {},
|
||||
execArgv: [],
|
||||
serialization: "json",
|
||||
stdio: ["ignore", "ignore", "ignore", "ipc"],
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
finish("failed");
|
||||
return;
|
||||
@@ -437,12 +550,7 @@ export async function verifyModulePackage(
|
||||
|
||||
// Executed only now, and first in isolation: a schema is imported in this process below only
|
||||
// after the probe proved it loads on its own.
|
||||
await loadEntrypoints(
|
||||
metadata.module.id,
|
||||
metadata.module.version,
|
||||
entrypoints,
|
||||
options.loadTimeoutMs ?? 30_000,
|
||||
);
|
||||
await loadEntrypoints(manifest, entrypoints, options.loadTimeoutMs ?? 30_000);
|
||||
try {
|
||||
await discoverModuleSchemas(discoveryOptions);
|
||||
} catch (error) {
|
||||
|
||||
+68
-26
@@ -7,15 +7,16 @@ import {
|
||||
EMPTY_MODULE_CONFIGURATION,
|
||||
EMPTY_MODULE_STORAGE,
|
||||
type ModuleConfiguration,
|
||||
type ModuleDatabaseExecutor,
|
||||
type ModuleHttpRouteRegistrar,
|
||||
type ModuleId,
|
||||
ModuleRealtimeError,
|
||||
type ModuleRealtimePublisher,
|
||||
type ModuleRegistrationContext,
|
||||
type ModuleRouteDefinitionInput,
|
||||
type ModuleRouteHandler,
|
||||
type ModuleRuntimeContext,
|
||||
type ModuleRuntimeDefinition,
|
||||
type ModuleRuntimeServices,
|
||||
type ModuleStorage,
|
||||
moduleRouteShapeKey,
|
||||
moduleRoutesOverlap,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
type RegisteredModuleHttpRoute,
|
||||
safeParseModuleRouteDefinition,
|
||||
} from "@ekklesia/contracts";
|
||||
import type { AnyModuleRuntimeServices, ModuleDatabase } from "./database.js";
|
||||
import {
|
||||
type DiscoveredModule,
|
||||
discoverModules,
|
||||
@@ -68,6 +70,7 @@ export type ModuleRuntimeIssueCode =
|
||||
| "MODULE_ROUTE_PERMISSION_INVALID"
|
||||
| "MODULE_ROUTE_PERMISSION_UNDECLARED"
|
||||
| "MODULE_RUNTIME_EXPORT_INVALID"
|
||||
| "MODULE_RUNTIME_DATABASE_MISMATCH"
|
||||
| "MODULE_RUNTIME_ID_MISMATCH"
|
||||
| "MODULE_RUNTIME_IMPORT_FAILED"
|
||||
| "MODULE_RUNTIME_VERSION_MISMATCH"
|
||||
@@ -105,9 +108,8 @@ export interface StartedModuleRuntime {
|
||||
/**
|
||||
* Per-module frozen configuration snapshots, keyed by module id (ADR 0029).
|
||||
*
|
||||
* A lookup rather than a field on `ModuleRuntimeServices`, because `services` is deliberately **one
|
||||
* shared object identity every module receives** — a property this runtime has a test for — while
|
||||
* configuration is per-module by definition. A module with no entry receives
|
||||
* A lookup rather than a field on `ModuleRuntimeServices`, because configuration is resolved and
|
||||
* frozen per module independently of its live service object. A module with no entry receives
|
||||
* `EMPTY_MODULE_CONFIGURATION` rather than `undefined`, so `context.config` is always present and
|
||||
* module code never branches on whether the platform supplied one.
|
||||
*/
|
||||
@@ -124,12 +126,33 @@ function configurationFor(
|
||||
* Per-module scoped object storage, keyed by module id (ADR 0030).
|
||||
*
|
||||
* A lookup for the same reason configuration is one, and emphatically **not** a field on
|
||||
* `ModuleRuntimeServices`: `services` is one shared object identity every module receives, while a
|
||||
* storage facade is scoped to exactly the buckets one module declared. Sharing it would hand every
|
||||
* `ModuleRuntimeServices`: a storage facade is scoped to exactly the buckets one module declared.
|
||||
* Sharing it would hand every
|
||||
* module every other module's storage.
|
||||
*/
|
||||
export type ModuleStorageLookup = ReadonlyMap<string, ModuleStorage>;
|
||||
|
||||
/** One frozen service object per module id. Legacy modules may point at objects wrapping the same
|
||||
* facade; ABI-v1 modules each point at their own attributed executor. */
|
||||
export type ModuleRuntimeServicesLookup = ReadonlyMap<string, AnyModuleRuntimeServices>;
|
||||
export type ModuleRuntimeServicesSource = ModuleRuntimeServicesLookup | AnyModuleRuntimeServices;
|
||||
|
||||
function servicesFor(
|
||||
services: ModuleRuntimeServicesSource,
|
||||
module: DiscoveredModule,
|
||||
): AnyModuleRuntimeServices {
|
||||
const selected = "get" in services ? services.get(module.manifest.id) : services;
|
||||
if (!selected) {
|
||||
throw new TypeError(`Module ${module.manifest.id} has no runtime services.`);
|
||||
}
|
||||
const expectsAbi = module.manifest.database?.abi === 1;
|
||||
const isAbi = "abiVersion" in selected.database && selected.database.abiVersion === 1;
|
||||
if (expectsAbi !== isAbi) {
|
||||
throw new TypeError(`Module ${module.manifest.id} has the wrong database service.`);
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
function storageFor(storages: ModuleStorageLookup, moduleId: string): ModuleStorage {
|
||||
return storages.get(moduleId) ?? EMPTY_MODULE_STORAGE;
|
||||
}
|
||||
@@ -164,10 +187,9 @@ export interface ModuleRuntimeCompositionOptions extends ModuleRuntimeTimeoutOpt
|
||||
export interface StartModuleRuntimeOptions
|
||||
extends ModuleDiscoveryOptions,
|
||||
ModuleRuntimeCompositionOptions {
|
||||
/** The same frozen services object every started module's `start`/`stop` hooks and (once
|
||||
* `apps/api` mounts routes) HTTP handlers receive for this runtime — a required production
|
||||
* dependency, never an optional test-only global. See `ModuleRuntimeServices`. */
|
||||
readonly services: ModuleRuntimeServices;
|
||||
/** The manifest-selected service object for each module. One module's `start`/`stop` hooks and
|
||||
* handlers receive its same entry; ABI-v1 entries carry distinct attributed executors. */
|
||||
readonly services: ModuleRuntimeServicesSource;
|
||||
}
|
||||
|
||||
interface LoadedModule {
|
||||
@@ -175,6 +197,13 @@ interface LoadedModule {
|
||||
readonly discovered: DiscoveredModule;
|
||||
}
|
||||
|
||||
type AnyRegistrationHook = (
|
||||
context: ModuleRegistrationContext<ModuleDatabase | ModuleDatabaseExecutor>,
|
||||
) => unknown;
|
||||
type AnyRuntimeHook = (
|
||||
context: ModuleRuntimeContext<ModuleDatabase | ModuleDatabaseExecutor>,
|
||||
) => unknown;
|
||||
|
||||
interface CapabilityRegistrationStage {
|
||||
readonly capabilities: CapabilityRegistrar;
|
||||
commit(): readonly ModuleRuntimeIssue[];
|
||||
@@ -316,11 +345,11 @@ class StagedCapabilityRegistry {
|
||||
createRuntimeContext(
|
||||
module: DiscoveredModule,
|
||||
phase: "rollback" | "start" | "stop",
|
||||
services: ModuleRuntimeServices,
|
||||
services: AnyModuleRuntimeServices,
|
||||
config: ModuleConfiguration,
|
||||
storage: ModuleStorage,
|
||||
realtime: ModuleRealtimePublisher,
|
||||
): ModuleRuntimeContext {
|
||||
): ModuleRuntimeContext<ModuleDatabase | ModuleDatabaseExecutor> {
|
||||
const capabilities: CapabilityResolver = Object.freeze({
|
||||
resolve: (capabilityId: CapabilityId): RegisteredCapability | undefined => {
|
||||
this.#assertAccess(module, capabilityId, phase);
|
||||
@@ -469,7 +498,7 @@ class StagedModuleHttpRoutes {
|
||||
local.push(
|
||||
Object.freeze({
|
||||
bodyMode: parsed.data.bodyMode,
|
||||
handler: parsed.data.handler,
|
||||
handler: parsed.data.handler as ModuleRouteHandler,
|
||||
maxBodyBytes: parsed.data.maxBodyBytes,
|
||||
method: parsed.data.method,
|
||||
moduleId: module.manifest.id,
|
||||
@@ -555,6 +584,19 @@ async function loadRuntimeDefinitions(
|
||||
),
|
||||
);
|
||||
}
|
||||
const runtimeDeclaresAbi = definition.database?.abi === 1;
|
||||
const manifestDeclaresAbi = discovered.manifest.database?.abi === 1;
|
||||
if (runtimeDeclaresAbi !== manifestDeclaresAbi) {
|
||||
matchesManifest = false;
|
||||
issues.push(
|
||||
runtimeIssue(
|
||||
"MODULE_RUNTIME_DATABASE_MISMATCH",
|
||||
"load",
|
||||
discovered.manifest.id,
|
||||
`Module runtime database declaration does not match manifest ${discovered.manifest.id}.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (matchesManifest) {
|
||||
loaded.push({ definition, discovered });
|
||||
}
|
||||
@@ -580,15 +622,15 @@ async function registerModules(
|
||||
module.discovered.manifest.id,
|
||||
new Set(module.discovered.manifest.permissions.map((declaration) => declaration.id)),
|
||||
);
|
||||
const context: ModuleRegistrationContext = Object.freeze({
|
||||
const context = Object.freeze({
|
||||
capabilities: capabilityStage.capabilities,
|
||||
config: configurationFor(configurations, module.discovered.manifest.id),
|
||||
http: routeStage.http,
|
||||
moduleId: module.discovered.manifest.id,
|
||||
realtime: realtimeStage.realtime,
|
||||
});
|
||||
}) as ModuleRegistrationContext<ModuleDatabase | ModuleDatabaseExecutor>;
|
||||
try {
|
||||
await module.definition.register?.(context);
|
||||
await (module.definition.register as AnyRegistrationHook | undefined)?.(context);
|
||||
} catch (error) {
|
||||
if (error instanceof ModuleRuntimeSignal) {
|
||||
throw new ModuleRuntimeError([error.issue]);
|
||||
@@ -685,7 +727,7 @@ async function stopModules(
|
||||
modules: readonly LoadedModule[],
|
||||
registry: StagedCapabilityRegistry,
|
||||
phase: "rollback" | "stop",
|
||||
services: ModuleRuntimeServices,
|
||||
services: ModuleRuntimeServicesSource,
|
||||
configurations: ModuleConfigurationLookup,
|
||||
storages: ModuleStorageLookup,
|
||||
realtimeRegistry: StagedModuleRealtimeRegistry,
|
||||
@@ -698,7 +740,7 @@ async function stopModules(
|
||||
// A dependency may keep doing ordinary work while its dependents stop. Deactivate only the
|
||||
// module whose stop hook is about to run, not every publisher at the beginning of the sequence.
|
||||
realtimeRegistry.deactivateModule(moduleId);
|
||||
const stop = module.definition.stop;
|
||||
const stop = module.definition.stop as AnyRuntimeHook | undefined;
|
||||
if (!stop) {
|
||||
// No synthetic callback and no timer for a module that declares no stop hook.
|
||||
continue;
|
||||
@@ -710,13 +752,13 @@ async function stopModules(
|
||||
registry.createRuntimeContext(
|
||||
module.discovered,
|
||||
phase,
|
||||
services,
|
||||
servicesFor(services, module.discovered),
|
||||
configurationFor(configurations, moduleId),
|
||||
storageFor(storages, moduleId),
|
||||
realtimeRegistry.publisherFor(moduleId),
|
||||
),
|
||||
) as ModuleRuntimeContext<ModuleDatabase | ModuleDatabaseExecutor>,
|
||||
),
|
||||
),
|
||||
).then(() => undefined),
|
||||
timeoutMs,
|
||||
);
|
||||
if (outcome === "timed-out") {
|
||||
@@ -750,7 +792,7 @@ async function stopModules(
|
||||
async function startModules(
|
||||
modules: readonly LoadedModule[],
|
||||
registry: StagedCapabilityRegistry,
|
||||
services: ModuleRuntimeServices,
|
||||
services: ModuleRuntimeServicesSource,
|
||||
configurations: ModuleConfigurationLookup,
|
||||
storages: ModuleStorageLookup,
|
||||
realtimeRegistry: StagedModuleRealtimeRegistry,
|
||||
@@ -766,15 +808,15 @@ async function startModules(
|
||||
for (const module of modules) {
|
||||
rollbackCandidates.push(module);
|
||||
try {
|
||||
await module.definition.start?.(
|
||||
await (module.definition.start as AnyRuntimeHook | undefined)?.(
|
||||
registry.createRuntimeContext(
|
||||
module.discovered,
|
||||
"start",
|
||||
services,
|
||||
servicesFor(services, module.discovered),
|
||||
configurationFor(configurations, module.discovered.manifest.id),
|
||||
storageFor(storages, module.discovered.manifest.id),
|
||||
realtimeRegistry.publisherFor(module.discovered.manifest.id),
|
||||
),
|
||||
) as ModuleRuntimeContext<ModuleDatabase | ModuleDatabaseExecutor>,
|
||||
);
|
||||
const missingSnapshots = realtimeRegistry.missingSnapshots(module.discovered.manifest.id);
|
||||
if (missingSnapshots.length > 0) {
|
||||
@@ -831,7 +873,7 @@ async function startModules(
|
||||
*/
|
||||
export async function startDiscoveredModuleRuntime(
|
||||
discoveredModules: readonly DiscoveredModule[],
|
||||
services: ModuleRuntimeServices,
|
||||
services: ModuleRuntimeServicesSource,
|
||||
timeouts: ModuleRuntimeCompositionOptions = {},
|
||||
): Promise<StartedModuleRuntime> {
|
||||
const modules = await loadRuntimeDefinitions(discoveredModules);
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
type ModuleId,
|
||||
type ModuleSchemaDefinition,
|
||||
type ModuleSchemaMetadataDefinition,
|
||||
moduleTableLocalNameSchema,
|
||||
moduleTablePrefix,
|
||||
parseModuleSchemaDefinition,
|
||||
parseModuleSchemaMetadataDefinition,
|
||||
physicalModuleTableNameSchema,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
type ModuleSchemaDefinition,
|
||||
parseModuleSchemaDefinition,
|
||||
} from "./legacy-schema-definition.js";
|
||||
import { is } from "drizzle-orm";
|
||||
import { type AnyPgTable, getTableConfig, PgColumn, PgTable } from "drizzle-orm/pg-core";
|
||||
import {
|
||||
@@ -40,7 +44,9 @@ export interface DiscoveredModuleTable {
|
||||
readonly exportName: string;
|
||||
readonly moduleId: ModuleId;
|
||||
readonly name: string;
|
||||
readonly table: AnyPgTable;
|
||||
/** Present only for a temporary legacy-facade module. ABI-v1 schema discovery publishes no ORM
|
||||
* object and contributes nothing to the legacy aggregate below. */
|
||||
readonly table?: AnyPgTable;
|
||||
}
|
||||
|
||||
export interface DiscoveredModuleSchema {
|
||||
@@ -55,21 +61,40 @@ export interface ModuleSchemaAggregate {
|
||||
readonly tables: readonly DiscoveredModuleTable[];
|
||||
}
|
||||
|
||||
interface LoadedModuleSchema {
|
||||
interface LoadedLegacyModuleSchema {
|
||||
readonly definition: ModuleSchemaDefinition;
|
||||
readonly discovered: DiscoveredModule;
|
||||
readonly mode: "legacy";
|
||||
}
|
||||
|
||||
interface LoadedAbiModuleSchema {
|
||||
readonly definition: ModuleSchemaMetadataDefinition;
|
||||
readonly discovered: DiscoveredModule;
|
||||
readonly mode: "abi-v1";
|
||||
}
|
||||
|
||||
type LoadedModuleSchema = LoadedLegacyModuleSchema | LoadedAbiModuleSchema;
|
||||
|
||||
interface InspectedModuleSchema {
|
||||
readonly discovered: DiscoveredModule;
|
||||
readonly tables: ModuleTableCandidate[];
|
||||
}
|
||||
|
||||
interface ModuleTableCandidate extends DiscoveredModuleTable {
|
||||
interface LegacyModuleTableCandidate extends DiscoveredModuleTable {
|
||||
readonly mode: "legacy";
|
||||
readonly foreignKeys: ReturnType<typeof getTableConfig>["foreignKeys"];
|
||||
readonly path: string;
|
||||
readonly table: AnyPgTable;
|
||||
}
|
||||
|
||||
interface AbiModuleTableCandidate extends DiscoveredModuleTable {
|
||||
readonly mode: "abi-v1";
|
||||
readonly foreignKeys: ModuleSchemaMetadataDefinition["tables"][number]["foreignKeys"];
|
||||
readonly path: string;
|
||||
}
|
||||
|
||||
type ModuleTableCandidate = LegacyModuleTableCandidate | AbiModuleTableCandidate;
|
||||
|
||||
const drizzleTableIsAlias = Symbol.for("drizzle:IsAlias");
|
||||
|
||||
export class ModuleSchemaDiscoveryError extends Error {
|
||||
@@ -156,9 +181,13 @@ async function loadSchemaDefinitions(discoveredModules: readonly DiscoveredModul
|
||||
continue;
|
||||
}
|
||||
|
||||
let definition: ModuleSchemaDefinition;
|
||||
let definition: ModuleSchemaDefinition | ModuleSchemaMetadataDefinition;
|
||||
const mode = discovered.manifest.database?.abi === 1 ? "abi-v1" : "legacy";
|
||||
try {
|
||||
definition = parseModuleSchemaDefinition(defaultExport(moduleNamespace));
|
||||
definition =
|
||||
mode === "abi-v1"
|
||||
? parseModuleSchemaMetadataDefinition(defaultExport(moduleNamespace))
|
||||
: parseModuleSchemaDefinition(defaultExport(moduleNamespace));
|
||||
} catch {
|
||||
issues.push(
|
||||
issue(
|
||||
@@ -182,7 +211,11 @@ async function loadSchemaDefinitions(discoveredModules: readonly DiscoveredModul
|
||||
continue;
|
||||
}
|
||||
|
||||
loaded.push({ definition, discovered });
|
||||
loaded.push(
|
||||
mode === "abi-v1"
|
||||
? { definition: definition as ModuleSchemaMetadataDefinition, discovered, mode }
|
||||
: { definition: definition as ModuleSchemaDefinition, discovered, mode },
|
||||
);
|
||||
}
|
||||
|
||||
return { issues, loaded };
|
||||
@@ -197,6 +230,58 @@ function inspectTables(
|
||||
|
||||
for (const loaded of loadedSchemas) {
|
||||
const moduleId = loaded.discovered.manifest.id;
|
||||
if (loaded.mode === "abi-v1") {
|
||||
const candidates: AbiModuleTableCandidate[] = [];
|
||||
loaded.definition.tables.forEach((table, index) => {
|
||||
const path = `tables.${index}`;
|
||||
const candidate: AbiModuleTableCandidate = {
|
||||
exportName: table.name,
|
||||
foreignKeys: table.foreignKeys,
|
||||
mode: "abi-v1",
|
||||
moduleId,
|
||||
name: table.name,
|
||||
path,
|
||||
};
|
||||
const tablePrefix = moduleTablePrefix(moduleId);
|
||||
if (!table.name.startsWith(tablePrefix)) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_SCHEMA_TABLE_NAMESPACE_INVALID",
|
||||
moduleId,
|
||||
`Table ${table.name} is not namespaced for module ${moduleId}.`,
|
||||
{ path, tableName: table.name },
|
||||
),
|
||||
);
|
||||
} else if (
|
||||
!moduleTableLocalNameSchema.safeParse(table.name.slice(tablePrefix.length)).success
|
||||
) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_SCHEMA_TABLE_NAME_INVALID",
|
||||
moduleId,
|
||||
`Module ${moduleId} has an invalid local table name.`,
|
||||
{ path },
|
||||
),
|
||||
);
|
||||
}
|
||||
const existing = firstTableByName.get(table.name);
|
||||
if (existing) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_SCHEMA_TABLE_DUPLICATE",
|
||||
moduleId,
|
||||
`Table ${table.name} is declared more than once.`,
|
||||
{ path, tableName: table.name },
|
||||
),
|
||||
);
|
||||
} else {
|
||||
firstTableByName.set(table.name, candidate);
|
||||
}
|
||||
candidates.push(candidate);
|
||||
});
|
||||
inspectedModules.push({ discovered: loaded.discovered, tables: candidates });
|
||||
continue;
|
||||
}
|
||||
const seenTables = new Set<AnyPgTable>();
|
||||
const candidates: ModuleTableCandidate[] = [];
|
||||
const entries = Object.entries(loaded.definition.tables).sort(([left], [right]) =>
|
||||
@@ -293,6 +378,7 @@ function inspectTables(
|
||||
const candidate: ModuleTableCandidate = {
|
||||
exportName,
|
||||
foreignKeys: config.foreignKeys,
|
||||
mode: "legacy",
|
||||
moduleId,
|
||||
name: config.name,
|
||||
path,
|
||||
@@ -321,8 +407,8 @@ function inspectTables(
|
||||
}
|
||||
|
||||
function validForeignKeyTarget(
|
||||
foreignKey: ModuleTableCandidate["foreignKeys"][number],
|
||||
candidate: ModuleTableCandidate,
|
||||
foreignKey: LegacyModuleTableCandidate["foreignKeys"][number],
|
||||
candidate: LegacyModuleTableCandidate,
|
||||
): AnyPgTable | undefined {
|
||||
try {
|
||||
const reference = foreignKey.reference();
|
||||
@@ -356,7 +442,9 @@ function inspectForeignKeys(
|
||||
const ownerByTable = new Map<AnyPgTable, ModuleId>();
|
||||
for (const inspected of inspectedModules) {
|
||||
for (const candidate of inspected.tables) {
|
||||
ownerByTable.set(candidate.table, candidate.moduleId);
|
||||
if (candidate.mode === "legacy") {
|
||||
ownerByTable.set(candidate.table, candidate.moduleId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,6 +452,32 @@ function inspectForeignKeys(
|
||||
const moduleId = inspected.discovered.manifest.id;
|
||||
const ownedTables = new Set(inspected.tables.map((candidate) => candidate.table));
|
||||
for (const candidate of inspected.tables) {
|
||||
if (candidate.mode === "abi-v1") {
|
||||
const ownedNames = new Set(inspected.tables.map((table) => table.name));
|
||||
candidate.foreignKeys.forEach((foreignKey, foreignKeyIndex) => {
|
||||
const path = `${candidate.path}.foreignKeys.${foreignKeyIndex}`;
|
||||
if (!foreignKey.table.startsWith(moduleTablePrefix(moduleId))) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_SCHEMA_CROSS_MODULE_FOREIGN_KEY",
|
||||
moduleId,
|
||||
`Table ${candidate.name} has a foreign key outside module ${moduleId}.`,
|
||||
{ path, tableName: candidate.name },
|
||||
),
|
||||
);
|
||||
} else if (!ownedNames.has(foreignKey.table)) {
|
||||
issues.push(
|
||||
issue(
|
||||
"MODULE_SCHEMA_FOREIGN_KEY_TARGET_MISSING",
|
||||
moduleId,
|
||||
`Table ${candidate.name} references a table not exported by module ${moduleId}.`,
|
||||
{ path, tableName: candidate.name },
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
continue;
|
||||
}
|
||||
candidate.foreignKeys.forEach((foreignKey, foreignKeyIndex) => {
|
||||
const path = `${candidate.path}.foreignKeys.${foreignKeyIndex}`;
|
||||
const foreignTable = validForeignKeyTarget(foreignKey, candidate);
|
||||
@@ -446,7 +560,9 @@ function publishAggregate(
|
||||
moduleId: inspected.discovered.manifest.id,
|
||||
tables: Object.freeze(
|
||||
inspected.tables
|
||||
.map(({ foreignKeys: _foreignKeys, path: _path, ...table }) => Object.freeze(table))
|
||||
.map(({ foreignKeys: _foreignKeys, mode: _mode, path: _path, ...table }) =>
|
||||
Object.freeze(table),
|
||||
)
|
||||
.sort((left, right) => compareText(left.exportName, right.exportName)),
|
||||
),
|
||||
}),
|
||||
@@ -458,7 +574,11 @@ function publishAggregate(
|
||||
.sort((left, right) => compareText(left.name, right.name)),
|
||||
);
|
||||
const drizzleSchema = Object.freeze(
|
||||
Object.fromEntries(tables.map((table) => [table.name, table.table] as const)),
|
||||
Object.fromEntries(
|
||||
tables.flatMap((table) =>
|
||||
table.table === undefined ? [] : [[table.name, table.table] as const],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return Object.freeze({ discoveredModules, drizzleSchema, modules, tables });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { IMPORT_SAFETY_TEST_TIMEOUT_MS } from "./support/import-safety.js";
|
||||
|
||||
const poolConstructor = vi.hoisted(() => vi.fn());
|
||||
|
||||
@@ -7,16 +8,20 @@ vi.mock("pg", () => ({ Pool: poolConstructor }));
|
||||
describe("bootstrap-platform-owner CLI import safety", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
it("does not read DATABASE_URL, connect to PostgreSQL, or run bootstrap merely by importing the module", async () => {
|
||||
delete process.env.DATABASE_URL;
|
||||
it(
|
||||
"does not read DATABASE_URL, connect to PostgreSQL, or run bootstrap merely by importing the module",
|
||||
async () => {
|
||||
delete process.env.DATABASE_URL;
|
||||
|
||||
// Under Vitest, process.argv[1] is never this file's own path, so isDirectExecution() is
|
||||
// false the same way it naturally is for apps/api/src/server.ts under its own test suite —
|
||||
// no argv manipulation is needed to prove the guard holds.
|
||||
await expect(import("../src/cli/bootstrap-platform-owner.js")).resolves.toBeDefined();
|
||||
// Under Vitest, process.argv[1] is never this file's own path, so isDirectExecution() is
|
||||
// false the same way it naturally is for apps/api/src/server.ts under its own test suite —
|
||||
// no argv manipulation is needed to prove the guard holds.
|
||||
await expect(import("../src/cli/bootstrap-platform-owner.js")).resolves.toBeDefined();
|
||||
|
||||
expect(poolConstructor).not.toHaveBeenCalled();
|
||||
expect(poolConstructor).not.toHaveBeenCalled();
|
||||
|
||||
process.env = originalEnv;
|
||||
});
|
||||
process.env = originalEnv;
|
||||
},
|
||||
IMPORT_SAFETY_TEST_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { IMPORT_SAFETY_TEST_TIMEOUT_MS } from "./support/import-safety.js";
|
||||
|
||||
const poolConstructor = vi.hoisted(() => vi.fn());
|
||||
|
||||
@@ -7,18 +8,22 @@ vi.mock("pg", () => ({ Pool: poolConstructor }));
|
||||
describe("create-initial-owner CLI import safety", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
it("does not read DATABASE_URL, connect to PostgreSQL, or create anything merely by importing the module", async () => {
|
||||
delete process.env.DATABASE_URL;
|
||||
it(
|
||||
"does not read DATABASE_URL, connect to PostgreSQL, or create anything merely by importing the module",
|
||||
async () => {
|
||||
delete process.env.DATABASE_URL;
|
||||
|
||||
// Under Vitest, process.argv[1] is never this file's own path, so isDirectExecution() is false
|
||||
// exactly as it is for `bootstrap-platform-owner.ts` — no argv manipulation is needed to prove
|
||||
// the guard holds. Importing the command must never prompt, connect, or write.
|
||||
await expect(import("../src/cli/create-initial-owner.js")).resolves.toBeDefined();
|
||||
// Under Vitest, process.argv[1] is never this file's own path, so isDirectExecution() is false
|
||||
// exactly as it is for `bootstrap-platform-owner.ts` — no argv manipulation is needed to prove
|
||||
// the guard holds. Importing the command must never prompt, connect, or write.
|
||||
await expect(import("../src/cli/create-initial-owner.js")).resolves.toBeDefined();
|
||||
|
||||
expect(poolConstructor).not.toHaveBeenCalled();
|
||||
expect(poolConstructor).not.toHaveBeenCalled();
|
||||
|
||||
process.env = originalEnv;
|
||||
});
|
||||
process.env = originalEnv;
|
||||
},
|
||||
IMPORT_SAFETY_TEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it("reads no environment variable for the password", async () => {
|
||||
// The password is prompted for or read from standard input; it is deliberately never taken from
|
||||
|
||||
@@ -91,7 +91,7 @@ async function createModule(
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
|
||||
@@ -59,7 +59,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
: {}),
|
||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||
id: fixture.id,
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
name: fixture.name,
|
||||
version: "1.0.0",
|
||||
}),
|
||||
|
||||
@@ -83,7 +83,7 @@ function discoveredModule(id: string, overrides: Record<string, unknown> = {}):
|
||||
},
|
||||
entrypoints: { server: "./dist/server.js" },
|
||||
id,
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
name: `${id} module`,
|
||||
permissions: [],
|
||||
storage: { buckets: [] },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { IMPORT_SAFETY_TEST_TIMEOUT_MS } from "./support/import-safety.js";
|
||||
|
||||
const discoverModules = vi.fn();
|
||||
const discoverModuleSchemas = vi.fn();
|
||||
@@ -22,24 +23,28 @@ vi.mock("../src/migrations/index.js", async (importOriginal) => ({
|
||||
}));
|
||||
|
||||
describe("Doctor import safety", () => {
|
||||
it("does not discover, connect, plan, or print when imported", async () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
it(
|
||||
"does not discover, connect, plan, or print when imported",
|
||||
async () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
|
||||
await import("../src/diagnostics/index.js");
|
||||
await import("../src/cli/doctor.js");
|
||||
await import("../src/index.js");
|
||||
await import("../src/diagnostics/index.js");
|
||||
await import("../src/cli/doctor.js");
|
||||
await import("../src/index.js");
|
||||
|
||||
expect(discoverModules).not.toHaveBeenCalled();
|
||||
expect(discoverModuleSchemas).not.toHaveBeenCalled();
|
||||
expect(discoverModuleMigrations).not.toHaveBeenCalled();
|
||||
expect(startModuleRuntime).not.toHaveBeenCalled();
|
||||
expect(createPostgresMigrationDatabase).not.toHaveBeenCalled();
|
||||
expect(planModuleMigrations).not.toHaveBeenCalled();
|
||||
expect(log).not.toHaveBeenCalled();
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
expect(discoverModules).not.toHaveBeenCalled();
|
||||
expect(discoverModuleSchemas).not.toHaveBeenCalled();
|
||||
expect(discoverModuleMigrations).not.toHaveBeenCalled();
|
||||
expect(startModuleRuntime).not.toHaveBeenCalled();
|
||||
expect(createPostgresMigrationDatabase).not.toHaveBeenCalled();
|
||||
expect(planModuleMigrations).not.toHaveBeenCalled();
|
||||
expect(log).not.toHaveBeenCalled();
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
|
||||
log.mockRestore();
|
||||
error.mockRestore();
|
||||
});
|
||||
log.mockRestore();
|
||||
error.mockRestore();
|
||||
},
|
||||
IMPORT_SAFETY_TEST_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { type ModuleManifest, parsePermissionId } from "@ekklesia/contracts";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { type ModuleManifest, parsePermissionId } from "@ekklesia/contracts";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createPermissionRegistry, PermissionRegistryError } from "../src/authorization/index.js";
|
||||
import {
|
||||
@@ -32,7 +32,11 @@ const checksumA = "a".repeat(64);
|
||||
const checksumB = "b".repeat(64);
|
||||
const checksumC = "c".repeat(64);
|
||||
|
||||
function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
|
||||
function discoveredModule(
|
||||
id: string,
|
||||
version = "0.1.0",
|
||||
database?: { readonly abi: 1 },
|
||||
): DiscoveredModule {
|
||||
return {
|
||||
contributions: [],
|
||||
directory: `/unpublished/${id}`,
|
||||
@@ -41,13 +45,14 @@ function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
|
||||
capabilities: [],
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
configuration: [],
|
||||
...(database ? { database } : {}),
|
||||
dependencies: {
|
||||
capabilities: { optional: {}, required: {} },
|
||||
modules: { optional: {}, required: {} },
|
||||
},
|
||||
entrypoints: { server: "./dist/server.js" },
|
||||
id,
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
name: `${id} module`,
|
||||
permissions: [],
|
||||
storage: { buckets: [] },
|
||||
@@ -294,7 +299,7 @@ describe("Ekklesia Doctor outbound email reporting", () => {
|
||||
describe("Ekklesia Doctor diagnostics", () => {
|
||||
it("publishes a healthy deterministic static report without database configuration", async () => {
|
||||
const people = discoveredModule("people");
|
||||
const calendar = discoveredModule("calendar", "0.2.0");
|
||||
const calendar = discoveredModule("calendar", "0.2.0", { abi: 1 });
|
||||
const inventory = migrationAggregate(
|
||||
[migration("people", "0000_initial", checksumA)],
|
||||
[people, calendar],
|
||||
@@ -327,6 +332,7 @@ describe("Ekklesia Doctor diagnostics", () => {
|
||||
]);
|
||||
expect(check(report, "modules.discovery").details).toEqual(["people 0.1.0", "calendar 0.2.0"]);
|
||||
expect(check(report, "modules.dependencies").details).toEqual(["Order: people → calendar"]);
|
||||
expect(check(report, "schemas.discovery")).toMatchObject({ status: "pass" });
|
||||
expect(check(report, "modules.permissions")).toMatchObject({
|
||||
details: ["0 permissions registered"],
|
||||
status: "pass",
|
||||
|
||||
+3
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"manifestVersion": 7,
|
||||
"manifestVersion": 8,
|
||||
"id": "db-facade",
|
||||
"name": "Database Facade Integration Test Module",
|
||||
"description": "Test-only installed module fixture proving ModuleDatabase against real PostgreSQL. Never discovered by production startup, Doctor, or normal migration aggregation — only apps/api/tests/module-database.integration.ts points discovery at it explicitly.",
|
||||
"version": "0.1.0",
|
||||
"compatibility": {
|
||||
"platform": "^0.1.0",
|
||||
"moduleSdk": "^0.1.0"
|
||||
"platform": "^0.2.0",
|
||||
"moduleSdk": "^0.2.0"
|
||||
},
|
||||
"permissions": [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { alias, getTableConfig, integer, pgTable } from "drizzle-orm/pg-core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseModuleSchemaDefinition } from "../src/modules/legacy-schema-definition.js";
|
||||
|
||||
describe("the legacy module schema definition", () => {
|
||||
it("normalizes and freezes a typed schema definition without freezing its tables", () => {
|
||||
const records = pgTable("example_records", { id: integer().primaryKey() });
|
||||
const definition = parseModuleSchemaDefinition({
|
||||
moduleId: "example",
|
||||
tables: { records },
|
||||
});
|
||||
|
||||
expect(definition.tables.records).toBe(records);
|
||||
expect(getTableConfig(records).name).toBe("example_records");
|
||||
expect(Object.isFrozen(definition)).toBe(true);
|
||||
expect(Object.isFrozen(definition.tables)).toBe(true);
|
||||
expect(Object.isFrozen(records)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects malformed, empty, unknown, and non-PostgreSQL table definitions", () => {
|
||||
const records = pgTable("example_records", { id: integer() });
|
||||
const disguisedRecords = alias(records, "example_disguised_records");
|
||||
expect(() => parseModuleSchemaDefinition({ moduleId: "example", tables: {} })).toThrow();
|
||||
expect(() =>
|
||||
parseModuleSchemaDefinition({ moduleId: "example", tables: { records: {} } }),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
parseModuleSchemaDefinition({ moduleId: "example", tables: { records }, extra: true }),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
parseModuleSchemaDefinition({ moduleId: "example", tables: { disguisedRecords } }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,7 @@ function fakeModule(
|
||||
} = {},
|
||||
): DiscoveredModule {
|
||||
const manifest = parseModuleManifest({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "1.0.0",
|
||||
|
||||
@@ -194,7 +194,7 @@ afterAll(async () => {
|
||||
|
||||
function manifestWith(configuration: readonly Record<string, unknown>[], id = "announcements") {
|
||||
return parseModuleManifest({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "1.0.0",
|
||||
|
||||
@@ -21,7 +21,7 @@ function manifestWith(
|
||||
id = "announcements",
|
||||
) {
|
||||
return parseModuleManifest({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "1.0.0",
|
||||
@@ -92,7 +92,7 @@ describe("module configuration declarations", () => {
|
||||
|
||||
it("rejects two declarations of the same key, so an ordinary key and a secret can never collide", () => {
|
||||
const result = safeParseModuleManifest({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id: "announcements",
|
||||
name: "announcements module",
|
||||
version: "1.0.0",
|
||||
@@ -114,7 +114,7 @@ describe("module configuration declarations", () => {
|
||||
|
||||
it("rejects a default on a secret, because a checked-in default credential is a leaked one", () => {
|
||||
const result = safeParseModuleManifest({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id: "announcements",
|
||||
name: "announcements module",
|
||||
version: "1.0.0",
|
||||
@@ -130,7 +130,7 @@ describe("module configuration declarations", () => {
|
||||
|
||||
it("rejects a key that is both required and defaulted, which states an intent nothing can honour", () => {
|
||||
const result = safeParseModuleManifest({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id: "announcements",
|
||||
name: "announcements module",
|
||||
version: "1.0.0",
|
||||
@@ -169,7 +169,7 @@ describe("module configuration declarations", () => {
|
||||
],
|
||||
])("rejects %s", (_label, declaration) => {
|
||||
const result = safeParseModuleManifest({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id: "announcements",
|
||||
name: "announcements module",
|
||||
version: "1.0.0",
|
||||
@@ -189,7 +189,7 @@ describe("module configuration declarations", () => {
|
||||
"valueOf",
|
||||
])("rejects the reserved object property name %s as a configuration key", (key) => {
|
||||
const result = safeParseModuleManifest({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id: "announcements",
|
||||
name: "announcements module",
|
||||
version: "1.0.0",
|
||||
@@ -210,7 +210,7 @@ describe("module configuration declarations", () => {
|
||||
"",
|
||||
])("rejects the malformed configuration key %s", (key) => {
|
||||
const result = safeParseModuleManifest({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id: "announcements",
|
||||
name: "announcements module",
|
||||
version: "1.0.0",
|
||||
@@ -591,7 +591,7 @@ describe("configuration over a real discovered inventory", () => {
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "1.0.0",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,867 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import {
|
||||
isModuleDatabaseError,
|
||||
type ModuleDatabaseErrorCode,
|
||||
type ModuleDatabaseTransaction,
|
||||
} from "@ekklesia/contracts";
|
||||
import { DatabaseError, type Pool } from "pg";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createModuleDatabaseExecutor,
|
||||
moduleDatabaseExecutorModuleId,
|
||||
} from "../src/modules/database-abi.js";
|
||||
import {
|
||||
decodeModuleDatabaseValue,
|
||||
encodeModuleDatabaseParameters,
|
||||
MODULE_DATABASE_MAX_PARAMETERS,
|
||||
parsePostgresArrayText,
|
||||
} from "../src/modules/database-abi-values.js";
|
||||
|
||||
/**
|
||||
* Module Database ABI v1 (ADR 0050) without a database: parameter validation and encoding, result
|
||||
* decoding from PostgreSQL text, option validation, error sanitization, and scope bookkeeping.
|
||||
*
|
||||
* Everything that depends on PostgreSQL's own behaviour (type OIDs, the protocol, SQLSTATEs,
|
||||
* transactions, savepoints, session settings) is proven against a real database in
|
||||
* `module-database-abi.integration.ts`. The scripted client here only replays command tags and
|
||||
* transaction status, so the executor's own bookkeeping can be observed statement by statement.
|
||||
*/
|
||||
|
||||
async function rejection(promise: Promise<unknown>): Promise<unknown> {
|
||||
try {
|
||||
await promise;
|
||||
} catch (error) {
|
||||
return error;
|
||||
}
|
||||
throw new Error("Expected the operation to reject.");
|
||||
}
|
||||
|
||||
async function expectAbiError(
|
||||
promise: Promise<unknown>,
|
||||
code: ModuleDatabaseErrorCode,
|
||||
sqlState?: string,
|
||||
): Promise<void> {
|
||||
const error = await rejection(promise);
|
||||
expect(isModuleDatabaseError(error)).toBe(true);
|
||||
expect(error).toMatchObject({ code });
|
||||
expect((error as { sqlState?: string }).sqlState).toBe(sqlState);
|
||||
}
|
||||
|
||||
function syncThrows(run: () => unknown): unknown {
|
||||
try {
|
||||
run();
|
||||
} catch (error) {
|
||||
return error;
|
||||
}
|
||||
throw new Error("Expected the call to throw.");
|
||||
}
|
||||
|
||||
// --- Parameters --------------------------------------------------------------------------------
|
||||
|
||||
describe("encodeModuleDatabaseParameters", () => {
|
||||
it("encodes every scalar category as text, or bytes, and nothing else", () => {
|
||||
const bytes = new Uint8Array([0, 1, 255]);
|
||||
const encoded = encodeModuleDatabaseParameters([
|
||||
null,
|
||||
true,
|
||||
false,
|
||||
1.5,
|
||||
Number.NaN,
|
||||
Number.POSITIVE_INFINITY,
|
||||
Number.NEGATIVE_INFINITY,
|
||||
9_007_199_254_740_993n,
|
||||
'it\'s \\ "quoted"',
|
||||
bytes,
|
||||
]);
|
||||
expect(encoded.slice(0, 9)).toEqual([
|
||||
null,
|
||||
"true",
|
||||
"false",
|
||||
"1.5",
|
||||
"NaN",
|
||||
"Infinity",
|
||||
"-Infinity",
|
||||
"9007199254740993",
|
||||
'it\'s \\ "quoted"',
|
||||
]);
|
||||
expect(Buffer.isBuffer(encoded[9])).toBe(true);
|
||||
expect([...(encoded[9] as Buffer)]).toEqual([0, 1, 255]);
|
||||
});
|
||||
|
||||
it("copies bytes, so a later change by the caller cannot reach the statement", () => {
|
||||
const bytes = new Uint8Array([1, 2, 3]);
|
||||
const view = bytes.subarray(1);
|
||||
const [encoded] = encodeModuleDatabaseParameters([view]);
|
||||
bytes[1] = 9;
|
||||
expect([...(encoded as Buffer)]).toEqual([2, 3]);
|
||||
});
|
||||
|
||||
it("encodes arrays as quoted PostgreSQL array literals, nested for more dimensions", () => {
|
||||
expect(
|
||||
encodeModuleDatabaseParameters([
|
||||
[1, null, 3],
|
||||
['a"b', "c\\d", "", "NULL", "{,}"],
|
||||
[
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
],
|
||||
[new Uint8Array([0, 255])],
|
||||
[true, 2n],
|
||||
[],
|
||||
]),
|
||||
).toEqual([
|
||||
'{"1",NULL,"3"}',
|
||||
'{"a\\"b","c\\\\d","","NULL","{,}"}',
|
||||
'{{"1","2"},{"3","4"}}',
|
||||
'{"\\\\x00ff"}',
|
||||
'{"true","2"}',
|
||||
"{}",
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats omitted values as no parameters", () => {
|
||||
expect(encodeModuleDatabaseParameters(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
class Point {
|
||||
readonly x = 1;
|
||||
}
|
||||
|
||||
const refused: readonly [string, unknown][] = [
|
||||
["a Date", new Date(0)],
|
||||
["a plain object", { a: 1 }],
|
||||
["a null-prototype object", Object.create(null)],
|
||||
["undefined", undefined],
|
||||
["a function", () => 1],
|
||||
["a symbol", Symbol("s")],
|
||||
["a class instance", new Point()],
|
||||
["a Map", new Map()],
|
||||
["another typed array", new Int16Array([1])],
|
||||
["an ArrayBuffer", new ArrayBuffer(1)],
|
||||
["a boxed string", new String("x")],
|
||||
["an array holding a Date", [new Date(0)]],
|
||||
["an array holding undefined", [1, undefined]],
|
||||
["an array holding an object", [{ a: 1 }]],
|
||||
// biome-ignore lint/suspicious/noSparseArray: the hole is the case under test.
|
||||
["a sparse array", [1, , 3]],
|
||||
["scalars mixed with sub-arrays", [1, [2]]],
|
||||
["a null where a sub-array belongs", [[1], null]],
|
||||
["ragged sub-arrays", [[1], [1, 2]]],
|
||||
["more dimensions than PostgreSQL allows", [[[[[[[1]]]]]]]],
|
||||
];
|
||||
|
||||
for (const [label, value] of refused) {
|
||||
it(`refuses ${label}, without naming the value`, () => {
|
||||
const error = syncThrows(() => encodeModuleDatabaseParameters(["fine", value]));
|
||||
expect(isModuleDatabaseError(error)).toBe(true);
|
||||
expect(error).toMatchObject({ code: "MODULE_DATABASE_PARAMETER_INVALID" });
|
||||
expect((error as Error).message).not.toMatch(/fine|1970|Point|Symbol/u);
|
||||
});
|
||||
}
|
||||
|
||||
it("accepts exactly PostgreSQL's six array dimensions", () => {
|
||||
expect(encodeModuleDatabaseParameters([[[[[[[1]]]]]]])).toEqual(['{{{{{{"1"}}}}}}']);
|
||||
});
|
||||
|
||||
it("refuses a values list that is not an array, or longer than the protocol allows", () => {
|
||||
for (const values of [
|
||||
"x",
|
||||
{ 0: "x", length: 1 },
|
||||
new Array(MODULE_DATABASE_MAX_PARAMETERS + 1).fill(1),
|
||||
]) {
|
||||
const error = syncThrows(() => encodeModuleDatabaseParameters(values));
|
||||
expect(error).toMatchObject({ code: "MODULE_DATABASE_PARAMETER_INVALID" });
|
||||
}
|
||||
expect(
|
||||
encodeModuleDatabaseParameters(new Array(MODULE_DATABASE_MAX_PARAMETERS).fill(1)),
|
||||
).toHaveLength(MODULE_DATABASE_MAX_PARAMETERS);
|
||||
});
|
||||
|
||||
it("refuses a self-referencing array instead of recursing forever", () => {
|
||||
const cyclic: unknown[] = [];
|
||||
cyclic.push(cyclic);
|
||||
expect(syncThrows(() => encodeModuleDatabaseParameters([cyclic]))).toMatchObject({
|
||||
code: "MODULE_DATABASE_PARAMETER_INVALID",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- Results -----------------------------------------------------------------------------------
|
||||
|
||||
describe("decodeModuleDatabaseValue", () => {
|
||||
it("decodes the scalar types the table names, and keeps everything else as exact text", () => {
|
||||
expect(decodeModuleDatabaseValue(16, "t")).toBe(true);
|
||||
expect(decodeModuleDatabaseValue(16, "f")).toBe(false);
|
||||
expect(decodeModuleDatabaseValue(21, "-32768")).toBe(-32_768);
|
||||
expect(decodeModuleDatabaseValue(23, "2147483647")).toBe(2_147_483_647);
|
||||
expect(decodeModuleDatabaseValue(26, "4294967295")).toBe(4_294_967_295);
|
||||
expect(decodeModuleDatabaseValue(700, "1.5")).toBe(1.5);
|
||||
expect(decodeModuleDatabaseValue(701, "NaN")).toBeNaN();
|
||||
expect(decodeModuleDatabaseValue(701, "Infinity")).toBe(Number.POSITIVE_INFINITY);
|
||||
expect(decodeModuleDatabaseValue(701, "-Infinity")).toBe(Number.NEGATIVE_INFINITY);
|
||||
expect(decodeModuleDatabaseValue(701, "1e+300")).toBe(1e300);
|
||||
// Precision-sensitive and zone-sensitive types stay exactly as PostgreSQL wrote them.
|
||||
expect(decodeModuleDatabaseValue(20, "9223372036854775807")).toBe("9223372036854775807");
|
||||
expect(decodeModuleDatabaseValue(1700, "0.1000000000000000000001")).toBe(
|
||||
"0.1000000000000000000001",
|
||||
);
|
||||
expect(decodeModuleDatabaseValue(3802, '{"a": 12345678901234567890}')).toBe(
|
||||
'{"a": 12345678901234567890}',
|
||||
);
|
||||
expect(decodeModuleDatabaseValue(1184, "2026-10-07 12:00:00+00")).toBe(
|
||||
"2026-10-07 12:00:00+00",
|
||||
);
|
||||
// An unknown OID (an enum, a domain, an extension type) is text.
|
||||
expect(decodeModuleDatabaseValue(987_654, "anything")).toBe("anything");
|
||||
});
|
||||
|
||||
it("decodes bytea to a plain Uint8Array, from hex or the older escape form", () => {
|
||||
const hex = decodeModuleDatabaseValue(17, "\\x00ff10");
|
||||
expect(Object.getPrototypeOf(hex)).toBe(Uint8Array.prototype);
|
||||
expect([...(hex as Uint8Array)]).toEqual([0, 255, 16]);
|
||||
expect([...(decodeModuleDatabaseValue(17, "\\x") as Uint8Array)]).toEqual([]);
|
||||
const escaped = decodeModuleDatabaseValue(17, "a\\000\\\\\\377");
|
||||
expect([...(escaped as Uint8Array)]).toEqual([97, 0, 92, 255]);
|
||||
});
|
||||
|
||||
it("decodes built-in arrays element by element", () => {
|
||||
expect(decodeModuleDatabaseValue(1016, "{1,NULL,9223372036854775807}")).toEqual([
|
||||
"1",
|
||||
null,
|
||||
"9223372036854775807",
|
||||
]);
|
||||
expect(decodeModuleDatabaseValue(1231, "{1.10,2}")).toEqual(["1.10", "2"]);
|
||||
expect(decodeModuleDatabaseValue(1000, "{t,f}")).toEqual([true, false]);
|
||||
expect(decodeModuleDatabaseValue(1007, "{{1,2},{3,4}}")).toEqual([
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
]);
|
||||
const bytes = decodeModuleDatabaseValue(1001, '{"\\\\x00ff"}') as Uint8Array[];
|
||||
expect([...(bytes[0] as Uint8Array)]).toEqual([0, 255]);
|
||||
expect(decodeModuleDatabaseValue(1009, "{}")).toEqual([]);
|
||||
// An array of a type the table does not know arrives as text, like the type itself.
|
||||
expect(decodeModuleDatabaseValue(987_655, "{a,b}")).toBe("{a,b}");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePostgresArrayText", () => {
|
||||
const text = (value: string) => value;
|
||||
|
||||
it('honours quoting, escapes, and the difference between NULL and "NULL"', () => {
|
||||
expect(parsePostgresArrayText('{"a\\"b","c,d",NULL,"NULL","","{}",null}', text)).toEqual([
|
||||
'a"b',
|
||||
"c,d",
|
||||
null,
|
||||
"NULL",
|
||||
"",
|
||||
"{}",
|
||||
null,
|
||||
]);
|
||||
expect(parsePostgresArrayText('{"back\\\\slash"}', text)).toEqual(["back\\slash"]);
|
||||
});
|
||||
|
||||
it("drops a non-default lower bound: a JavaScript array is a zero-based list", () => {
|
||||
expect(parsePostgresArrayText("[0:1]={5,6}", text)).toEqual(["5", "6"]);
|
||||
expect(parsePostgresArrayText("[1:1][2:3]={{7,8}}", text)).toEqual([["7", "8"]]);
|
||||
});
|
||||
|
||||
it("refuses text that is not an array literal", () => {
|
||||
for (const malformed of ["", "{", "{1", '{"a', "{1}x", "[1:2]{1,2}", "1"]) {
|
||||
expect(() => parsePostgresArrayText(malformed, text)).toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- The executor, over a scripted client -------------------------------------------------------
|
||||
|
||||
interface ScriptedStatement {
|
||||
readonly text: string;
|
||||
readonly values?: readonly unknown[];
|
||||
}
|
||||
|
||||
interface Script {
|
||||
readonly pool: Pool;
|
||||
readonly statements: ScriptedStatement[];
|
||||
readonly released: (boolean | undefined)[];
|
||||
/** Resolve a statement only when `release` is called, to model in-flight work. */
|
||||
holdNext(): () => void;
|
||||
failNext(error: unknown): void;
|
||||
/** Fail the next statement whose text is exactly `text`. */
|
||||
failWhen(text: string, error: unknown): void;
|
||||
connectCount(): number;
|
||||
}
|
||||
|
||||
function commandOf(text: string): string {
|
||||
const word = /^\s*([A-Za-z]+)/u.exec(text)?.[1]?.toUpperCase() ?? "";
|
||||
return word === "END" ? "COMMIT" : word;
|
||||
}
|
||||
|
||||
/**
|
||||
* A pool whose one client replays PostgreSQL's command tags and transaction status for simple SQL.
|
||||
* It is not PostgreSQL: it exists only so the executor's bookkeeping can be observed.
|
||||
*/
|
||||
function scriptedPool(): Script {
|
||||
const statements: ScriptedStatement[] = [];
|
||||
const released: (boolean | undefined)[] = [];
|
||||
let status = "I";
|
||||
let connects = 0;
|
||||
let hold: Promise<void> | undefined;
|
||||
let failure: unknown;
|
||||
const failures = new Map<string, unknown>();
|
||||
|
||||
const client = {
|
||||
getTransactionStatus: () => status,
|
||||
on: () => client,
|
||||
removeListener: () => client,
|
||||
async query(config: string | { text: string; values?: unknown[] }) {
|
||||
const text = typeof config === "string" ? config : config.text;
|
||||
statements.push(
|
||||
typeof config === "string" ? { text } : { text, values: config.values ?? [] },
|
||||
);
|
||||
const held = hold;
|
||||
hold = undefined;
|
||||
if (held !== undefined) {
|
||||
await held;
|
||||
}
|
||||
if (failure !== undefined || failures.has(text)) {
|
||||
const error = failure ?? failures.get(text);
|
||||
failure = undefined;
|
||||
failures.delete(text);
|
||||
if (status === "T") {
|
||||
status = "E";
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const command = commandOf(text);
|
||||
if (command === "BEGIN" || command === "START") {
|
||||
status = "T";
|
||||
} else if (command === "COMMIT" || (command === "ROLLBACK" && !/\bTO\b/iu.test(text))) {
|
||||
const aborted = status === "E";
|
||||
status = "I";
|
||||
return { command: aborted ? "ROLLBACK" : command, fields: [], rowCount: null, rows: [] };
|
||||
}
|
||||
return {
|
||||
command,
|
||||
fields: [{ dataTypeID: 23, name: "n" }],
|
||||
rowCount: 1,
|
||||
rows: [["1"]],
|
||||
};
|
||||
},
|
||||
release: (destroy?: boolean) => {
|
||||
released.push(destroy);
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
connectCount: () => connects,
|
||||
failNext(error) {
|
||||
failure = error;
|
||||
},
|
||||
failWhen(text, error) {
|
||||
failures.set(text, error);
|
||||
},
|
||||
holdNext() {
|
||||
let release!: () => void;
|
||||
hold = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
return release;
|
||||
},
|
||||
pool: {
|
||||
connect: vi.fn(async () => {
|
||||
connects += 1;
|
||||
return client;
|
||||
}),
|
||||
} as unknown as Pool,
|
||||
released,
|
||||
statements,
|
||||
};
|
||||
}
|
||||
|
||||
function pgError(code: string): DatabaseError {
|
||||
const error = new DatabaseError(
|
||||
'duplicate key value violates unique constraint "example_secret_key"',
|
||||
100,
|
||||
"error",
|
||||
);
|
||||
error.code = code;
|
||||
error.detail = "Key (secret)=(hunter2) already exists.";
|
||||
error.hint = "a hint";
|
||||
error.table = "example_records";
|
||||
error.constraint = "example_secret_key";
|
||||
return error;
|
||||
}
|
||||
|
||||
const pin =
|
||||
"SET TimeZone TO 'UTC'; SET DateStyle TO 'ISO, MDY'; SET IntervalStyle TO 'postgres'; SET bytea_output TO 'hex'; SET extra_float_digits TO 1";
|
||||
|
||||
function texts(script: Script): string[] {
|
||||
return script.statements.map((statement) => statement.text);
|
||||
}
|
||||
|
||||
describe("createModuleDatabaseExecutor", () => {
|
||||
it("builds a frozen executor holding exactly the ABI's members, without touching the pool", () => {
|
||||
const script = scriptedPool();
|
||||
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||
|
||||
expect(Object.keys(executor).sort()).toEqual(["abiVersion", "query", "transaction"]);
|
||||
expect(executor.abiVersion).toBe(1);
|
||||
expect(Object.isFrozen(executor)).toBe(true);
|
||||
expect(Object.getPrototypeOf(executor)).toBe(Object.prototype);
|
||||
for (const forbidden of ["pool", "client", "$client", "_", "session", "dialect", "select"]) {
|
||||
expect(forbidden in executor).toBe(false);
|
||||
}
|
||||
expect(script.connectCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("carries its module for Core's attribution, and refuses an invalid module ID", () => {
|
||||
const executor = createModuleDatabaseExecutor(scriptedPool().pool, { moduleId: "care-team" });
|
||||
expect(moduleDatabaseExecutorModuleId(executor)).toBe("care-team");
|
||||
expect(moduleDatabaseExecutorModuleId({})).toBeUndefined();
|
||||
expect(() =>
|
||||
createModuleDatabaseExecutor(scriptedPool().pool, { moduleId: "Not A Module" }),
|
||||
).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it("pins the session, runs one extended-protocol statement, and resets before releasing", async () => {
|
||||
const script = scriptedPool();
|
||||
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||
|
||||
const result = await executor.query({ text: "SELECT 1 AS n", values: [5n] });
|
||||
|
||||
expect(result).toEqual({
|
||||
command: "SELECT",
|
||||
fields: [{ name: "n", typeOid: 23 }],
|
||||
rowCount: 1,
|
||||
rows: [{ n: 1 }],
|
||||
});
|
||||
expect(texts(script)).toEqual([pin, "SELECT 1 AS n", "DISCARD ALL"]);
|
||||
expect(script.statements[1]).toEqual({ text: "SELECT 1 AS n", values: ["5"] });
|
||||
expect(script.released).toEqual([undefined]);
|
||||
expect(Object.isFrozen(result)).toBe(true);
|
||||
expect(Object.isFrozen(result.rows)).toBe(true);
|
||||
expect(Object.isFrozen(result.fields)).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses a malformed request before borrowing a connection", async () => {
|
||||
const script = scriptedPool();
|
||||
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||
const query = executor.query as (request: unknown) => Promise<unknown>;
|
||||
|
||||
for (const request of [
|
||||
undefined,
|
||||
null,
|
||||
"SELECT 1",
|
||||
[],
|
||||
{ text: "" },
|
||||
{ text: 1 },
|
||||
{ text: "SELECT 1", rowMode: "rows" },
|
||||
{ text: "SELECT 1", name: "prepared" },
|
||||
{ text: "SELECT 1", types: [] },
|
||||
]) {
|
||||
await expectAbiError(query(request), "MODULE_DATABASE_REQUEST_INVALID");
|
||||
}
|
||||
await expectAbiError(
|
||||
query({ text: "SELECT $1", values: [new Date()] }),
|
||||
"MODULE_DATABASE_PARAMETER_INVALID",
|
||||
);
|
||||
expect(script.connectCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("reports only PostgreSQL's SQLSTATE from a database error, never its text", async () => {
|
||||
const script = scriptedPool();
|
||||
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||
script.failWhen("SELECT 1", pgError("23505"));
|
||||
const error = await rejection(executor.query({ text: "SELECT 1" }));
|
||||
|
||||
expect(error).toMatchObject({ code: "MODULE_DATABASE_QUERY_FAILED", sqlState: "23505" });
|
||||
const serialized = `${JSON.stringify(error)} ${String(error)} ${(error as Error).stack}`;
|
||||
for (const secret of [
|
||||
"hunter2",
|
||||
"example_secret_key",
|
||||
"example_records",
|
||||
"duplicate",
|
||||
"hint",
|
||||
]) {
|
||||
expect(serialized).not.toContain(secret);
|
||||
}
|
||||
expect((error as Error).cause).toBeUndefined();
|
||||
// A PostgreSQL error leaves the connection usable, so it is reset and returned.
|
||||
expect(script.released).toEqual([undefined]);
|
||||
});
|
||||
|
||||
it("drops a non-PostgreSQL failure's code and destroys the connection it happened on", async () => {
|
||||
const script = scriptedPool();
|
||||
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||
script.failWhen(
|
||||
"SELECT 1",
|
||||
Object.assign(new Error("Connection terminated: postgres://u:p@h/db"), { code: "EPIPE" }),
|
||||
);
|
||||
|
||||
const error = await rejection(executor.query({ text: "SELECT 1" }));
|
||||
expect(error).toMatchObject({ code: "MODULE_DATABASE_QUERY_FAILED" });
|
||||
expect((error as { sqlState?: string }).sqlState).toBeUndefined();
|
||||
expect(JSON.stringify(error)).not.toContain("postgres://");
|
||||
expect(script.released).toEqual([true]);
|
||||
});
|
||||
|
||||
it("refuses a top-level transaction-control statement and destroys a connection it left open", async () => {
|
||||
const script = scriptedPool();
|
||||
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||
|
||||
await expectAbiError(executor.query({ text: "BEGIN" }), "MODULE_DATABASE_REQUEST_INVALID");
|
||||
expect(script.released).toEqual([true]);
|
||||
expect(texts(script)).not.toContain("DISCARD ALL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("transaction options", () => {
|
||||
const invalid: readonly unknown[] = [
|
||||
null,
|
||||
"serializable",
|
||||
[],
|
||||
{ isolation: "read uncommitted" },
|
||||
{ isolation: "SERIALIZABLE" },
|
||||
{ isolation: "constructor" },
|
||||
{ readOnly: "yes" },
|
||||
{ readOnly: 1 },
|
||||
{ deferrable: true },
|
||||
{ isolation: "serializable", retries: 3 },
|
||||
];
|
||||
|
||||
for (const options of invalid) {
|
||||
it(`refuses ${JSON.stringify(options)} before borrowing a connection`, async () => {
|
||||
const script = scriptedPool();
|
||||
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||
const callback = vi.fn();
|
||||
await expectAbiError(
|
||||
executor.transaction(callback, options as never),
|
||||
"MODULE_DATABASE_TRANSACTION_OPTION_INVALID",
|
||||
);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
expect(script.connectCount()).toBe(0);
|
||||
});
|
||||
}
|
||||
|
||||
it("refuses a callback that is not a function", async () => {
|
||||
const script = scriptedPool();
|
||||
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||
await expectAbiError(
|
||||
executor.transaction("SELECT 1" as never),
|
||||
"MODULE_DATABASE_REQUEST_INVALID",
|
||||
);
|
||||
expect(script.connectCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("begins every transaction with an explicit isolation level and access mode", async () => {
|
||||
const cases: readonly [unknown, string][] = [
|
||||
[undefined, "BEGIN ISOLATION LEVEL READ COMMITTED READ WRITE"],
|
||||
[{}, "BEGIN ISOLATION LEVEL READ COMMITTED READ WRITE"],
|
||||
[{ readOnly: true }, "BEGIN ISOLATION LEVEL READ COMMITTED READ ONLY"],
|
||||
[{ isolation: "repeatable read" }, "BEGIN ISOLATION LEVEL REPEATABLE READ READ WRITE"],
|
||||
[
|
||||
{ isolation: "serializable", readOnly: false },
|
||||
"BEGIN ISOLATION LEVEL SERIALIZABLE READ WRITE",
|
||||
],
|
||||
];
|
||||
for (const [options, begin] of cases) {
|
||||
const script = scriptedPool();
|
||||
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||
await executor.transaction(async () => undefined, options as never);
|
||||
expect(texts(script)).toEqual([pin, begin, "COMMIT", "DISCARD ALL"]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("transaction scopes", () => {
|
||||
it("commits a resolved callback and returns its value", async () => {
|
||||
const script = scriptedPool();
|
||||
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||
|
||||
const value = await executor.transaction(async (tx) => {
|
||||
expect(Object.keys(tx).sort()).toEqual(["abiVersion", "query", "savepoint"]);
|
||||
expect(Object.isFrozen(tx)).toBe(true);
|
||||
expect(moduleDatabaseExecutorModuleId(tx)).toBe("example");
|
||||
await tx.query({ text: "INSERT INTO example_records VALUES (1)" });
|
||||
return "done";
|
||||
});
|
||||
|
||||
expect(value).toBe("done");
|
||||
expect(texts(script).slice(1)).toEqual([
|
||||
"BEGIN ISOLATION LEVEL READ COMMITTED READ WRITE",
|
||||
"INSERT INTO example_records VALUES (1)",
|
||||
"COMMIT",
|
||||
"DISCARD ALL",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rolls back a rejected callback and rejects with the callback's own error, untouched", async () => {
|
||||
const script = scriptedPool();
|
||||
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||
const own = new Error("module failure");
|
||||
|
||||
const error = await rejection(
|
||||
executor.transaction(async (tx) => {
|
||||
await tx.query({ text: "INSERT INTO example_records VALUES (1)" });
|
||||
throw own;
|
||||
}),
|
||||
);
|
||||
|
||||
expect(error).toBe(own);
|
||||
expect(texts(script)).toContain("ROLLBACK");
|
||||
expect(texts(script)).not.toContain("COMMIT");
|
||||
});
|
||||
|
||||
it("revokes the executor when the callback settles; later use sends nothing", async () => {
|
||||
const script = scriptedPool();
|
||||
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||
let escaped: ModuleDatabaseTransaction | undefined;
|
||||
await executor.transaction((tx) => {
|
||||
escaped = tx;
|
||||
});
|
||||
const sent = script.statements.length;
|
||||
|
||||
await expectAbiError(
|
||||
(escaped as ModuleDatabaseTransaction).query({ text: "SELECT 1" }),
|
||||
"MODULE_DATABASE_SCOPE_CLOSED",
|
||||
);
|
||||
await expectAbiError(
|
||||
(escaped as ModuleDatabaseTransaction).savepoint(async () => undefined),
|
||||
"MODULE_DATABASE_SCOPE_CLOSED",
|
||||
);
|
||||
expect(script.statements).toHaveLength(sent);
|
||||
});
|
||||
|
||||
it("never commits a callback that returned with a query still outstanding, even once it succeeds", async () => {
|
||||
const script = scriptedPool();
|
||||
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||
let forgotten: Promise<unknown> | undefined;
|
||||
let releaseHeld: (() => void) | undefined;
|
||||
|
||||
const outcome = executor.transaction((tx) => {
|
||||
releaseHeld = script.holdNext();
|
||||
forgotten = tx.query({ text: "INSERT INTO example_records VALUES (1)" });
|
||||
});
|
||||
await vi.waitFor(() => expect(releaseHeld).toBeDefined());
|
||||
// Core waits for the started statement to settle before rolling back the connection.
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(texts(script)).not.toContain("ROLLBACK");
|
||||
releaseHeld?.();
|
||||
|
||||
await expectAbiError(outcome, "MODULE_DATABASE_SCOPE_WORK_OUTSTANDING");
|
||||
await expect(forgotten).resolves.toBeDefined();
|
||||
expect(texts(script).slice(-2)).toEqual(["ROLLBACK", "DISCARD ALL"]);
|
||||
expect(texts(script)).not.toContain("COMMIT");
|
||||
});
|
||||
|
||||
it("keeps a rejected callback's own error when sibling work is still settling, and still never commits", async () => {
|
||||
const script = scriptedPool();
|
||||
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||
const own = new Error("module failure");
|
||||
let releaseHeld: (() => void) | undefined;
|
||||
|
||||
const outcome = executor.transaction(async (tx) => {
|
||||
releaseHeld = script.holdNext();
|
||||
void tx.query({ text: "INSERT INTO example_records VALUES (1)" });
|
||||
throw own;
|
||||
});
|
||||
await vi.waitFor(() => expect(releaseHeld).toBeDefined());
|
||||
// The sibling statement is drained before Core rolls back.
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(texts(script)).not.toContain("ROLLBACK");
|
||||
releaseHeld?.();
|
||||
|
||||
expect(await rejection(outcome)).toBe(own);
|
||||
expect(texts(script).slice(-2)).toEqual(["ROLLBACK", "DISCARD ALL"]);
|
||||
expect(texts(script)).not.toContain("COMMIT");
|
||||
});
|
||||
|
||||
it("refuses a transaction-control statement inside a transaction and never commits it", async () => {
|
||||
const script = scriptedPool();
|
||||
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||
|
||||
const error = await rejection(
|
||||
executor.transaction(async (tx) => {
|
||||
await expectAbiError(
|
||||
tx.query({ text: "SAVEPOINT mine" }),
|
||||
"MODULE_DATABASE_REQUEST_INVALID",
|
||||
);
|
||||
return "carried on";
|
||||
}),
|
||||
);
|
||||
|
||||
expect(error).toMatchObject({ code: "MODULE_DATABASE_REQUEST_INVALID" });
|
||||
expect(texts(script)).not.toContain("COMMIT");
|
||||
expect(texts(script)).toContain("ROLLBACK");
|
||||
// The connection is destroyed, never reset and reused.
|
||||
expect(texts(script)).not.toContain("DISCARD ALL");
|
||||
expect(script.released).toEqual([true]);
|
||||
});
|
||||
|
||||
it("reports a COMMIT that PostgreSQL answered with ROLLBACK as a failed transaction", async () => {
|
||||
const script = scriptedPool();
|
||||
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||
|
||||
const outcome = executor.transaction(async (tx) => {
|
||||
script.failNext(pgError("22012"));
|
||||
await expectAbiError(
|
||||
tx.query({ text: "SELECT 1/0" }),
|
||||
"MODULE_DATABASE_QUERY_FAILED",
|
||||
"22012",
|
||||
);
|
||||
});
|
||||
|
||||
await expectAbiError(outcome, "MODULE_DATABASE_TRANSACTION_FAILED");
|
||||
});
|
||||
});
|
||||
|
||||
describe("savepoint scopes", () => {
|
||||
it("names savepoints itself, releases a resolved one, and rolls back a rejected one", async () => {
|
||||
const script = scriptedPool();
|
||||
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||
const own = new Error("savepoint failure");
|
||||
|
||||
await executor.transaction(async (tx) => {
|
||||
await tx.savepoint(async (sp) => {
|
||||
await sp.query({ text: "INSERT INTO example_records VALUES (1)" });
|
||||
});
|
||||
expect(await rejection(tx.savepoint(async () => Promise.reject(own)))).toBe(own);
|
||||
});
|
||||
|
||||
expect(texts(script).slice(2, -2)).toEqual([
|
||||
"SAVEPOINT ekklesia_savepoint_1",
|
||||
"INSERT INTO example_records VALUES (1)",
|
||||
"RELEASE SAVEPOINT ekklesia_savepoint_1",
|
||||
"SAVEPOINT ekklesia_savepoint_2",
|
||||
"ROLLBACK TO SAVEPOINT ekklesia_savepoint_2",
|
||||
"RELEASE SAVEPOINT ekklesia_savepoint_2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("makes the parent inactive while a savepoint is open, so scopes cannot overlap", async () => {
|
||||
const script = scriptedPool();
|
||||
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||
|
||||
await executor.transaction(async (tx) => {
|
||||
await tx.savepoint(async (sp) => {
|
||||
await expectAbiError(tx.query({ text: "SELECT 1" }), "MODULE_DATABASE_SCOPE_INACTIVE");
|
||||
await expectAbiError(
|
||||
tx.savepoint(async () => undefined),
|
||||
"MODULE_DATABASE_SCOPE_INACTIVE",
|
||||
);
|
||||
await sp.savepoint(async (inner) => {
|
||||
await expectAbiError(sp.query({ text: "SELECT 1" }), "MODULE_DATABASE_SCOPE_INACTIVE");
|
||||
await inner.query({ text: "SELECT 2" });
|
||||
});
|
||||
await sp.query({ text: "SELECT 3" });
|
||||
});
|
||||
await tx.query({ text: "SELECT 4" });
|
||||
});
|
||||
|
||||
expect(texts(script)).toContain("SAVEPOINT ekklesia_savepoint_2");
|
||||
expect(texts(script)).toContain("COMMIT");
|
||||
});
|
||||
|
||||
it("revokes an escaped savepoint executor", async () => {
|
||||
const script = scriptedPool();
|
||||
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||
|
||||
await executor.transaction(async (tx) => {
|
||||
let escaped: ModuleDatabaseTransaction | undefined;
|
||||
await tx.savepoint((sp) => {
|
||||
escaped = sp;
|
||||
});
|
||||
await expectAbiError(
|
||||
(escaped as ModuleDatabaseTransaction).query({ text: "SELECT 1" }),
|
||||
"MODULE_DATABASE_SCOPE_CLOSED",
|
||||
);
|
||||
await tx.query({ text: "SELECT 2" });
|
||||
});
|
||||
expect(texts(script)).toContain("COMMIT");
|
||||
});
|
||||
|
||||
it("rolls back, and never releases, a savepoint whose callback left work outstanding", async () => {
|
||||
const script = scriptedPool();
|
||||
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||
|
||||
await executor.transaction(async (tx) => {
|
||||
await expectAbiError(
|
||||
tx.savepoint((sp) => {
|
||||
const release = script.holdNext();
|
||||
void sp.query({ text: "INSERT INTO example_records VALUES (1)" });
|
||||
setTimeout(release, 10);
|
||||
}),
|
||||
"MODULE_DATABASE_SCOPE_WORK_OUTSTANDING",
|
||||
);
|
||||
});
|
||||
|
||||
expect(texts(script).slice(2, -2)).toEqual([
|
||||
"SAVEPOINT ekklesia_savepoint_1",
|
||||
"INSERT INTO example_records VALUES (1)",
|
||||
"ROLLBACK TO SAVEPOINT ekklesia_savepoint_1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a rejected savepoint callback's own error, rolling back and releasing after its sibling work settles", async () => {
|
||||
const script = scriptedPool();
|
||||
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||
const own = new Error("savepoint failure");
|
||||
|
||||
await executor.transaction(async (tx) => {
|
||||
const error = await rejection(
|
||||
tx.savepoint(async (sp) => {
|
||||
const release = script.holdNext();
|
||||
void sp.query({ text: "INSERT INTO example_records VALUES (1)" });
|
||||
setTimeout(release, 10);
|
||||
throw own;
|
||||
}),
|
||||
);
|
||||
expect(error).toBe(own);
|
||||
});
|
||||
|
||||
expect(texts(script).slice(2, -2)).toEqual([
|
||||
"SAVEPOINT ekklesia_savepoint_1",
|
||||
"INSERT INTO example_records VALUES (1)",
|
||||
"ROLLBACK TO SAVEPOINT ekklesia_savepoint_1",
|
||||
"RELEASE SAVEPOINT ekklesia_savepoint_1",
|
||||
]);
|
||||
expect(texts(script)).toContain("COMMIT");
|
||||
});
|
||||
|
||||
it("counts an unawaited savepoint as the transaction's outstanding work", async () => {
|
||||
const script = scriptedPool();
|
||||
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||
let forgotten: Promise<unknown> | undefined;
|
||||
|
||||
await expectAbiError(
|
||||
executor.transaction((tx) => {
|
||||
forgotten = tx.savepoint(async (sp) => {
|
||||
await sp.query({ text: "INSERT INTO example_records VALUES (1)" });
|
||||
});
|
||||
}),
|
||||
"MODULE_DATABASE_SCOPE_WORK_OUTSTANDING",
|
||||
);
|
||||
await expectAbiError(forgotten as Promise<unknown>, "MODULE_DATABASE_SCOPE_CLOSED");
|
||||
expect(texts(script)).not.toContain("COMMIT");
|
||||
expect(texts(script).filter((text) => text.startsWith("RELEASE"))).toEqual([]);
|
||||
expect(texts(script).at(-1)).toBe("DISCARD ALL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the ABI's own sources", () => {
|
||||
it("import no ORM, query builder, or ORM type", async () => {
|
||||
for (const file of [
|
||||
"../src/modules/database-abi.ts",
|
||||
"../src/modules/database-abi-values.ts",
|
||||
]) {
|
||||
const source = await readFile(new URL(file, import.meta.url), "utf8");
|
||||
const specifiers = [...source.matchAll(/\bfrom\s+"([^"]+)"/gu)].map((match) => match[1]);
|
||||
expect(
|
||||
specifiers.every((specifier) =>
|
||||
["@ekklesia/contracts", "pg", "./database-abi-values.js"].includes(specifier ?? ""),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(source).not.toMatch(/drizzle|kysely|NodePg|PgTable|PgColumn/iu);
|
||||
}
|
||||
});
|
||||
});
|
||||
+9
-8
@@ -1,8 +1,8 @@
|
||||
import { integer, pgTable, text } from "drizzle-orm/pg-core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ModuleDatabase, ModuleRuntimeServices } from "../src/index.js";
|
||||
import type { LegacyModuleRuntimeServices, ModuleDatabase } from "../src/modules/database.js";
|
||||
|
||||
const testTable = pgTable("contracts_module_database_test", {
|
||||
const testTable = pgTable("core_module_database_type_test", {
|
||||
id: integer("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
});
|
||||
@@ -68,16 +68,17 @@ function typeOnlyExclusions(database: ModuleDatabase): void {
|
||||
}
|
||||
void typeOnlyExclusions;
|
||||
|
||||
/** Never invoked — proves `ModuleRuntimeServices` has exactly one field, `database`, typed as
|
||||
* `ModuleDatabase`, with no storage/jobs/configuration/secrets/authorization field this phase. */
|
||||
function typeOnlyServicesShape(services: ModuleRuntimeServices): void {
|
||||
/** Never invoked — proves a legacy module's `ModuleRuntimeServices` has exactly one field,
|
||||
* `database`, typed as `ModuleDatabase`, with no storage/jobs/configuration/secrets/authorization
|
||||
* field this phase. */
|
||||
function typeOnlyServicesShape(services: LegacyModuleRuntimeServices): void {
|
||||
const database: ModuleDatabase = services.database;
|
||||
void database;
|
||||
// @ts-expect-error `storage` does not exist on `ModuleRuntimeServices` yet.
|
||||
// @ts-expect-error `storage` does not exist on the services yet.
|
||||
void services.storage;
|
||||
// @ts-expect-error `jobs` does not exist on `ModuleRuntimeServices` yet.
|
||||
// @ts-expect-error `jobs` does not exist on the services yet.
|
||||
void services.jobs;
|
||||
// @ts-expect-error `authorization` does not exist on `ModuleRuntimeServices` yet.
|
||||
// @ts-expect-error `authorization` does not exist on the services yet.
|
||||
void services.authorization;
|
||||
}
|
||||
void typeOnlyServicesShape;
|
||||
@@ -34,7 +34,7 @@ async function createModulesDirectory(): Promise<string> {
|
||||
|
||||
function manifestFor(id: string): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { IMPORT_SAFETY_TEST_TIMEOUT_MS } from "./support/import-safety.js";
|
||||
|
||||
const readdir = vi.fn();
|
||||
const readFile = vi.fn();
|
||||
@@ -14,13 +15,17 @@ vi.mock("node:fs/promises", async (importOriginal) => ({
|
||||
}));
|
||||
|
||||
describe("module HTTP contract and runtime import safety", () => {
|
||||
it("performs no filesystem I/O merely by importing the module runtime or the module-http contract", async () => {
|
||||
await import("../src/modules/index.js");
|
||||
await import("@ekklesia/contracts");
|
||||
it(
|
||||
"performs no filesystem I/O merely by importing the module runtime or the module-http contract",
|
||||
async () => {
|
||||
await import("../src/modules/index.js");
|
||||
await import("@ekklesia/contracts");
|
||||
|
||||
expect(readdir).not.toHaveBeenCalled();
|
||||
expect(readFile).not.toHaveBeenCalled();
|
||||
expect(realpath).not.toHaveBeenCalled();
|
||||
expect(stat).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(readdir).not.toHaveBeenCalled();
|
||||
expect(readFile).not.toHaveBeenCalled();
|
||||
expect(realpath).not.toHaveBeenCalled();
|
||||
expect(stat).not.toHaveBeenCalled();
|
||||
},
|
||||
IMPORT_SAFETY_TEST_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -200,7 +200,7 @@ afterAll(async () => {
|
||||
* by eligibility resolution. */
|
||||
function fakeModule(id: string, dependencies?: Record<string, unknown>): DiscoveredModule {
|
||||
const manifest = parseModuleManifest({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "1.0.0",
|
||||
|
||||
@@ -22,7 +22,7 @@ function fakeModule(
|
||||
} = {},
|
||||
): DiscoveredModule {
|
||||
const manifest = parseModuleManifest({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
@@ -344,7 +344,7 @@ describe("resolveModuleEligibility — over a real discovered inventory", () =>
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
|
||||
@@ -35,9 +35,9 @@ async function createModulesDirectory(): Promise<string> {
|
||||
|
||||
function discoveryOptions(modulesDirectory: string) {
|
||||
return {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
moduleSdkVersion: "0.2.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
platformVersion: "0.2.0",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@ import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
type ModulePackageVerificationErrorCode,
|
||||
currentModulePackageHost,
|
||||
type ModulePackageHost,
|
||||
ModulePackageVerificationError,
|
||||
type ModulePackageVerificationErrorCode,
|
||||
verifyModulePackage,
|
||||
} from "../src/modules/index.js";
|
||||
import {
|
||||
@@ -16,8 +18,8 @@ import {
|
||||
|
||||
const platform = {
|
||||
databaseRuntime: { "drizzle-orm": "0.45.2" },
|
||||
moduleSdkVersion: "0.1.2",
|
||||
platformVersion: "0.1.2",
|
||||
moduleSdkVersion: "0.2.0",
|
||||
platformVersion: "0.2.0",
|
||||
} as const;
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
@@ -72,13 +74,26 @@ function withEntry(entry: FixtureEntry): PackageFixtureOptions {
|
||||
return { extraEntries: [entry] };
|
||||
}
|
||||
|
||||
function abiPackage(overrides: PackageFixtureOptions = {}): PackageFixtureOptions {
|
||||
return {
|
||||
manifest: (manifest) => ({ ...manifest, database: { abi: 1 } }),
|
||||
schemaSource: `export default {
|
||||
moduleId: "example",
|
||||
tables: [{ name: "example_records", foreignKeys: [] }]
|
||||
};\n`,
|
||||
serverSource:
|
||||
'export default { database: { abi: 1 }, id: "example", version: "0.1.0", register() {} };\n',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Module Package v1 verification (ADR 0049)", () => {
|
||||
it("verifies a valid any-target package, its digest, and its module", async () => {
|
||||
const { archivePath, sha256: digest } = await archiveFile();
|
||||
const result = await verifyModulePackage({ archivePath, expectedSha256: digest, platform });
|
||||
expect(result).toEqual({
|
||||
archiveSha256: digest,
|
||||
fileCount: 8,
|
||||
fileCount: 9,
|
||||
moduleId: "example",
|
||||
target: "any",
|
||||
version: "0.1.0",
|
||||
@@ -91,6 +106,7 @@ describe("Module Package v1 verification (ADR 0049)", () => {
|
||||
await verifyModulePackage({ archivePath, extractTo: target, platform });
|
||||
const names = (await readdir(target, { recursive: true })).sort();
|
||||
expect(names).toContain("ekklesia.package.json");
|
||||
expect(names).toContain("LICENSE");
|
||||
expect(names).toContain("dist/server/index.js");
|
||||
expect((await stat(target)).mode & 0o7777).toBe(0o755);
|
||||
expect((await stat(path.join(target, "dist", "server", "index.js"))).mode & 0o7777).toBe(0o644);
|
||||
@@ -140,6 +156,88 @@ describe("Module Package v1 verification (ADR 0049)", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a missing license declaration", undefined],
|
||||
["an empty license declaration", ""],
|
||||
["UNLICENSED", "UNLICENSED"],
|
||||
["a malformed SPDX expression", "MIT OR"],
|
||||
])("fails package metadata with %s", async (_label, license) => {
|
||||
await rejection(
|
||||
{
|
||||
metadata: (metadata) => {
|
||||
const changed: Record<string, unknown> = { ...metadata, license };
|
||||
if (license === undefined) {
|
||||
delete changed.license;
|
||||
}
|
||||
return changed;
|
||||
},
|
||||
},
|
||||
"MODULE_PACKAGE_METADATA_INVALID",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed on pre-ADR-0051 metadata", async () => {
|
||||
await rejection(
|
||||
{
|
||||
metadata: (metadata) => {
|
||||
const old: Record<string, unknown> = { ...metadata };
|
||||
delete old.license;
|
||||
return old;
|
||||
},
|
||||
},
|
||||
"MODULE_PACKAGE_METADATA_INVALID",
|
||||
);
|
||||
});
|
||||
|
||||
it("requires LICENSE at the canonical path in both payload and inventory", async () => {
|
||||
await rejection(
|
||||
{ tamper: (files) => void files.delete("LICENSE") },
|
||||
"MODULE_PACKAGE_FILE_MISSING",
|
||||
);
|
||||
await rejection(
|
||||
{
|
||||
metadata: (metadata) => ({
|
||||
...metadata,
|
||||
files: (metadata.files as { path: string }[]).filter((file) => file.path !== "LICENSE"),
|
||||
}),
|
||||
},
|
||||
"MODULE_PACKAGE_METADATA_INVALID",
|
||||
);
|
||||
await rejection(
|
||||
withEntry({ path: "example/licenses/LICENSE", data: "attacker-chosen" }),
|
||||
"MODULE_PACKAGE_FILE_UNLISTED",
|
||||
);
|
||||
await rejection(
|
||||
withEntry({ path: "example/COPYING", data: "attacker-chosen" }),
|
||||
"MODULE_PACKAGE_FILE_UNLISTED",
|
||||
);
|
||||
});
|
||||
|
||||
it("checks the listed LICENSE bytes, size, and digest", async () => {
|
||||
await rejection(
|
||||
{ tamper: (files) => files.set("LICENSE", Buffer.from("attacker-chosen")) },
|
||||
"MODULE_PACKAGE_FILE_MISMATCH",
|
||||
);
|
||||
await rejection(
|
||||
{
|
||||
metadata: (metadata) => ({
|
||||
...metadata,
|
||||
files: (metadata.files as { path: string; size: number; sha256: string }[]).map((file) =>
|
||||
file.path === "LICENSE" ? { ...file, size: file.size + 1 } : file,
|
||||
),
|
||||
}),
|
||||
},
|
||||
"MODULE_PACKAGE_FILE_MISMATCH",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a duplicate LICENSE archive entry", async () => {
|
||||
await rejection(
|
||||
withEntry({ path: "example/LICENSE", data: "attacker-chosen" }),
|
||||
"MODULE_PACKAGE_LAYOUT_INVALID",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails when the manifest and the package disagree about the version", async () => {
|
||||
await rejection(
|
||||
{ manifest: (manifest) => ({ ...manifest, version: "0.2.0" }) },
|
||||
@@ -214,13 +312,51 @@ describe("Module Package v1 verification (ADR 0049)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("verifies ABI-v1 plain schema metadata without comparing Drizzle with Core", async () => {
|
||||
const { archivePath } = await archiveFile(abiPackage());
|
||||
|
||||
await expect(
|
||||
verifyModulePackage({
|
||||
archivePath,
|
||||
platform: { ...platform, databaseRuntime: { "drizzle-orm": "99.0.0" } },
|
||||
}),
|
||||
).resolves.toMatchObject({ moduleId: "example" });
|
||||
});
|
||||
|
||||
it("rejects transitional database runtime metadata from an ABI-v1 package", async () => {
|
||||
await rejection(
|
||||
abiPackage({
|
||||
metadata: (metadata) => ({
|
||||
...metadata,
|
||||
transitional: { databaseRuntime: { "drizzle-orm": "0.45.2" } },
|
||||
}),
|
||||
}),
|
||||
"MODULE_PACKAGE_DATABASE_RUNTIME_UNEXPECTED",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an unsupported database ABI and malformed ABI-v1 schema metadata", async () => {
|
||||
await rejection(
|
||||
abiPackage({ manifest: (manifest) => ({ ...manifest, database: { abi: 2 } }) }),
|
||||
"MODULE_PACKAGE_MANIFEST_INVALID",
|
||||
);
|
||||
const schemaError = await rejection(
|
||||
abiPackage({
|
||||
schemaSource:
|
||||
'export default { moduleId: "example", tables: { records: "attacker-chosen" } };\n',
|
||||
}),
|
||||
"MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED",
|
||||
);
|
||||
expect(schemaError.message).not.toContain("attacker-chosen");
|
||||
});
|
||||
|
||||
it("fails a target this platform cannot run", async () => {
|
||||
await rejection(
|
||||
{
|
||||
metadata: (metadata) => ({
|
||||
...metadata,
|
||||
target: "linux-arm64-musl",
|
||||
runtime: { node: ">=20", nodeApi: 9 },
|
||||
runtime: { libcVersion: ">=1.2.0", node: ">=20", nodeApi: 9 },
|
||||
}),
|
||||
},
|
||||
"MODULE_PACKAGE_TARGET_INCOMPATIBLE",
|
||||
@@ -359,6 +495,179 @@ describe("Module Package v1 verification (ADR 0049)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("native targets (ADR 0049)", () => {
|
||||
/** The first bytes of a 64-bit little-endian ELF object for `machine` (62 is x86-64). The
|
||||
* verifier reads only the identification and machine fields. */
|
||||
function elfHeader(machine = 62): Buffer {
|
||||
const header = Buffer.alloc(64);
|
||||
Buffer.from([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1]).copy(header);
|
||||
header.writeUInt16LE(3, 16);
|
||||
header.writeUInt16LE(machine, 18);
|
||||
return header;
|
||||
}
|
||||
|
||||
const linuxHost = {
|
||||
arch: "x64",
|
||||
libc: { family: "glibc", version: "2.41" },
|
||||
nodeApi: 10,
|
||||
nodeAbi: "137",
|
||||
platform: "linux",
|
||||
} as const;
|
||||
|
||||
function nativePackage(
|
||||
overrides: {
|
||||
readonly files?: Record<string, Uint8Array | string>;
|
||||
readonly target?: string;
|
||||
readonly runtime?: Record<string, unknown>;
|
||||
} = {},
|
||||
): PackageFixtureOptions {
|
||||
return {
|
||||
files: overrides.files ?? {
|
||||
"node_modules/native-thing/index.js": "module.exports = {};\n",
|
||||
"node_modules/native-thing/lib/native-thing.node": elfHeader(),
|
||||
"node_modules/native-thing/package.json": '{"name":"native-thing","version":"1.0.0"}\n',
|
||||
},
|
||||
metadata: (metadata) => ({
|
||||
...metadata,
|
||||
runtime: overrides.runtime ?? {
|
||||
libcVersion: ">=2.28.0",
|
||||
node: ">=20",
|
||||
nodeApi: 9,
|
||||
},
|
||||
target: overrides.target ?? "linux-x64-glibc",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function verifyOn(
|
||||
options: PackageFixtureOptions,
|
||||
host: ModulePackageHost,
|
||||
): Promise<unknown> {
|
||||
const { archivePath } = await archiveFile(options);
|
||||
return verifyModulePackage({ archivePath, platform: { ...platform, host } });
|
||||
}
|
||||
|
||||
async function refusedOn(
|
||||
options: PackageFixtureOptions,
|
||||
host: ModulePackageHost,
|
||||
code: ModulePackageVerificationErrorCode,
|
||||
): Promise<void> {
|
||||
await expect(verifyOn(options, host)).rejects.toMatchObject({ code });
|
||||
}
|
||||
|
||||
it("accepts linux-x64-glibc on a matching host, native node_modules and addon included", async () => {
|
||||
await expect(verifyOn(nativePackage(), linuxHost)).resolves.toMatchObject({
|
||||
moduleId: "example",
|
||||
target: "linux-x64-glibc",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses another operating system, CPU, or C library family", async () => {
|
||||
await refusedOn(
|
||||
nativePackage(),
|
||||
{ ...linuxHost, platform: "darwin" },
|
||||
"MODULE_PACKAGE_TARGET_INCOMPATIBLE",
|
||||
);
|
||||
await refusedOn(
|
||||
nativePackage(),
|
||||
{ ...linuxHost, arch: "arm64" },
|
||||
"MODULE_PACKAGE_TARGET_INCOMPATIBLE",
|
||||
);
|
||||
// musl reports no glibc version, so the host cannot be proven glibc.
|
||||
const { libc: _libc, ...musl } = linuxHost;
|
||||
await refusedOn(nativePackage(), musl, "MODULE_PACKAGE_TARGET_INCOMPATIBLE");
|
||||
});
|
||||
|
||||
it("refuses a target this platform does not implement", async () => {
|
||||
await refusedOn(
|
||||
nativePackage({ target: "linux-arm64-glibc" }),
|
||||
{ ...linuxHost, arch: "arm64" },
|
||||
"MODULE_PACKAGE_TARGET_INCOMPATIBLE",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a C library older than the package needs", async () => {
|
||||
await refusedOn(
|
||||
nativePackage(),
|
||||
{ ...linuxHost, libc: { family: "glibc", version: "2.17" } },
|
||||
"MODULE_PACKAGE_LIBC_INCOMPATIBLE",
|
||||
);
|
||||
await expect(
|
||||
verifyOn(nativePackage(), { ...linuxHost, libc: { family: "glibc", version: "2.28" } }),
|
||||
).resolves.toMatchObject({ target: "linux-x64-glibc" });
|
||||
});
|
||||
|
||||
it("refuses a Node-API version or Node.js ABI the host does not provide", async () => {
|
||||
await refusedOn(
|
||||
nativePackage(),
|
||||
{ ...linuxHost, nodeApi: 8 },
|
||||
"MODULE_PACKAGE_NODE_API_INCOMPATIBLE",
|
||||
);
|
||||
const { nodeApi: _nodeApi, ...unknownApi } = linuxHost;
|
||||
await refusedOn(nativePackage(), unknownApi, "MODULE_PACKAGE_NODE_API_INCOMPATIBLE");
|
||||
await refusedOn(
|
||||
nativePackage({ runtime: { libcVersion: ">=2.28.0", node: ">=20", nodeAbi: "115" } }),
|
||||
linuxHost,
|
||||
"MODULE_PACKAGE_NODE_API_INCOMPATIBLE",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a native file built for another machine, or an addon that is not one", async () => {
|
||||
await refusedOn(
|
||||
nativePackage({
|
||||
files: { "node_modules/native-thing/lib/native-thing.node": elfHeader(183) },
|
||||
}),
|
||||
linuxHost,
|
||||
"MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE",
|
||||
);
|
||||
await refusedOn(
|
||||
nativePackage({
|
||||
files: { "node_modules/native-thing/lib/libthing.so": elfHeader(183) },
|
||||
}),
|
||||
linuxHost,
|
||||
"MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE",
|
||||
);
|
||||
await refusedOn(
|
||||
nativePackage({
|
||||
files: { "node_modules/native-thing/lib/native-thing.node": "not an ELF object" },
|
||||
}),
|
||||
linuxHost,
|
||||
"MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE",
|
||||
);
|
||||
});
|
||||
|
||||
it("requires a native package to state its Node.js interface and, on Linux, its C library", async () => {
|
||||
await rejection(
|
||||
nativePackage({ runtime: { libcVersion: ">=2.28.0", node: ">=20" } }),
|
||||
"MODULE_PACKAGE_METADATA_INVALID",
|
||||
);
|
||||
await rejection(
|
||||
nativePackage({ runtime: { node: ">=20", nodeApi: 9 } }),
|
||||
"MODULE_PACKAGE_METADATA_INVALID",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps any-target packages free of node_modules, addons, and ELF objects", async () => {
|
||||
await rejection(
|
||||
{ files: { "node_modules/thing/index.js": "module.exports = {};\n" } },
|
||||
"MODULE_PACKAGE_NATIVE_FILE",
|
||||
);
|
||||
await rejection({ files: { "dist/thing.node": elfHeader() } }, "MODULE_PACKAGE_NATIVE_FILE");
|
||||
await rejection({ files: { "dist/libthing.so": elfHeader() } }, "MODULE_PACKAGE_NATIVE_FILE");
|
||||
});
|
||||
|
||||
it("establishes this host's facts from Node.js alone", () => {
|
||||
const host = currentModulePackageHost();
|
||||
expect(host.platform).toBe(process.platform);
|
||||
expect(host.arch).toBe(process.arch);
|
||||
expect(host.nodeApi).toBe(Number(process.versions.napi));
|
||||
if (process.platform === "linux") {
|
||||
const report = process.report.getReport() as { header?: { glibcVersionRuntime?: string } };
|
||||
expect(host.libc?.version).toBe(report.header?.glibcVersionRuntime);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("fails an archive without package metadata", async () => {
|
||||
const fixture = await modulePackageFixture();
|
||||
const { gunzipSync, gzipSync } = await import("node:zlib");
|
||||
|
||||
@@ -28,7 +28,7 @@ interface PermissionInput {
|
||||
* is read by `composePlatformPermissionRegistry`; the rest exist only to satisfy the type. */
|
||||
function fakeModule(id: string, permissions: readonly PermissionInput[] = []): DiscoveredModule {
|
||||
const manifest = parseModuleManifest({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type {
|
||||
ModuleDatabase,
|
||||
ModuleRealtimePublisher,
|
||||
ModuleRuntimeServices,
|
||||
} from "@ekklesia/contracts";
|
||||
import type { ModuleRealtimePublisher } from "@ekklesia/contracts";
|
||||
import type { LegacyModuleRuntimeServices, ModuleDatabase } from "../src/modules/database.js";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { ModuleRuntimeError, startModuleRuntime } from "../src/modules/runtime.js";
|
||||
|
||||
@@ -22,7 +19,7 @@ afterEach(async () => {
|
||||
);
|
||||
});
|
||||
|
||||
function fakeServices(): ModuleRuntimeServices {
|
||||
function fakeServices(): LegacyModuleRuntimeServices {
|
||||
return Object.freeze({ database: Object.freeze({}) as ModuleDatabase });
|
||||
}
|
||||
|
||||
@@ -31,7 +28,7 @@ function manifest(
|
||||
permissions: readonly Record<string, string>[] = [],
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { ModuleConfiguration, ModuleDatabaseExecutor } from "@ekklesia/contracts";
|
||||
import type {
|
||||
ModuleConfiguration,
|
||||
AnyModuleRuntimeServices,
|
||||
LegacyModuleRuntimeServices,
|
||||
ModuleDatabase,
|
||||
ModuleRuntimeServices,
|
||||
} from "@ekklesia/contracts";
|
||||
} from "../src/modules/database.js";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { discoverModules } from "../src/modules/discovery.js";
|
||||
import { resolveModuleEligibility } from "../src/modules/lifecycle.js";
|
||||
import {
|
||||
ModuleDiscoveryError,
|
||||
ModuleRuntimeError,
|
||||
type ModuleRuntimeIssueCode,
|
||||
type ModuleRuntimeServicesLookup,
|
||||
startDiscoveredModuleRuntime,
|
||||
startModuleRuntime,
|
||||
} from "../src/modules/index.js";
|
||||
import { resolveModuleEligibility } from "../src/modules/lifecycle.js";
|
||||
|
||||
vi.mock("../src/modules/discovery.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../src/modules/discovery.js")>();
|
||||
@@ -52,12 +54,13 @@ function manifestFor(
|
||||
id: string,
|
||||
options: {
|
||||
capabilities?: Array<{ id: string; version: string }>;
|
||||
database?: { abi: 1 };
|
||||
dependencies?: Record<string, unknown>;
|
||||
permissions?: Array<{ id: string; baseline: string; description: string }>;
|
||||
} = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
@@ -67,6 +70,7 @@ function manifestFor(
|
||||
},
|
||||
capabilities: options.capabilities ?? [],
|
||||
permissions: options.permissions ?? [],
|
||||
...(options.database ? { database: options.database } : {}),
|
||||
...(options.dependencies ? { dependencies: options.dependencies } : {}),
|
||||
entrypoints: {
|
||||
server: "./dist/server/index.js",
|
||||
@@ -145,13 +149,29 @@ function fakeModuleDatabase(): ModuleDatabase {
|
||||
}) as unknown as ModuleDatabase;
|
||||
}
|
||||
|
||||
function fakeModuleRuntimeServices(): ModuleRuntimeServices {
|
||||
return Object.freeze({ database: fakeModuleDatabase() });
|
||||
function fakeModuleRuntimeServices(): ModuleRuntimeServicesLookup {
|
||||
const services = new Map<string, LegacyModuleRuntimeServices>();
|
||||
const database = fakeModuleDatabase();
|
||||
return new Proxy(services, {
|
||||
get(target, property, receiver) {
|
||||
if (property === "get") {
|
||||
return (moduleId: string) => {
|
||||
let value = target.get(moduleId);
|
||||
if (!value) {
|
||||
value = Object.freeze({ database });
|
||||
target.set(moduleId, value);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
}
|
||||
return Reflect.get(target, property, receiver);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function runtimeOptions(
|
||||
modulesDirectory: string,
|
||||
services: ModuleRuntimeServices = fakeModuleRuntimeServices(),
|
||||
services: ModuleRuntimeServicesLookup = fakeModuleRuntimeServices(),
|
||||
) {
|
||||
return {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
@@ -1067,6 +1087,54 @@ describe("already-discovered module runtime", () => {
|
||||
});
|
||||
|
||||
describe("module runtime services", () => {
|
||||
it("delivers only the manifest-selected database mode and distinct ABI executors", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const recorder = createReferenceRecorder();
|
||||
const legacyDatabase = fakeModuleDatabase();
|
||||
const alphaExecutor = Object.freeze({
|
||||
abiVersion: 1,
|
||||
query: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
}) as unknown as ModuleDatabaseExecutor;
|
||||
const betaExecutor = Object.freeze({
|
||||
abiVersion: 1,
|
||||
query: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
}) as unknown as ModuleDatabaseExecutor;
|
||||
const services: ModuleRuntimeServicesLookup = new Map<string, AnyModuleRuntimeServices>([
|
||||
["legacy", Object.freeze({ database: legacyDatabase })],
|
||||
["alpha", Object.freeze({ database: alphaExecutor })],
|
||||
["beta", Object.freeze({ database: betaExecutor })],
|
||||
]);
|
||||
await createRuntimeModule(modulesDirectory, "legacy", {
|
||||
serverSource: `export default { id: "legacy", version: "0.1.0", start({ services }) { ${recorder.expression}.push(services.database); } };`,
|
||||
});
|
||||
for (const id of ["alpha", "beta"]) {
|
||||
await createRuntimeModule(modulesDirectory, id, {
|
||||
manifest: manifestFor(id, { database: { abi: 1 } }),
|
||||
serverSource: `export default { database: { abi: 1 }, id: ${JSON.stringify(id)}, version: "0.1.0", start({ services }) { ${recorder.expression}.push(services.database); } };`,
|
||||
});
|
||||
}
|
||||
|
||||
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory, services));
|
||||
|
||||
expect(recorder.values).toEqual([alphaExecutor, betaExecutor, legacyDatabase]);
|
||||
expect("select" in alphaExecutor).toBe(false);
|
||||
expect(alphaExecutor).not.toBe(betaExecutor);
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
it("rejects a runtime database declaration that disagrees with its manifest", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createRuntimeModule(modulesDirectory, "mismatch", {
|
||||
manifest: manifestFor("mismatch", { database: { abi: 1 } }),
|
||||
serverSource: `export default { id: "mismatch", version: "0.1.0" };`,
|
||||
});
|
||||
|
||||
const error = await captureRuntimeError(modulesDirectory);
|
||||
expect(runtimeIssueCodes(error)).toContain("MODULE_RUNTIME_DATABASE_MISMATCH");
|
||||
});
|
||||
|
||||
it("never gives register a services field, and gives start and stop the exact same services object", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const services = fakeModuleRuntimeServices();
|
||||
@@ -1086,11 +1154,11 @@ describe("module runtime services", () => {
|
||||
|
||||
expect(registerServices.values).toEqual([undefined]);
|
||||
expect(lifecycleServices.values).toHaveLength(2);
|
||||
expect(lifecycleServices.values[0]).toBe(services);
|
||||
expect(lifecycleServices.values[1]).toBe(services);
|
||||
expect(lifecycleServices.values[0]).toBe(services.get("wired"));
|
||||
expect(lifecycleServices.values[1]).toBe(services.get("wired"));
|
||||
});
|
||||
|
||||
it("shares the exact same services object identity across every module's start hook", async () => {
|
||||
it("selects a stable services object per module rather than sharing one across modules", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const services = fakeModuleRuntimeServices();
|
||||
const recorder = createReferenceRecorder();
|
||||
@@ -1112,7 +1180,8 @@ describe("module runtime services", () => {
|
||||
|
||||
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory, services));
|
||||
|
||||
expect(recorder.values).toEqual([services, services]);
|
||||
expect(recorder.values).toEqual([services.get("alpha"), services.get("beta")]);
|
||||
expect(recorder.values[0]).not.toBe(recorder.values[1]);
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
@@ -1141,23 +1210,26 @@ describe("module runtime services", () => {
|
||||
startModuleRuntime(runtimeOptions(modulesDirectory, services)),
|
||||
).rejects.toBeInstanceOf(ModuleRuntimeError);
|
||||
|
||||
expect(recorder.values).toEqual([services, services]);
|
||||
expect(recorder.values).toEqual([services.get("started"), services.get("started")]);
|
||||
});
|
||||
|
||||
it("starts and stops a zero-module runtime without ever touching the provided services", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const services = fakeModuleRuntimeServices();
|
||||
const database = fakeModuleDatabase();
|
||||
const services: ModuleRuntimeServicesLookup = new Map([
|
||||
["unused", Object.freeze({ database })],
|
||||
]);
|
||||
|
||||
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory, services));
|
||||
await runtime.stop();
|
||||
|
||||
expect(runtime.modules).toEqual([]);
|
||||
expect(vi.mocked(services.database.select)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.insert)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.update)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.delete)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.execute)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.transaction)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(database.select)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(database.insert)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(database.update)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(database.delete)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(database.execute)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(database.transaction)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1463,7 +1535,7 @@ describe("module configuration delivery (ADR 0029)", () => {
|
||||
expect(recorder.values).toEqual([["object", []]]);
|
||||
});
|
||||
|
||||
it("keeps the services object one shared identity even though configuration is per-module", async () => {
|
||||
it("keeps each module's services object stable while configuration remains per-module", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const services = fakeModuleRuntimeServices();
|
||||
const recorder = createReferenceRecorder();
|
||||
@@ -1492,11 +1564,11 @@ describe("module configuration delivery (ADR 0029)", () => {
|
||||
await runtime.stop();
|
||||
|
||||
const [alphaServices, alphaConfig, bravoServices, bravoConfig] = recorder.values;
|
||||
// This is the reason configuration is a sibling of `services` rather than a field inside it:
|
||||
// `services` is deliberately one shared object for the whole runtime, while configuration must
|
||||
// differ per module. Both properties hold simultaneously.
|
||||
expect(alphaServices).toBe(services);
|
||||
expect(bravoServices).toBe(services);
|
||||
// Configuration remains a sibling of `services`: both are selected per module, but they are
|
||||
// independently composed snapshots with separate contracts and lifetimes.
|
||||
expect(alphaServices).toBe(services.get("alpha"));
|
||||
expect(bravoServices).toBe(services.get("bravo"));
|
||||
expect(alphaServices).not.toBe(bravoServices);
|
||||
expect(alphaConfig).not.toBe(bravoConfig);
|
||||
expect((alphaConfig as ModuleConfiguration).getString("label")).toBe("alpha");
|
||||
expect((bravoConfig as ModuleConfiguration).getString("label")).toBe("bravo");
|
||||
|
||||
@@ -38,10 +38,14 @@ async function createModulesDirectory(): Promise<string> {
|
||||
|
||||
function manifestFor(
|
||||
id: string,
|
||||
options: { dependencies?: Record<string, unknown>; schema?: string | false } = {},
|
||||
options: {
|
||||
database?: { abi: 1 };
|
||||
dependencies?: Record<string, unknown>;
|
||||
schema?: string | false;
|
||||
} = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
@@ -49,6 +53,7 @@ function manifestFor(
|
||||
...(options.dependencies ? { dependencies: options.dependencies } : {}),
|
||||
capabilities: [],
|
||||
permissions: [],
|
||||
...(options.database ? { database: options.database } : {}),
|
||||
entrypoints: {
|
||||
server: "./dist/server/index.js",
|
||||
...(options.schema === false ? {} : { schema: options.schema ?? "./dist/schema/index.js" }),
|
||||
@@ -126,6 +131,62 @@ function issueCodes(error: ModuleSchemaDiscoveryError): ModuleSchemaDiscoveryIss
|
||||
}
|
||||
|
||||
describe("module schema discovery", () => {
|
||||
it("loads ABI-v1 schema entrypoints as plain metadata without importing an ORM", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createSchemaModule(modulesDirectory, "calendar", {
|
||||
manifest: manifestFor("calendar", { database: { abi: 1 } }),
|
||||
schemaSource: `
|
||||
export default {
|
||||
moduleId: "calendar",
|
||||
tables: [
|
||||
{ name: "calendar_events", foreignKeys: [] },
|
||||
{ name: "calendar_attendees", foreignKeys: [{ table: "calendar_events" }] }
|
||||
]
|
||||
};
|
||||
`,
|
||||
});
|
||||
|
||||
const aggregate = await discoverModuleSchemas(discoveryOptions(modulesDirectory));
|
||||
|
||||
expect(aggregate.tables.map((table) => table.name)).toEqual([
|
||||
"calendar_attendees",
|
||||
"calendar_events",
|
||||
]);
|
||||
expect(aggregate.tables.every((table) => table.table === undefined)).toBe(true);
|
||||
expect(aggregate.drizzleSchema).toEqual({});
|
||||
});
|
||||
|
||||
it("aggregates ABI-v1 prefix, duplicate, cross-module FK, missing target, and malformed errors", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createSchemaModule(modulesDirectory, "alpha", {
|
||||
manifest: manifestFor("alpha", { database: { abi: 1 } }),
|
||||
schemaSource: `export default {
|
||||
moduleId: "alpha",
|
||||
tables: [
|
||||
{ name: "wrong_records", foreignKeys: [] },
|
||||
{ name: "alpha_records", foreignKeys: [{ table: "beta_records" }] },
|
||||
{ name: "alpha_records", foreignKeys: [{ table: "alpha_missing" }] }
|
||||
]
|
||||
};`,
|
||||
});
|
||||
await createSchemaModule(modulesDirectory, "malformed", {
|
||||
manifest: manifestFor("malformed", { database: { abi: 1 } }),
|
||||
schemaSource: `export default { moduleId: "malformed", tables: [{ name: "malformed_rows", unknown: true }] };`,
|
||||
});
|
||||
|
||||
const error = await captureSchemaError(modulesDirectory);
|
||||
|
||||
expect(issueCodes(error)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"MODULE_SCHEMA_CROSS_MODULE_FOREIGN_KEY",
|
||||
"MODULE_SCHEMA_EXPORT_INVALID",
|
||||
"MODULE_SCHEMA_FOREIGN_KEY_TARGET_MISSING",
|
||||
"MODULE_SCHEMA_TABLE_DUPLICATE",
|
||||
"MODULE_SCHEMA_TABLE_NAMESPACE_INVALID",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns a frozen empty aggregate without importing module servers", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const recorder = createGlobalEventRecorder();
|
||||
|
||||
@@ -18,7 +18,7 @@ function fakeModule(
|
||||
} = {},
|
||||
): DiscoveredModule {
|
||||
const manifest = parseModuleManifest({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "1.0.0",
|
||||
|
||||
@@ -79,7 +79,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
version: contribution.version,
|
||||
})),
|
||||
id: fixture.id,
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
name: `${fixture.id} module`,
|
||||
permissions:
|
||||
fixture.display === undefined
|
||||
|
||||
@@ -1905,7 +1905,7 @@ describe.sequential("live PostgreSQL migration timeout precedence", () => {
|
||||
describe.sequential("live PostgreSQL retained history for an uninstalled module", () => {
|
||||
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
const templateModule = path.join(repositoryRoot, "templates", "module");
|
||||
const versions = { moduleSdkVersion: "0.1.0", platformVersion: "0.1.0" } as const;
|
||||
const versions = { moduleSdkVersion: "0.2.0", platformVersion: "0.2.0" } as const;
|
||||
|
||||
async function coreWithoutNewestMigration(directory: string): Promise<string> {
|
||||
const target = path.join(directory, "core-drizzle");
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* The per-test timeout for Core's import-safety tests, and only those.
|
||||
*
|
||||
* Each of these tests proves that importing a module does nothing (no I/O, no connection, no output)
|
||||
* by performing a cold dynamic `import()` of a large Core source graph inside the test body. Vitest
|
||||
* transforms and evaluates that whole graph on demand, and that work is CPU-bound. An ordinary test
|
||||
* file's own top-level imports run before its tests start and fall under no per-test timeout; these
|
||||
* deliberately do not, so the cost lands inside Vitest's default 5-second test timeout.
|
||||
*
|
||||
* Alone they take about 0.7–1.2 s. During a full `pnpm verify`, Turbo runs every package's suite at
|
||||
* once and the CPU is saturated. Reproduced locally (six cores, Core's suite alongside five other
|
||||
* packages' suites), the same tests took 3.0–5.4 s, and so timed out intermittently.
|
||||
*
|
||||
* 30 seconds is about five times the slowest time measured under load, which leaves room for slower
|
||||
* shared CI runners. It matches the repository's integration-test timeout. It is still bounded, so a
|
||||
* module that genuinely hangs while being imported, for example on a network call, still fails. The
|
||||
* assertions themselves are unchanged: these tests fail on what an import does, never on speed.
|
||||
*/
|
||||
export const IMPORT_SAFETY_TEST_TIMEOUT_MS = 30_000;
|
||||
@@ -50,11 +50,11 @@ export function manifestFor(
|
||||
} = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
||||
compatibility: { platform: "^0.2.0", moduleSdk: "^0.2.0" },
|
||||
...(options.dependencies ? { dependencies: options.dependencies } : {}),
|
||||
capabilities: [],
|
||||
permissions: [],
|
||||
|
||||
@@ -86,6 +86,7 @@ export interface PackageFixtureOptions {
|
||||
readonly id?: string;
|
||||
readonly version?: string;
|
||||
readonly serverSource?: string;
|
||||
readonly schemaSource?: string;
|
||||
readonly manifest?: (manifest: Record<string, unknown>) => Record<string, unknown>;
|
||||
readonly runtimePackage?: Record<string, unknown>;
|
||||
readonly metadata?: (metadata: Record<string, unknown>) => Record<string, unknown>;
|
||||
@@ -93,6 +94,8 @@ export interface PackageFixtureOptions {
|
||||
readonly tamper?: (files: Map<string, Buffer>) => void;
|
||||
/** Entries appended after the package's own, under any path. */
|
||||
readonly extraEntries?: readonly FixtureEntry[];
|
||||
/** Extra payload files, listed in the metadata like every other file. */
|
||||
readonly files?: Readonly<Record<string, Uint8Array | string>>;
|
||||
}
|
||||
|
||||
export interface PackageFixture {
|
||||
@@ -125,6 +128,7 @@ export async function modulePackageFixture(
|
||||
let files: Map<string, Buffer>;
|
||||
try {
|
||||
const created = await createMigrationModule(scratch, id, {
|
||||
...(options.schemaSource === undefined ? {} : { schemaSource: options.schemaSource }),
|
||||
serverSource:
|
||||
options.serverSource ??
|
||||
`export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)}, register() {} };\n`,
|
||||
@@ -160,6 +164,10 @@ export async function modulePackageFixture(
|
||||
),
|
||||
);
|
||||
files.set("THIRD_PARTY_NOTICES", Buffer.from("No third-party software is included.\n"));
|
||||
files.set("LICENSE", Buffer.from("Example module license text.\n"));
|
||||
for (const [filePath, data] of Object.entries(options.files ?? {})) {
|
||||
files.set(filePath, Buffer.from(data));
|
||||
}
|
||||
|
||||
const listed = [...files.entries()]
|
||||
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
||||
@@ -167,13 +175,14 @@ export async function modulePackageFixture(
|
||||
const baseMetadata: Record<string, unknown> = {
|
||||
packageFormat: 1,
|
||||
module: { id, version },
|
||||
license: "MIT",
|
||||
target: "any",
|
||||
runtime: { node: ">=20" },
|
||||
provenance: {
|
||||
revision: "86ae907f59458508e3277666cdd214f5bd7c1078",
|
||||
packager: { name: "@ekklesia/module-pack", version: "0.1.2" },
|
||||
packager: { name: "@ekklesia/module-pack", version: "0.2.0" },
|
||||
},
|
||||
incorporates: { "@ekklesia/module-sdk": "0.1.2" },
|
||||
incorporates: { "@ekklesia/module-sdk": "0.2.0" },
|
||||
files: listed,
|
||||
};
|
||||
const metadata = options.metadata ? options.metadata(baseMetadata) : baseMetadata;
|
||||
|
||||
+2
-1
@@ -2,7 +2,8 @@
|
||||
"extends": "../packages/typescript-config/node.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"types": ["node", "vitest/globals"]
|
||||
"types": ["node", "vitest/globals"],
|
||||
"rewriteRelativeImportExtensions": true
|
||||
},
|
||||
"include": [
|
||||
"drizzle.config.ts",
|
||||
|
||||
+2
-2
@@ -500,10 +500,10 @@ finds its files:
|
||||
```sh
|
||||
docker build \
|
||||
--file deploy/Dockerfile \
|
||||
--build-arg VERSION=0.1.2 \
|
||||
--build-arg VERSION=0.2.1 \
|
||||
--build-arg REVISION="$(git rev-parse HEAD)" \
|
||||
--build-arg SOURCE=https://git.example.org/your-org/ekklesia \
|
||||
--tag ekklesia:0.1.2 \
|
||||
--tag ekklesia:0.2.1 \
|
||||
.
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,556 @@
|
||||
# 0050: Module Database ABI v1: modules and Core exchange SQL, never ORM objects
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-10-07
|
||||
- Supersedes (partial): [0016](0016-module-database-access.md) — for modules that declare ABI v1,
|
||||
only its definition of the module database service as `Pick<NodePgDatabase, …>`, a facade over
|
||||
Core's Drizzle instance, and its rule that every module receives the same `services` object.
|
||||
One pool, no pool or connection string for modules, no database access during `register`, the
|
||||
shutdown order, handler error sanitization, and the trust boundary remain accepted and unchanged.
|
||||
Modules that do not declare ABI v1 keep ADR 0016's facade until it is removed (see
|
||||
[Compatibility and deprecation](#compatibility-and-deprecation)).
|
||||
- Amends: [0007](0007-module-owned-drizzle-schemas.md) — for modules that declare ABI v1, the
|
||||
schema definition Core inspects is plain Ekklesia-owned metadata instead of Drizzle table objects.
|
||||
Table namespacing, identifier rules, uniqueness, same-module foreign keys, atomic aggregation, and
|
||||
sanitized discovery errors remain accepted and unchanged.
|
||||
- Extends: [0049](0049-module-package-v1.md) — this is the Module Database ABI that ADR 0049 deferred
|
||||
and anticipated. ABI v1 packages carry no `transitional.databaseRuntime`. `packageFormat` stays 1.
|
||||
- Related: [ADR 0006](0006-versioned-capabilities-and-compiled-module-runtime.md),
|
||||
[ADR 0008](0008-module-owned-migration-contracts.md),
|
||||
[ADR 0009](0009-transactional-module-migrations.md),
|
||||
[ADR 0015](0015-static-module-http-routing-and-runtime-lifecycle.md),
|
||||
[ADR 0021](0021-modules-as-hostable-products.md),
|
||||
[ADR 0047](0047-module-ci-harness.md),
|
||||
[ADR 0048](0048-generic-image-and-installed-module-storage.md)
|
||||
|
||||
## Context
|
||||
|
||||
ADR 0016 gave module code database access through `ModuleDatabase`, which is
|
||||
`Pick<NodePgDatabase, "select" | "selectDistinct" | "selectDistinctOn" | "insert" | "update" |
|
||||
"delete" | "execute" | "transaction">`. Core implements it as a frozen object whose eight methods are
|
||||
bound to Core's own `drizzle(pool)` instance.
|
||||
|
||||
ADR 0048 then took modules out of the platform image, and ADR 0049 made each module release a
|
||||
self-contained package that bundles its own copy of Drizzle. The two copies now meet at run time,
|
||||
and an investigation of the current code (Drizzle 0.45.2) found the coupling runs deeper than a
|
||||
type:
|
||||
|
||||
- **Module objects cross into Core's ORM.** A module builds queries from table, column, and `SQL`
|
||||
objects made by its own Drizzle copy. Core's Drizzle instance then compiles and decodes them.
|
||||
This only works while the two copies agree on internal symbols and entity kinds, which is why ADR
|
||||
0049 records the module's Drizzle version under `transitional.databaseRuntime` and refuses a
|
||||
mismatch.
|
||||
- **Core's ORM objects cross into the module.** `transaction` hands module code Core's live
|
||||
`NodePgTransaction`. At run time that object is a full Drizzle database: its session, dialect,
|
||||
relational query builder, and `rollback()` are all reachable, whatever the `Pick` type says. A
|
||||
reference kept after the callback settles still points at a connection that has returned to the
|
||||
pool.
|
||||
- **Result decoding is split between two ORMs.** Core's Drizzle session overrides `pg`'s parsers
|
||||
for each query, so temporal values arrive as raw text. Then the module's column objects convert
|
||||
them. Neither half is an Ekklesia contract. `pg`'s default parsers are also process-global and
|
||||
mutable, and some lose information: `jsonb` numbers beyond double precision, and `numeric[]`
|
||||
elements, are parsed to JavaScript numbers.
|
||||
- **Errors are ORM and driver shapes.** A module that needs to recognize a PostgreSQL condition
|
||||
reads it from whatever object Drizzle or `pg` throws. Drizzle 0.45 wraps driver errors in its own
|
||||
error type, whose message carries the SQL text and the parameters.
|
||||
- **Schema validation needs Drizzle objects.** Module validation, Doctor, and the package verifier
|
||||
import a module's schema entrypoint and inspect it with Drizzle's `is(…, PgTable)`,
|
||||
`getTableConfig`, and `PgColumn`. That is the same cross-copy dependency, at validation time. Core
|
||||
extracts only plain facts from it: table names and foreign-key target tables.
|
||||
|
||||
Two findings show the dependency is unnecessary:
|
||||
|
||||
- **Migration execution is already independent of the ORM runtime.** Migration discovery reads SQL
|
||||
files and JSON metadata, and the runner executes SQL text. Neither imports Drizzle. Drizzle Kit
|
||||
snapshots are validated metadata; they are not needed to apply a migration.
|
||||
- **The module side can compile to SQL alone.** Drizzle and Kysely both compile a query to
|
||||
PostgreSQL text with `$n` placeholders and an ordered parameter list. Drizzle 0.45.2's own
|
||||
`pg-proxy` driver, given only a callback that receives SQL text, parameters, and an array-or-object
|
||||
row mode, ran `select`, `update`, and `insert … returning`, and decoded `timestamptz` and `int8`
|
||||
text with the module's own column definitions. That driver has no transactions, which a
|
||||
module-side adapter has to supply.
|
||||
|
||||
Signage 0.1.2, the first production module, uses `select`, `insert`, `update`, `delete`,
|
||||
`transaction`, one `returning`, and SQL expressions in `set` clauses. It uses no `execute`, joins,
|
||||
row locks, isolation options, or result row counts. None of its usage needs a Core ORM object. It
|
||||
recognizes a uniqueness conflict by reading SQLSTATE `23505` from the thrown error. A stable,
|
||||
Ekklesia-owned place for that code is desirable regardless of which ORM either side uses. (This
|
||||
paragraph is motivation, not part of the decision.)
|
||||
|
||||
## Decision
|
||||
|
||||
### The goal: the ORM is an implementation detail on both sides
|
||||
|
||||
Ekklesia defines a stable, Ekklesia-owned **Module Database ABI** between Core and modules.
|
||||
|
||||
- **The boundary is PostgreSQL itself.** Exactly these concepts cross it:
|
||||
- SQL text;
|
||||
- ordered, bound parameter values;
|
||||
- result rows and their metadata;
|
||||
- transaction scopes that Core controls.
|
||||
- **Core never receives or depends on a module's ORM or query-builder object.** That includes:
|
||||
- Drizzle tables, columns, `SQL` objects, query builders, and transactions;
|
||||
- Kysely objects;
|
||||
- any other third-party ORM or runtime object.
|
||||
- **Modules never receive Core's ORM objects, driver objects, or connections.**
|
||||
- **Each side chooses its own tools.** A module may use Drizzle, Kysely, raw SQL, or any module-side
|
||||
query builder that produces PostgreSQL text and parameters. Core may keep Drizzle, or replace it,
|
||||
without any module noticing.
|
||||
|
||||
This ADR defines **Module Database ABI version 1**. The TypeScript names below are illustrative; the
|
||||
concepts and the semantics are the contract.
|
||||
|
||||
### A correctness boundary, not a security boundary
|
||||
|
||||
**The Module Database ABI is a correctness and compatibility boundary. It is not a security
|
||||
sandbox.** Installed modules remain trusted backend code running inside the API process with its
|
||||
database role (ADR 0006, ADR 0016, ADR 0048).
|
||||
|
||||
Nothing in this ADR claims, and nothing built from it may claim, that the following can stop
|
||||
malicious module code from reaching data that is not its own:
|
||||
|
||||
- the ABI itself;
|
||||
- parsing or inspecting SQL;
|
||||
- checking table prefixes when statements execute;
|
||||
- `SET ROLE`;
|
||||
- PostgreSQL roles.
|
||||
|
||||
A module sends arbitrary SQL by design, including SQL that changes its role back. It also runs
|
||||
in-process, where it can reach the network and the environment without the ABI.
|
||||
|
||||
Table ownership remains what ADR 0007 made it: a static correctness check of declared tables and
|
||||
foreign keys, run during validation. The ABI keeps that check honest by removing the ORM objects it
|
||||
used to depend on. It adds no runtime enforcement.
|
||||
|
||||
### Declaring the ABI: manifest version 8
|
||||
|
||||
A module opts into the ABI explicitly, in its static manifest. The manifest is strict and changes by
|
||||
hard cutover (ADR 0006, ADR 0014, ADR 0021, ADR 0029), so the declaration arrives with
|
||||
**manifest version 8**. Conceptually:
|
||||
|
||||
```json
|
||||
{
|
||||
"manifestVersion": 8,
|
||||
"database": { "abi": 1 }
|
||||
}
|
||||
```
|
||||
|
||||
- **`database.abi: 1`** means the module receives a Module Database ABI v1 executor. It never
|
||||
receives the legacy facade.
|
||||
- **No `database` field** means the module receives ADR 0016's legacy `ModuleDatabase` facade
|
||||
during the deprecation window.
|
||||
- **Any other ABI value** fails validation. A platform that does not implement a declared ABI refuses
|
||||
the module rather than guessing.
|
||||
- **Version 8 is a hard cutover, like every manifest version before it.** Core parses exactly one
|
||||
manifest version; it never parses version 7 and version 8 side by side.
|
||||
- An existing source module moves to version 8, and repacks, without changing any database code.
|
||||
- A package already built with a version 7 manifest must be rebuilt.
|
||||
- Module Package v1 has only just landed, and no published ecosystem depends on version 7
|
||||
packages, so this costs a rebuild and nothing more.
|
||||
|
||||
In this ADR, a **legacy module** means a module with a version 8 manifest and no `database`
|
||||
declaration, which therefore uses the legacy facade. It never means a module with a version 7
|
||||
manifest.
|
||||
|
||||
Because the declaration is static, Core, the packager, and the verifier know a module's database
|
||||
contract without loading its code. Because Core enforces it, an ABI v1 module cannot reach the
|
||||
legacy facade, so nothing at run time can depend on Drizzle agreement.
|
||||
|
||||
The rest of manifest version 8 (field placement, validation messages, the template, and the
|
||||
cutover itself) is an implementation detail of the change that introduces it.
|
||||
|
||||
### The executor
|
||||
|
||||
An ABI v1 module's database service is an **executor**. Conceptually:
|
||||
|
||||
```ts
|
||||
query({ text, values, rowMode }) → { command, rowCount, rows, fields }
|
||||
transaction(callback, options) → the callback's result
|
||||
```
|
||||
|
||||
- **`text`** is exactly one PostgreSQL statement, using `$1 … $n` placeholders. **One `query` call
|
||||
runs one statement.** This is a correctness and API rule, not a security boundary.
|
||||
- The implementation upholds it with a reliable driver or protocol mechanism where one exists,
|
||||
such as PostgreSQL's extended query protocol, which accepts a single statement per request.
|
||||
- It does not require, and must not grow, an Ekklesia SQL parser.
|
||||
- If an implementation cannot reliably uphold this rule, the implementing change stops and reports
|
||||
the conflict. It does not weaken this ADR silently.
|
||||
- **`values`** is the ordered list of bound parameters (see
|
||||
[Parameters](#parameters)). Values are always bound and never interpolated into the text.
|
||||
- **`rowMode`** is `"object"` or `"array"`.
|
||||
- `"object"` rows are keyed by column name.
|
||||
- `"array"` rows are positional, in `fields` order. An ORM that selects the same column name twice,
|
||||
as joins do, needs this mode.
|
||||
- Duplicate column names are invalid in object mode. Core rejects such a result rather than
|
||||
silently dropping a column.
|
||||
- With the current PostgreSQL and driver execution path, the result's shape is only known once
|
||||
the statement has run. So a mutating statement can already have had its effects when the
|
||||
result-shape error is reported.
|
||||
- This is not a security guarantee. A caller that needs atomicity runs the mutation inside an ABI
|
||||
transaction, where the error rolls the mutation back with everything else.
|
||||
- **`command`** is PostgreSQL's command tag word, such as `SELECT`, `INSERT`, `UPDATE`, or `DELETE`.
|
||||
- **`rowCount`** is the number of rows PostgreSQL reports as processed, or `null` when it reports
|
||||
none.
|
||||
- **`rows`** are the result rows, decoded as in [Results](#results). `RETURNING` rows are ordinary
|
||||
rows.
|
||||
- **`fields`** are the ordered column metadata, present even when there are no rows: at least each
|
||||
column's name and its PostgreSQL type OID.
|
||||
|
||||
The executor contains no ORM type, AST, query object, or schema object, and accepts none.
|
||||
|
||||
**One executor per module.** Each ABI v1 module receives its own executor, bound to its module id.
|
||||
The module's `start` and `stop` hooks and its route handlers all receive that same object, which
|
||||
keeps ADR 0016's guarantee that lifecycle code and handlers share one database service. Different
|
||||
modules get different executors, so Core can attribute every statement to the module that issued it.
|
||||
|
||||
**Observability belongs to Core.** Core may record per-module timings, command tags, row counts, and
|
||||
error codes. It never writes SQL text or parameter values to logs, diagnostics, Doctor output, or
|
||||
HTTP responses.
|
||||
|
||||
**Session state is not part of the ABI.** Top-level `query` calls are separate autocommit statements
|
||||
that may run on different pooled connections. A module must not rely on session-level state, and
|
||||
must not change it. That includes:
|
||||
|
||||
- session `SET`;
|
||||
- temporary tables;
|
||||
- named prepared statements;
|
||||
- `LISTEN`;
|
||||
- session advisory locks.
|
||||
|
||||
Settings that must hold for one unit of work use `SET LOCAL` inside a transaction. Core may reset or
|
||||
discard a connection that a module changed. `COPY`, cursors, streaming results, and named prepared
|
||||
statements are not part of v1.
|
||||
|
||||
### Parameters
|
||||
|
||||
The ABI deliberately accepts a small set of PostgreSQL wire-safe values:
|
||||
|
||||
| Value | Sent as |
|
||||
| --- | --- |
|
||||
| `null` | SQL `NULL` |
|
||||
| `boolean` | `true` / `false` |
|
||||
| `number` | its JavaScript decimal text; `NaN`, `Infinity`, and `-Infinity` as PostgreSQL's `NaN`, `Infinity`, and `-Infinity` |
|
||||
| `bigint` | its decimal text |
|
||||
| `string` | the text, unchanged |
|
||||
| `Uint8Array` | `bytea` |
|
||||
| arrays of the above | a PostgreSQL array, nested for more dimensions |
|
||||
|
||||
Everything else is refused with a sanitized error before anything reaches PostgreSQL, rather than
|
||||
being coerced. In particular:
|
||||
|
||||
- **`Date` is not an ABI parameter.** A JavaScript `Date` is an instant. Whether it should become a
|
||||
`timestamp` or a `timestamptz`, and in which time zone, depends on a column type the ABI cannot
|
||||
see. The module-side adapter converts it to explicit text first; for example, Drizzle's timestamp
|
||||
columns already send ISO-8601 text.
|
||||
- **Plain objects are not implicit JSON.** A module or adapter encodes JSON explicitly, as a string,
|
||||
and the SQL casts it where needed.
|
||||
- **Identifiers are never parameters.** Table and column names are part of the module's compiled
|
||||
SQL text. The ABI provides no identifier interpolation.
|
||||
|
||||
### Results
|
||||
|
||||
Core owns one documented decoding table for ABI v1. It does not depend on `pg`'s global parser
|
||||
registry, which module code could change, or on any ORM. Values that a generic conversion could
|
||||
change, or change silently by time zone, arrive in a stable raw form:
|
||||
|
||||
| PostgreSQL type | Arrives as |
|
||||
| --- | --- |
|
||||
| any `NULL` | `null` |
|
||||
| `bool` | `boolean` |
|
||||
| `int2`, `int4`, `oid` | `number` |
|
||||
| `int8` | `string`, its exact decimal text |
|
||||
| `float4`, `float8` | `number`, including `NaN` and `±Infinity` |
|
||||
| `numeric` / `decimal` | `string`, its exact text (`NaN` included) |
|
||||
| `text`, `varchar`, `bpchar`, `name`, `uuid` | `string` |
|
||||
| `date`, `timestamp`, `timestamptz`, `time`, `timetz`, `interval` | `string`: PostgreSQL's ISO text output |
|
||||
| `json`, `jsonb` | `string`: the JSON text, not parsed |
|
||||
| `bytea` | `Uint8Array` |
|
||||
| arrays of any of the above | JavaScript arrays (nested for more dimensions), each element decoded by the same rule, with `null` elements as `null` |
|
||||
| any other type | `string`: PostgreSQL's text output |
|
||||
|
||||
- **Temporal text is fixed for every module.** Core fixes the session's `DateStyle` (ISO),
|
||||
`IntervalStyle` (`postgres`), and `TimeZone` (UTC) on every connection that serves the ABI. The
|
||||
same instant therefore always produces the same text, whatever the server's or the host's local
|
||||
zone.
|
||||
- **`bytea` has one representation.** A value is a `Uint8Array`. A module must not rely on it being
|
||||
any particular subclass.
|
||||
- **Higher-level conversion belongs to the module side.** Converting text to `Date`, `numeric` to a
|
||||
decimal type, `int8` to `bigint` or `number`, and JSON text to values is the adapter's job, or the
|
||||
module's. The Drizzle column types Signage uses already accept exactly these raw forms.
|
||||
|
||||
Changing how any type arrives, including decoding a type that arrives as text today, requires a new
|
||||
ABI version.
|
||||
|
||||
### Errors
|
||||
|
||||
Every failure the executor reports is a Core-owned, sanitized **`ModuleDatabaseError`**.
|
||||
Conceptually:
|
||||
|
||||
```ts
|
||||
ModuleDatabaseError { code, sqlState? }
|
||||
```
|
||||
|
||||
The two fields mean different things:
|
||||
|
||||
- **`code`** is always present. It is a stable, Ekklesia-owned error **category**, meant for
|
||||
programmatic handling. Its categories cover at least:
|
||||
- PostgreSQL rejected the statement;
|
||||
- a parameter value was refused;
|
||||
- a transaction option was refused;
|
||||
- the scope was closed or used incorrectly;
|
||||
- the transaction could not be completed.
|
||||
|
||||
This ADR does not freeze the complete set or the exact names. Once published, a code keeps its
|
||||
meaning.
|
||||
- **`sqlState`** is optional. It is the five-character SQLSTATE that PostgreSQL itself reported, such
|
||||
as `23505` (unique violation), `23503` (foreign-key violation), or `40001` (serialization failure).
|
||||
It is present only when the database supplied one, and Core never invents one.
|
||||
- Failures that the ABI detects itself have a `code` and no `sqlState`: a closed scope, an
|
||||
invalid parameter type, or an invalid transaction option.
|
||||
- Modules branch on `code` for ABI failures and on `sqlState` for PostgreSQL conditions.
|
||||
- **It never carries:**
|
||||
- SQL text or parameter values;
|
||||
- the driver's message, detail, hint, constraint, table, or column names;
|
||||
- a stack, a cause, connection details, or any Core, `pg`, or Drizzle object.
|
||||
- **Modules recognize it structurally** (its name and its `code`), never with `instanceof`. A module
|
||||
bundles its own copy of the SDK (ADR 0049), so class identity across the boundary is not a
|
||||
contract.
|
||||
|
||||
This replaces modules depending on `DrizzleQueryError`, `pg`'s `DatabaseError`, or any other driver
|
||||
or ORM error shape. An uncaught `ModuleDatabaseError` in a route handler is sanitized at the
|
||||
handler boundary exactly as before (ADR 0015, ADR 0016).
|
||||
|
||||
### Transactions
|
||||
|
||||
Transactions are **callback-scoped**, and Core owns the connection throughout:
|
||||
|
||||
```ts
|
||||
transaction(async (tx) => { … }, { isolation, readOnly })
|
||||
```
|
||||
|
||||
- **Starting.** Core takes a connection from its pool and begins a transaction with the requested
|
||||
options. It then calls the callback with `tx`, a Module Database ABI v1 executor scoped to that
|
||||
transaction alone. No `pg` client and no Core ORM transaction is ever exposed.
|
||||
- **Commit and rollback.** Core commits only when the callback's promise resolves and no operation
|
||||
launched through `tx` is still outstanding. When the callback rejects, Core rolls back, and the
|
||||
transaction call rejects with the callback's error. There is no `rollback()` method: to roll back,
|
||||
the module throws.
|
||||
- **All work is awaited inside the callback.** Module code must await every database operation it
|
||||
starts through `tx` before its callback returns. What happens when work is still outstanding
|
||||
depends on how the callback settled:
|
||||
- **Resolved with work outstanding.** The module forgot an `await` and has used the scope
|
||||
incorrectly. The transaction is never committed: Core rolls it back, and the transaction call
|
||||
rejects with the scope-usage `ModuleDatabaseError`. A forgotten `await` therefore fails. It never
|
||||
quietly changes what the transaction contains.
|
||||
- **Rejected with work outstanding.** The callback's own rejection stays the transaction's error.
|
||||
Core rolls back and does not replace that error, a PostgreSQL failure included, with the
|
||||
scope-usage error just because sibling work was still settling. This is what happens when one
|
||||
query in `await Promise.all([tx.query(a), tx.query(b)])` fails while the other is still running.
|
||||
- Either way, outstanding work never leads to a commit.
|
||||
- Core may let operations that already started finish, or settle, before it rolls back and releases
|
||||
the connection, so that a pooled connection is never handed on mid-operation. Waiting for them
|
||||
never turns into a commit.
|
||||
- **Transaction control belongs to the callbacks.** Transaction-control SQL is forbidden through
|
||||
`query`: `BEGIN`, `START TRANSACTION`, `COMMIT`, `ROLLBACK`, `SAVEPOINT`, `RELEASE SAVEPOINT`, and
|
||||
every other statement that starts, ends, or marks a transaction. Modules control a transaction's
|
||||
lifecycle only through the `transaction` and `savepoint` callbacks.
|
||||
- This is a conformance rule for trusted module code, not a sandbox or a security guarantee.
|
||||
- Core does not parse SQL to enforce it. Such a statement is valid PostgreSQL, so PostgreSQL
|
||||
executes it before Core can see its result.
|
||||
- Core detects a violation where the driver and protocol make that possible: from the statement's
|
||||
command tag, and from the connection's transaction status. It then fails safely:
|
||||
- the call rejects;
|
||||
- the transaction's scopes refuse every later statement without sending it, because it would
|
||||
otherwise run outside any transaction;
|
||||
- Core never commits the enclosing transaction itself, and that transaction rejects too;
|
||||
- the connection is destroyed rather than reused.
|
||||
- Core does not claim to undo a transaction-control statement PostgreSQL has already executed. A
|
||||
`COMMIT` sent through a transaction's `query` has already committed the work before it.
|
||||
- The commit and rollback guarantees in this section apply to conforming use of the ABI.
|
||||
- **Revocation.** When the callback settles, `tx` is revoked, and it stays revoked once the scope has
|
||||
finished. Any later call on it fails with the
|
||||
closed-scope `ModuleDatabaseError` and sends nothing to PostgreSQL. A `tx` that escapes its
|
||||
callback can never run a statement on a connection that has gone back to the pool.
|
||||
- **Options.** `isolation` is `read committed`, `repeatable read`, or `serializable`. `readOnly` is a
|
||||
boolean. An unknown option or value is refused, never ignored. `deferrable` is reserved for a
|
||||
later version.
|
||||
- **No automatic retries.** Core never retries a transaction. A callback may have side effects
|
||||
outside the database, so retrying on `40001` is the module's decision.
|
||||
- **Failed statements.** PostgreSQL semantics apply unchanged. After a failed statement the
|
||||
transaction is aborted, and later statements fail until the scope ends. A module that wants to
|
||||
continue after a failure uses a savepoint.
|
||||
|
||||
**Savepoints** are callback-scoped too:
|
||||
|
||||
```ts
|
||||
tx.savepoint(async (sp) => { … })
|
||||
```
|
||||
|
||||
- **Naming.** Core generates every savepoint identifier. Modules never provide one.
|
||||
- **Release and rollback.** A resolved callback releases the savepoint. A rejected one rolls back
|
||||
to it, releases it, and rejects with the callback's error.
|
||||
- **Outstanding work.** The awaiting rule for transactions applies unchanged.
|
||||
- A savepoint callback that resolves with operations still outstanding is a scope-usage error.
|
||||
The savepoint is rolled back, never released, and the call rejects with that error.
|
||||
- A savepoint callback that rejects keeps its own error. The savepoint is rolled back as for any
|
||||
rejection.
|
||||
- Either way, outstanding work never leads to a release.
|
||||
- **One active scope.** Only the innermost open scope may run statements. While a savepoint is
|
||||
open, its parent executor refuses statements and new savepoints, so savepoint scopes can never
|
||||
overlap.
|
||||
- **Revocation.** `sp` is revoked when its callback settles, exactly like `tx`.
|
||||
|
||||
Nested transactions are savepoints. No nested-transaction API exposes a driver or ORM object.
|
||||
|
||||
**Timeouts and cancellation stay with Core.** The existing per-statement bounds of the pool
|
||||
(`statement_timeout` and `query_timeout`) apply to every ABI statement. Core may also bound how long
|
||||
a transaction stays open. Modules cannot change Core's timeout policy through the ABI. `AbortSignal`
|
||||
and module-initiated cancellation are not part of v1.
|
||||
|
||||
### Module-side adapters
|
||||
|
||||
The ABI is ORM-neutral. The Module SDK may ship adapters **on the module side**, bundled into each
|
||||
module like the rest of the SDK (ADR 0049):
|
||||
|
||||
- **A Drizzle adapter.** It turns the executor into a Drizzle database the module's own tables work
|
||||
with. It may use Drizzle's `pg-proxy` driver, mapping row-array queries to `rowMode: "array"` and
|
||||
the rest to `"object"`. It supplies `transaction` by opening an ABI transaction and binding a fresh
|
||||
Drizzle instance to `tx`.
|
||||
- **A raw-SQL helper.** A tagged template that turns interpolations into placeholders and values.
|
||||
- **Later, a Kysely adapter.**
|
||||
|
||||
Adapter types and query-builder types are not part of the ABI. Core never learns, and never needs
|
||||
to learn, that a module used Drizzle. A module may bundle any Drizzle version its adapter supports.
|
||||
|
||||
### Schema metadata
|
||||
|
||||
For an ABI v1 module, Core validates table ownership from **plain, Ekklesia-owned metadata**, not
|
||||
from ORM objects. Conceptually:
|
||||
|
||||
```yaml
|
||||
tables:
|
||||
- name: care_team_members
|
||||
foreignKeys:
|
||||
- table: care_team_teams
|
||||
```
|
||||
|
||||
- **What it is.** Plain data: strings, arrays, and records, with no functions or class instances.
|
||||
Core validates its structure strictly.
|
||||
- **What Core checks.** It is enough for the checks ADR 0007 already makes:
|
||||
- the module namespace and identifier rules;
|
||||
- uniqueness;
|
||||
- foreign keys that stay within the module and target its declared tables;
|
||||
- atomic, deterministic aggregation with sanitized issues.
|
||||
- **What Core never does.** For ABI v1 modules, Core does not call Drizzle's `is(…, PgTable)`,
|
||||
`getTableConfig`, or `PgColumn` checks on module-provided objects, and does not import an ORM to
|
||||
understand the metadata.
|
||||
- **How a module produces it.** A Drizzle helper in the Module SDK may derive it from the module's
|
||||
own Drizzle tables, on the module side. A Kysely or raw-SQL module may write it by hand or produce
|
||||
it another way.
|
||||
- **What it promises.** The metadata is a declaration checked for consistency. It is exactly as
|
||||
strong a correctness check as today's, and no stronger: the database enforces columns when the
|
||||
migration runs.
|
||||
|
||||
Legacy modules keep the existing Drizzle-object path while legacy support exists. Where the metadata
|
||||
lives (in the schema entrypoint's default export or in a static file) is decided by the change that
|
||||
implements it. Either way, it must be readable without Core importing an ORM.
|
||||
|
||||
### Migrations are unchanged
|
||||
|
||||
Migration discovery, validation, and execution stay as ADR 0008, ADR 0009, and ADR 0048 define
|
||||
them. The runner already executes SQL without an ORM runtime. Migrations stay forward-only and
|
||||
explicit, and history is retained for absent modules.
|
||||
|
||||
The Drizzle Kit migration file format (journal, snapshots, and `_ekklesia.json`) remains the module
|
||||
migration contract. Whether that build-tool format should become Ekklesia-owned is a separate
|
||||
question, and this ADR does not change it.
|
||||
|
||||
### Module Package v1
|
||||
|
||||
ADR 0049's `packageFormat` stays **1**. For a module that declares ABI v1:
|
||||
|
||||
- **Drizzle is allowed in the bundle.** It is a module-side implementation detail, for example of
|
||||
the adapter.
|
||||
- **The packager omits `transitional.databaseRuntime`.** Core requires no Drizzle runtime agreement.
|
||||
- **The verifier checks the ABI declaration and the plain schema metadata instead.** It refuses an
|
||||
ABI v1 package that carries `transitional.databaseRuntime`. Such a package was made by a packager
|
||||
that did not know the module's database contract.
|
||||
|
||||
A legacy module's package keeps today's `transitional.databaseRuntime` recording and validation for
|
||||
as long as the legacy facade exists.
|
||||
|
||||
This needs no new package format. ADR 0049 already lets a format 1 package carry no `transitional`
|
||||
facts. A platform from before this change cannot misread an ABI v1 package either: it refuses the
|
||||
version 8 manifest.
|
||||
|
||||
### Compatibility and deprecation
|
||||
|
||||
The deprecation window applies to the **legacy database facade**, not to manifest version 7.
|
||||
|
||||
1. **Phase 1.** Core serves both contracts: the legacy facade to version 8 modules without a
|
||||
`database` declaration, and an ABI v1 executor to modules that declare `abi: 1`.
|
||||
2. **Phase 2.** The Module SDK and the module template use ABI v1. First-party modules move over
|
||||
independently, each in its own change.
|
||||
3. **Phase 3.** After at least one documented deprecation window, ending in a breaking Module SDK
|
||||
release, the legacy facade may be removed. Its removal date, and how the manifest then expresses
|
||||
the database contract, belong to that later decision.
|
||||
|
||||
The legacy facade is transitional. It is not part of the permanent module contract, and it keeps
|
||||
its current behaviour until it is removed.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A module's ORM, and its version, no longer has to match Core's. Core can upgrade or replace its
|
||||
own Drizzle without rebuilding any ABI v1 module.
|
||||
- `transitional.databaseRuntime`, recorded in ADR 0049 as an implementation constraint, disappears
|
||||
for ABI v1 packages, as ADR 0049 anticipated.
|
||||
- Modules get one stable error shape with SQLSTATE, and one documented result encoding that loses
|
||||
no precision and does not depend on time zones.
|
||||
- A transaction object can no longer outlive its callback or reach a pooled connection afterwards.
|
||||
- Every module moves to manifest version 8. Already-built version 7 packages must be rebuilt.
|
||||
Database code changes only when a module opts into ABI v1.
|
||||
- An ABI v1 module bundles an adapter. Its Drizzle code mostly stays as it is: the query builders
|
||||
are its own.
|
||||
- Core takes on a documented decoding table, transaction scoping, savepoint management, and error
|
||||
mapping that Drizzle did implicitly before. Each needs live PostgreSQL test evidence.
|
||||
- Core must serve two database contracts during the deprecation window.
|
||||
- None of this is a sandbox. A trusted module can still do anything the API's database role can.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **An ORM AST or query-object ABI.** Rejected. Anything richer than SQL text and parameters is one
|
||||
ORM's internal representation, so it would tie the ABI to that ORM and its release cadence. The
|
||||
only part worth keeping, the row mode and field metadata, is already in the executor.
|
||||
- **An Ekklesia-owned query DSL.** Rejected. It would cover less of PostgreSQL than Drizzle or
|
||||
Kysely, cost more to maintain than either, and still force every module to rewrite its queries.
|
||||
- **A permanent, versioned Drizzle facade.** Rejected as the permanent contract. It would make Drizzle
|
||||
version agreement a permanent install-time constraint. Core's transaction object would keep
|
||||
reaching module code. Core could never change its ORM. It remains only as the transitional legacy
|
||||
facade.
|
||||
- **Exposing the pool, a client, or a connection string.** Rejected, as in ADR 0016. Connection
|
||||
lifecycle, timeouts, and transaction cleanup belong to the platform.
|
||||
- **SQL parsing or execution-time prefix checks as a security boundary.** Rejected. SQL can name
|
||||
tables in too many ways, and a trusted module can send any SQL, so a parser would be bypassable
|
||||
and a false promise. ADR 0016 rejected the same idea.
|
||||
- **`SET ROLE` or per-module PostgreSQL roles as a security boundary.** Rejected. Any SQL the module
|
||||
sends can reset the role, and in-process code can reach the database without the ABI. Roles in a
|
||||
module's CI, as a test aid, would be a correctness tool, not a boundary.
|
||||
- **`Date` as a direct parameter.** Rejected. Its meaning depends on the target column's type, which
|
||||
the ABI cannot see. Adapters convert it explicitly.
|
||||
- **`pg`'s default parsing as the result contract.** Rejected. It is process-global and mutable, it
|
||||
loses precision for `jsonb` numbers and `numeric[]` elements, and it is not an Ekklesia contract.
|
||||
- **Parsing manifest versions 7 and 8 side by side.** Rejected. Manifest versions are hard cutovers,
|
||||
and the deprecation window is about the database facade, not the manifest.
|
||||
|
||||
## Deferred
|
||||
|
||||
- The implementation: Core's executor, decoding table, transactions, savepoints, errors, and their
|
||||
tests.
|
||||
- Manifest version 8's exact mechanics, and the template and fixture cutover.
|
||||
- The Module SDK's Drizzle adapter and raw-SQL helper.
|
||||
- A Kysely adapter.
|
||||
- The legacy facade's removal date and the release that removes it.
|
||||
- Redesigning the migration file format.
|
||||
- SQL cancellation and `AbortSignal`.
|
||||
- Stronger process or database isolation for modules.
|
||||
- Moving Signage to ABI v1.
|
||||
@@ -0,0 +1,207 @@
|
||||
# 0051: Module packages declare their own license and carry its text
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-10-07
|
||||
- Amends: [0049](0049-module-package-v1.md) — every Module Package v1 artifact gains an
|
||||
authoritative package-level license declaration and a `LICENSE` payload. `packageFormat` stays 1.
|
||||
- Related: [the repository license](../../../README.md#license),
|
||||
[ADR 0048](0048-generic-image-and-installed-module-storage.md),
|
||||
[ADR 0050](0050-module-database-abi-v1.md)
|
||||
|
||||
## Context
|
||||
|
||||
ADR 0049 makes a module release one immutable, self-contained package and already requires
|
||||
`THIRD_PARTY_NOTICES` for code bundled from third-party dependencies. It deliberately says that no
|
||||
package is a distributable release until license and notice collection is proven. The implemented
|
||||
packager now generates those notices from the dependency code it actually bundles, but Module
|
||||
Package v1 still does not declare the module's own license or carry the module's license text.
|
||||
|
||||
Those are different obligations. `THIRD_PARTY_NOTICES` excludes both the module itself and bundled
|
||||
Ekklesia-owned packages. The source module's development `package.json` is not copied into the
|
||||
package: the packager generates a minimal runtime `package.json` instead. Consequently, an archive
|
||||
can preserve every third-party notice and still lose the authoritative declaration and text for the
|
||||
work being distributed.
|
||||
|
||||
The repository and its workspace packages declare `AGPL-3.0-or-later`, and the repository carries
|
||||
the GNU AGPL version 3 text in `LICENSE`. The canonical module template also declares
|
||||
`AGPL-3.0-or-later` in its source `package.json`. Official Ekklesia modules use that license today,
|
||||
but a packager cannot assume that every module has the platform's license. A module release must say
|
||||
what license its own publisher actually declared and must carry the corresponding material.
|
||||
|
||||
The current `ekklesia.package.json` contract is strict: a reader refuses unknown fields. Adding a
|
||||
required field therefore makes pre-change and post-change readers reject one another's artifacts.
|
||||
That is intentional here, not a reason for `packageFormat: 2`. ADR 0049 has not admitted a
|
||||
distributable v1 release without proven license handling; this decision completes that still-open v1
|
||||
requirement before real modules are distributed. The packager and verifier must adopt the tightening
|
||||
together, and strict readers fail closed during that cutover rather than silently misreading a
|
||||
package.
|
||||
|
||||
## Decision
|
||||
|
||||
### The package declares the module's own license once
|
||||
|
||||
Every Module Package v1 `ekklesia.package.json` has a required top-level `license` field containing
|
||||
the module's SPDX license expression:
|
||||
|
||||
```json
|
||||
{
|
||||
"packageFormat": 1,
|
||||
"module": { "id": "example", "version": "0.1.0" },
|
||||
"license": "AGPL-3.0-or-later"
|
||||
}
|
||||
```
|
||||
|
||||
`ekklesia.package.json.license` is the authoritative package-release declaration. The expression is
|
||||
not repeated in `ekklesia.module.json` or in the generated runtime `package.json`. The module's
|
||||
source `package.json` is a packaging input, not release metadata inside the finished artifact.
|
||||
|
||||
The source module must explicitly declare its license in its own project metadata. The canonical
|
||||
packager reads the normal string `license` field from the source module's `package.json`, validates
|
||||
it as a supported SPDX license expression, and preserves that declaration in
|
||||
`ekklesia.package.json`. It refuses a missing, empty, `UNLICENSED`, legacy object-shaped, malformed,
|
||||
or unsupported declaration. It never substitutes the Ekklesia platform's license and does not try
|
||||
to decide whether a publisher chose an appropriate license.
|
||||
|
||||
For official Ekklesia modules today the declaration is `AGPL-3.0-or-later`, matching the repository
|
||||
license. This is a fact about those modules, not a default the packager invents for other modules.
|
||||
|
||||
### The package carries the applicable license material
|
||||
|
||||
Every distributable Module Package v1 contains a regular file at this canonical path inside the
|
||||
module directory:
|
||||
|
||||
```text
|
||||
LICENSE
|
||||
```
|
||||
|
||||
`LICENSE` contains the license material applicable to the module release. It is an ordinary payload
|
||||
file under ADR 0049:
|
||||
|
||||
- it appears exactly once in the strict `files` list in `ekklesia.package.json`;
|
||||
- its byte size and SHA-256 are recorded there;
|
||||
- the archive SHA-256 covers it and the metadata that records it;
|
||||
- it is immutable for that release and changes only by producing a different archive.
|
||||
|
||||
The license bytes must be available as an explicit source or build input. The canonical source is a
|
||||
module-owned `LICENSE` file at the module source root. Tooling may also accept an explicitly named
|
||||
license-text input when a workspace layout needs one, but it must place those bytes at the package's
|
||||
canonical `LICENSE` path. It must not search ancestor directories and silently adopt a repository
|
||||
license, generate project-specific license material, or download text from the network. The
|
||||
in-repository template implementation may later add its own `LICENSE` input or explicitly select
|
||||
the repository's AGPL text; this ADR does neither.
|
||||
|
||||
The packager validates that both inputs exist: the declared SPDX expression and the applicable
|
||||
license text. It preserves the supplied text as deterministic package input. It is not a license
|
||||
compatibility engine and does not offer legal advice.
|
||||
|
||||
### `THIRD_PARTY_NOTICES` remains a separate concern
|
||||
|
||||
`THIRD_PARTY_NOTICES` continues to describe third-party packages actually bundled into or included
|
||||
with the module: their identities, versions, declared licenses, uses, and required license and notice
|
||||
texts. It is not the module's own license file and cannot satisfy the `LICENSE` requirement.
|
||||
|
||||
The module itself is not added to `THIRD_PARTY_NOTICES`. A first-party module is not duplicated there
|
||||
merely because it uses the same AGPL license as Ekklesia. `LICENSE` and `THIRD_PARTY_NOTICES` both
|
||||
remain required because they describe different parts of the release.
|
||||
|
||||
### Bundled Ekklesia code
|
||||
|
||||
A module package may bundle Ekklesia-owned SDK, contracts, database adapter, UI, and related code.
|
||||
`incorporates` continues to record the exact versions of those Ekklesia components. It is an
|
||||
inventory and compatibility fact, not a second authoritative declaration of the module's license.
|
||||
|
||||
Today's official module packages and the Ekklesia components they incorporate are all
|
||||
`AGPL-3.0-or-later`. Their package `LICENSE` therefore carries the applicable AGPL text once. The
|
||||
package does not need one identical copy per incorporated Ekklesia component, and those components
|
||||
do not move into `THIRD_PARTY_NOTICES` merely to obtain duplicate text.
|
||||
|
||||
This ADR does not decide every future combination of a community module's license with differently
|
||||
licensed incorporated code. Publishers remain responsible for their choices and obligations. The
|
||||
packager preserves declared information and required material; it does not judge broad license
|
||||
compatibility.
|
||||
|
||||
### Packaging and verification fail closed
|
||||
|
||||
The implementation that follows this ADR must make the packager refuse a module when:
|
||||
|
||||
- its source project has no acceptable explicit SPDX license expression;
|
||||
- its license-text input is missing or is not a regular file;
|
||||
- producing the package would omit `LICENSE` from the strict payload.
|
||||
|
||||
The package verifier must refuse an artifact when:
|
||||
|
||||
- `ekklesia.package.json.license` is missing, malformed, or outside the expression syntax the
|
||||
contract supports;
|
||||
- the `LICENSE` payload is missing;
|
||||
- `LICENSE` is absent from the exact `files` list; or
|
||||
- its listed size or SHA-256 does not match its bytes, as with every other payload file.
|
||||
|
||||
This ADR does not implement those checks. The contracts, packager, verifier, fixtures, template,
|
||||
and package proofs must change together in the implementation PR.
|
||||
|
||||
### Determinism and runtime metadata
|
||||
|
||||
`LICENSE` introduces no timestamp, generated host path, network lookup, or other environmental
|
||||
input. The deterministic archive rules in ADR 0049 apply to it unchanged.
|
||||
|
||||
The generated runtime `package.json` remains minimal and is not authoritative for licensing. There
|
||||
is no runtime need to add a `license` field there. `ekklesia.package.json` is the release metadata,
|
||||
while the source module's `package.json` only supplies the publisher's declaration to the packager.
|
||||
|
||||
### Package format remains 1
|
||||
|
||||
`packageFormat` remains **1**. This is an additive tightening that completes ADR 0049's licensing
|
||||
requirement before the first distributable Module Package v1 release. It does not change the archive
|
||||
encoding, safety rules, target model, installation layout, or meaning of any existing field.
|
||||
|
||||
The strict metadata schema is preserved. A verifier that predates this decision refuses the new
|
||||
unknown `license` field; an updated verifier refuses an old package that lacks it or lacks `LICENSE`.
|
||||
Both outcomes are safe. There is no installed or published v1 release compatibility promise to
|
||||
preserve because ADR 0049 explicitly withheld distributable-release status pending this work.
|
||||
|
||||
ADR 0050's database ABI decision is unaffected. Whether a package uses database ABI v1 or the legacy
|
||||
database facade has no bearing on its license declaration or payload.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A Module Package v1 release is self-describing about the module's own license and carries the
|
||||
corresponding material without consulting its source repository or the network.
|
||||
- Operators can audit the release declaration in `ekklesia.package.json`, the module's license in
|
||||
`LICENSE`, and dependency obligations in `THIRD_PARTY_NOTICES` without conflating them.
|
||||
- Module publishers must make both the SPDX declaration and license text explicit packaging inputs.
|
||||
- The template and every package fixture must be updated before packaging succeeds, but only in the
|
||||
later implementation PR.
|
||||
- Existing development artifacts produced before this tightening are intentionally rejected and
|
||||
rebuilt; none was a distributable v1 release under ADR 0049.
|
||||
- Identical AGPL text is carried once for today's official module and incorporated Ekklesia code.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **Use only `THIRD_PARTY_NOTICES`.** Rejected: it describes third-party dependencies and explicitly
|
||||
excludes the module itself and Ekklesia-owned incorporated packages.
|
||||
- **Make the generated runtime `package.json` authoritative.** Rejected: it is deliberately minimal
|
||||
runtime discovery metadata, and copying the declaration there would duplicate release metadata.
|
||||
- **Copy the platform's license automatically.** Rejected: the platform cannot declare a module's
|
||||
license for its publisher, and a community module may make a different declaration.
|
||||
- **Search parent directories for `LICENSE`.** Rejected: the chosen file would depend on checkout
|
||||
layout and could silently apply an unrelated repository's terms. An out-of-tree input must be
|
||||
explicit.
|
||||
- **Download standard license text while packaging.** Rejected: builds must remain offline,
|
||||
deterministic, and independent of a mutable remote source.
|
||||
- **Duplicate the AGPL text for every incorporated Ekklesia package.** Rejected: identical copies
|
||||
add no information. `incorporates` records component identity and version; one applicable text is
|
||||
sufficient for today's official packages.
|
||||
- **Create `packageFormat: 2`.** Rejected: v1 has not crossed its own distributable-release boundary,
|
||||
strict readers fail safely, and no archive or installation mechanism changes.
|
||||
- **Make the packager decide license compatibility.** Rejected: preserving declarations and material
|
||||
is mechanical; judging every present and future license combination is a broader legal and policy
|
||||
problem.
|
||||
|
||||
## Deferred
|
||||
|
||||
- Implementing contract, packager, verifier, fixture, template, and package-proof changes.
|
||||
- The exact set and version of SPDX expression syntax accepted by the implementation, beyond the
|
||||
requirement that unsupported expressions fail closed.
|
||||
- Broad policy for future mixed-license community modules and incorporated code.
|
||||
- Container-image license distribution.
|
||||
- Marketplace, installer, publisher verification, and package-signing policy.
|
||||
@@ -0,0 +1,227 @@
|
||||
# 0052: Reviewed license supplements cover redistributed dependencies whose packages omit required material
|
||||
|
||||
- Status: Proposed
|
||||
- Date: 2026-10-07
|
||||
- Extends: [0049](0049-module-package-v1.md), in one respect only. ADR 0049 left exactly how licence
|
||||
and notice material is collected to the packager's implementation. That implementation, and the
|
||||
module development guide, then took material only from a redistributed package's own files, or
|
||||
from a predefined standard licence text that needs no project-specific lines. This ADR adds one
|
||||
further source: a reviewed supplement. Everything else in ADR 0049 is unchanged, as is
|
||||
`packageFormat`.
|
||||
- Related: [ADR 0048](0048-generic-image-and-installed-module-storage.md),
|
||||
[ADR 0051](0051-module-package-license-material.md)
|
||||
|
||||
## Context
|
||||
|
||||
ADR 0049 requires every Module Package v1 to carry `THIRD_PARTY_NOTICES`: the licence and notice
|
||||
material that redistributing its third-party code requires. It also states that no package is a
|
||||
distributable release until licence and notice collection is proven. The canonical packager collects
|
||||
material from two places:
|
||||
|
||||
- the licence, copying, copyright, and notice files a redistributed package ships at its root;
|
||||
- the standard text of the package's declared licence, but only when that text is complete without
|
||||
project-specific lines (Apache-2.0 today).
|
||||
|
||||
A package that has neither stops packaging. That is fail-closed by design: an MIT or BSD package
|
||||
without its file is refused, because its copyright line exists only in that file.
|
||||
|
||||
The first native-target module package needs a dependency that this rule cannot handle.
|
||||
`@img/[email protected]` is the prebuilt libvips that `sharp` loads on Linux x64 with
|
||||
glibc. It declares `LGPL-3.0-or-later`. The package itself, like its latest release 1.3.4, contains
|
||||
only these files:
|
||||
- `README.md`
|
||||
- `versions.json`
|
||||
- `lib/index.js`
|
||||
- one glib header
|
||||
- one shared library
|
||||
|
||||
That shared library includes about 29 libraries. Its README names each one and the licence it is
|
||||
used under, mostly with a link:
|
||||
- LGPL components, such as libvips, glib, and pango;
|
||||
- cairo, under MPL-2.0;
|
||||
- many BSD-, MIT-, zlib-, IJG-, and FreeType-licensed libraries.
|
||||
|
||||
The package ships no licence text and no copyright notice for any of them. No standard text can stand
|
||||
in for that material: it includes per-project copyright notices and several different licences.
|
||||
|
||||
Options that were ruled out before this decision:
|
||||
- **Downloading material while packaging.** Packaging must stay offline and deterministic (ADR 0049,
|
||||
ADR 0051).
|
||||
- **Inventing material.** A packager that guesses notices produces artifacts that look compliant and
|
||||
may not be.
|
||||
|
||||
Ekklesia needs a way to carry material that a dependency's own package omits. It has to be reviewed
|
||||
by people, tied to exactly the bytes it was reviewed for, and fail closed whenever that tie breaks.
|
||||
|
||||
Some of these licences may also impose obligations beyond carrying text: the LGPL and the MPL can
|
||||
require recipients to be able to obtain corresponding source. Whether and how such an obligation is
|
||||
met for a particular redistributed binary is a legal and compliance determination. This decision
|
||||
does not make that determination.
|
||||
|
||||
## Decision
|
||||
|
||||
### Package-provided material stays the preferred source
|
||||
|
||||
The licence, copying, copyright, and notice material that a redistributed package ships remains the
|
||||
primary source, collected exactly as it is today. Nothing in this ADR removes, replaces, or rewrites
|
||||
package-provided material. A supplement only adds to it.
|
||||
|
||||
### A reviewed supplement fills what a package omits
|
||||
|
||||
When a redistributed dependency omits material that redistribution requires, the canonical Ekklesia
|
||||
packager may use a **license supplement**: a checked-in, reviewed record of that material for one
|
||||
exact dependency.
|
||||
|
||||
- **Where supplements live.** In the Ekklesia repository, as part of the canonical packager's own
|
||||
versioned inputs. A supplement enters only through Ekklesia's normal reviewed change process, and
|
||||
the change that adds it is its review. A given packager version therefore always carries the same
|
||||
supplements.
|
||||
- **What that review is.** Repository review of a supplement is project and code review of a build
|
||||
input: whether its content, its exact match, and its provenance are what the change says they are.
|
||||
It is not legal review, and it does not prove that any redistribution obligation is satisfied.
|
||||
- **What a supplement may contain:**
|
||||
- licence texts;
|
||||
- copyright and attribution notices;
|
||||
- provenance: where each piece of material was obtained, and which upstream component versions it
|
||||
covers;
|
||||
- corresponding-source information, such as where upstream source for the exact build is
|
||||
published;
|
||||
- other material that accompanies the dependency's redistribution.
|
||||
- **Generic by construction.** Supplements are data. The mechanism contains no package-specific code
|
||||
and treats every supplement identically, whether it is for libvips or for any other dependency.
|
||||
|
||||
### A supplement matches one exact package, or it fails closed
|
||||
|
||||
A supplement names exactly one redistributed package:
|
||||
|
||||
- its package name;
|
||||
- its exact version;
|
||||
- an integrity value for the package's contents: a digest the packager computes from the package's
|
||||
redistributed files.
|
||||
- **Deterministic.** It covers exactly each redistributed file's normalized relative path within
|
||||
the package and its bytes, combined in an order defined by the paths themselves.
|
||||
- **Host-independent.** It excludes filesystem metadata, timestamps, ownership, permissions, and
|
||||
the order in which files were extracted or listed. The same package contents always give the
|
||||
same digest, wherever and however they were installed.
|
||||
- **Documented.** The implementation defines and documents the exact algorithm, which does not
|
||||
depend on any package manager.
|
||||
|
||||
The packager uses a supplement only when all three match the package it is about to redistribute.
|
||||
|
||||
- **A mismatch on any of the three fails packaging.** This covers, for example, a supplement whose
|
||||
name and version match but whose integrity differs. It never falls back to a nearby version, and
|
||||
it never silently skips the supplement.
|
||||
- **Every match is used.** A supplement that matches is always merged into the notices, including
|
||||
when the package also ships some material of its own.
|
||||
- **Gaps still fail.** If a dependency lacks required material and no exact supplement matches, the
|
||||
packager stops, exactly as it does today.
|
||||
|
||||
### Supplements are immutable build inputs
|
||||
|
||||
- **Immutable.** A supplement is never edited in place to cover a different build. A changed
|
||||
package, a new version, or different contents need a new supplement, reviewed again.
|
||||
- **No network.** Packaging reads supplements from the packager's own inputs and never downloads
|
||||
anything. Assembling a supplement's content is a separate, offline-reviewed curation step done
|
||||
when the supplement is authored, never during packaging.
|
||||
|
||||
### Merging is deterministic and labelled
|
||||
|
||||
The notice generator merges package-provided material and supplement material deterministically. The
|
||||
same packager version and the same inputs still produce a byte-identical `THIRD_PARTY_NOTICES`
|
||||
(ADR 0049).
|
||||
|
||||
- **Order.** Within a dependency's entry, package-provided material comes first, then supplement
|
||||
material.
|
||||
- **Labelling.** Supplement material is marked as supplied by Ekklesia's reviewed supplement, never
|
||||
presented as if it came from the package.
|
||||
- **No other change.** Dependencies are still sorted, line endings normalized, and each dependency
|
||||
keeps its own entry, exactly as before.
|
||||
|
||||
### A supplement never changes a dependency's declared licence
|
||||
|
||||
`THIRD_PARTY_NOTICES` keeps reporting the licence the dependency itself declares.
|
||||
|
||||
- **No override.** A supplement can add material about the components a dependency contains, but it
|
||||
cannot replace, override, or rewrite the dependency's declared licence.
|
||||
- **Consistency check.** A supplement records the declared licence it was reviewed against. If that
|
||||
disagrees with the package's actual declaration, packaging fails.
|
||||
|
||||
### Legal and compliance determinations are not made here
|
||||
|
||||
A supplement can record corresponding-source information, but this ADR does not decide what
|
||||
satisfies any licence's source obligation, and neither does the mechanism:
|
||||
- the mechanism never asserts that a written offer, a source URL, a mirror, or any other arrangement
|
||||
satisfies a licence;
|
||||
- carrying a supplement shows that the reviewed material is present in the package. It does not
|
||||
certify that the package is compliant;
|
||||
- for every redistributed binary under the LGPL, the MPL, or any other licence with source or
|
||||
similar obligations, the determination of whether and how those obligations are met is deferred.
|
||||
It must be made, and verified, separately.
|
||||
|
||||
ADR 0049's release gate still applies. A package that redistributes such a binary does not become a
|
||||
distributable release just because its supplement was merged. It becomes one only once that
|
||||
binary's compliance determination has been made and verified.
|
||||
|
||||
### Scope
|
||||
|
||||
- **Third-party code only.** Supplements apply only to redistributed third-party dependencies,
|
||||
whether bundled or copied into the package. They never apply to the module's own licence or
|
||||
`LICENSE` (ADR 0051), or to incorporated Ekklesia packages.
|
||||
- **No package format change.** `packageFormat` stays **1**. A supplement changes only the content of
|
||||
`THIRD_PARTY_NOTICES`, which is already a listed, digested payload file. No metadata field, archive
|
||||
rule, or verifier contract changes.
|
||||
|
||||
### The motivating case
|
||||
|
||||
`@img/[email protected]` is the first dependency expected to need a supplement. This ADR
|
||||
does not curate it. Its supplement will be assembled, reviewed, and merged separately, together with
|
||||
the native-target packaging work that redistributes it. The source-obligation determination for its
|
||||
LGPL and MPL components remains open, as above.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A dependency whose package omits required material can be redistributed after review, offline and
|
||||
deterministically, instead of making native packaging impossible.
|
||||
- Each supplement is tied to exact package bytes, so a changed or unexpected dependency fails
|
||||
packaging rather than inheriting material reviewed for something else.
|
||||
- Ekklesia takes on reviewing and maintaining supplements. Every version bump of a supplemented
|
||||
dependency needs a new reviewed supplement before packaging works again.
|
||||
- `THIRD_PARTY_NOTICES` can now contain material that did not come from the dependency's package. It
|
||||
is always labelled.
|
||||
- Carrying material and meeting obligations stay separate. Source-availability questions remain open
|
||||
until they are determined per binary.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **Download licence material while packaging.** Rejected: packaging must stay offline,
|
||||
deterministic, and independent of a mutable remote source (ADR 0049, ADR 0051).
|
||||
- **Extend the standard-text list.** Rejected: standard texts only work when the licence needs no
|
||||
project-specific line. Bundled native libraries need per-project copyright notices, and several
|
||||
different licences.
|
||||
- **Match supplements by name and version alone.** Rejected: identical versions can differ in content
|
||||
across registries or rebuilds, and the material was reviewed for specific bytes.
|
||||
- **Fall back to the nearest supplemented version.** Rejected: material reviewed for one build says
|
||||
nothing reliable about another.
|
||||
- **Let a supplement restate a dependency's licence.** Rejected: the declaration belongs to the
|
||||
dependency's publisher. A supplement that could override it could misrepresent what is being
|
||||
redistributed.
|
||||
- **Per-package special cases in the packager** (for example, code that knows about libvips).
|
||||
Rejected: every supplemented dependency would need new code, and review would be split between
|
||||
code and data.
|
||||
- **Treat a recorded source URL or written offer as satisfying copyleft obligations.** Rejected for
|
||||
this ADR: that is a legal determination that has to be verified per binary, not assumed by a build
|
||||
mechanism.
|
||||
- **Supplements provided by module authors.** Not adopted now: the canonical packager uses only
|
||||
supplements reviewed into Ekklesia. Whether module repositories may contribute their own reviewed
|
||||
supplements is deferred.
|
||||
|
||||
## Deferred
|
||||
|
||||
- Implementing the mechanism in the canonical packager: the supplement format and storage location,
|
||||
the integrity algorithm, the matching, the merge, and the tests.
|
||||
- Curating and reviewing the supplement for `@img/[email protected]`.
|
||||
- The legal and compliance determination of source obligations for each redistributed binary, and
|
||||
how a determination is recorded.
|
||||
- Whether the packager should refuse, or flag, packages whose supplements note an open determination.
|
||||
- Supplements provided by module repositories.
|
||||
- Recording which supplements were used in `ekklesia.package.json`, should that ever be needed.
|
||||
@@ -0,0 +1,255 @@
|
||||
# 0053: A module builds outside the monorepo, from Ekklesia packages published over the npm registry protocol
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-10-07
|
||||
- Superseded in part by: [0054](0054-public-package-reads-before-launch.md) — only where this ADR
|
||||
describes the package phase before launch as private, or requires authenticated package reads.
|
||||
- Extends: [0049](0049-module-package-v1.md). This is the step ADR 0049 deferred: "publishing the
|
||||
Ekklesia SDK packages for module builds outside a platform workspace". A registry is used only to
|
||||
build a module. Installing one stays registry-free: an installed package is self-contained, as ADR
|
||||
0049 decided.
|
||||
- Related: [ADR 0002](0002-pnpm-workspace-and-typescript.md),
|
||||
[ADR 0006](0006-versioned-capabilities-and-compiled-module-runtime.md),
|
||||
[ADR 0021](0021-modules-as-hostable-products.md),
|
||||
[ADR 0023](0023-module-ui-build-pipeline.md),
|
||||
[ADR 0039](0039-shared-design-tokens-and-elements.md),
|
||||
[ADR 0047](0047-module-ci-harness.md),
|
||||
[ADR 0048](0048-generic-image-and-installed-module-storage.md),
|
||||
[ADR 0050](0050-module-database-abi-v1.md),
|
||||
[ADR 0051](0051-module-package-license-material.md),
|
||||
[ADR 0052](0052-reviewed-license-supplements.md)
|
||||
|
||||
## Context
|
||||
|
||||
Modules are independently versioned products in their own repositories (ADR 0021). Yet no module can
|
||||
be built outside Ekklesia's monorepo today. Signage's continuous integration clones the platform at
|
||||
a pinned commit, places Signage under `modules/signage`, and installs and builds inside that
|
||||
workspace, because its Ekklesia dependencies exist only as `workspace:*` packages.
|
||||
|
||||
An investigation of the current packages found the following.
|
||||
|
||||
- **One coordinated version.** Every `@ekklesia/*` package, and the platform, is version 0.1.2. The
|
||||
release process already bumps them together. The platform reports its Module SDK version, which
|
||||
manifests' `compatibility.moduleSdk` ranges are checked against (ADR 0006), by reading
|
||||
`@ekklesia/module-sdk`'s own package version.
|
||||
- **The tarballs already stand alone.** `pnpm pack` rewrites `workspace:*` ranges to exact versions,
|
||||
and each tarball carries only its built `dist`, declarations, and `LICENSE`. But every package's
|
||||
`prepack` runs a command at the monorepo root, and `@ekklesia/typescript-config` is marked
|
||||
`private`.
|
||||
- **The SDK exposes everything.** `@ekklesia/module-sdk` re-exports the whole `@ekklesia/contracts`
|
||||
barrel. That includes contracts no module needs, such as authorization and identity, and the
|
||||
legacy Drizzle `ModuleDatabase` type. `@ekklesia/contracts` depends on `drizzle-orm` for that type,
|
||||
so every module install pulls Drizzle in.
|
||||
- Neither the module template nor Signage imports `@ekklesia/contracts` directly.
|
||||
- **Frameworks are declared twice.** `@ekklesia/ui` lists `react`, and `@ekklesia/module-sdk` lists
|
||||
`drizzle-orm`, as both a dependency and a peer dependency. That invites a second copy of a runtime
|
||||
that must be single within one module bundle.
|
||||
- **The canonical packager assumes the monorepo**, in two ways:
|
||||
- it finds the module's Node.js range only through an enclosing `pnpm-workspace.yaml`;
|
||||
- it decides that an Ekklesia package is the platform's own, not third-party, because its real
|
||||
path does not contain `node_modules`.
|
||||
|
||||
An `@ekklesia/*` package installed from a registry would therefore be treated as third-party.
|
||||
Packaging would omit it from `incorporates` and skip the Module SDK compatibility check.
|
||||
- **The registry and its hosts.**
|
||||
- The current private host is a Gitea 1.27.2 server. Its npm registry is enabled for the
|
||||
organization, and that endpoint and the repositories are currently readable anonymously.
|
||||
- pnpm leaves a tarball URL out of its lockfile only when the URL has the canonical
|
||||
`<registry>/<name>/-/<name>-<version>.tgz` form. Gitea advertises another form, so lockfiles
|
||||
produced against Gitea name the Gitea host.
|
||||
- The integrity hashes in those lockfiles are independent of the host.
|
||||
|
||||
## Decision
|
||||
|
||||
### The npm registry protocol, not a provider
|
||||
|
||||
Ekklesia's module-development packages are distributed over the **standard npm registry protocol**,
|
||||
and modules install them with ordinary pnpm or npm commands.
|
||||
|
||||
- **No provider is part of the architecture.** Not Gitea, GitHub, npmjs, or any other. A module
|
||||
repository depends on the protocol and on a scope-to-registry mapping in normal npm/pnpm
|
||||
configuration.
|
||||
- **Gitea may host the private phase.** It is a host for this phase, not a permanent decision.
|
||||
- **No host-neutral lockfile or Ekklesia proxy is required.** Moving to another registry may need a
|
||||
one-time change of a module's registry configuration and a regeneration of its lockfile. The
|
||||
integrity hashes keep verifying the package content across such a move.
|
||||
|
||||
A module repository's registry configuration is, for example:
|
||||
|
||||
```ini
|
||||
# .npmrc, committed: where the @ekklesia scope lives, and nothing secret.
|
||||
@ekklesia:registry=https://<registry endpoint>/
|
||||
```
|
||||
|
||||
```ini
|
||||
# User or CI configuration, never committed: the credential, from the environment.
|
||||
//<registry endpoint>/:_authToken=${EKKLESIA_PACKAGES_TOKEN}
|
||||
```
|
||||
|
||||
### The public package set
|
||||
|
||||
Only packages a module author needs are published. Nothing is published merely because it exists in
|
||||
the workspace.
|
||||
|
||||
| Category | Packages |
|
||||
| --- | --- |
|
||||
| Author API | `@ekklesia/module-sdk`, `@ekklesia/module-database`, `@ekklesia/module-ui-sdk`, `@ekklesia/design-tokens` |
|
||||
| Tooling | `@ekklesia/module-ui-build`, `@ekklesia/module-pack`, `@ekklesia/typescript-config` |
|
||||
| Pre-1.0 component API | `@ekklesia/ui`: publishable, explicitly unstable before 1.0 |
|
||||
| Implementation dependency only | `@ekklesia/contracts`: may be published because the packages above depend on it, but it is **not** a supported direct module-author API |
|
||||
| Never published | `@ekklesia/core`, the applications, `@ekklesia/module-ui-host`, `@ekklesia/module-template`, and every other platform internal |
|
||||
|
||||
### A curated SDK surface
|
||||
|
||||
Before the first external publication, the author-facing SDK is narrowed.
|
||||
|
||||
- **A curated API.** `@ekklesia/module-sdk` exposes what a module author needs, and no longer
|
||||
re-exports the complete `@ekklesia/contracts` barrel.
|
||||
- **No forced Drizzle.** The stable SDK root does not make Drizzle a dependency of modules that do
|
||||
not use it.
|
||||
- **No legacy database contract.** The legacy Drizzle database contract (ADR 0016's facade type) is
|
||||
not part of the standalone author-facing surface. Core still serves the legacy facade for its
|
||||
deprecation window (ADR 0050). This decision only keeps its type out of what new standalone
|
||||
modules build against.
|
||||
- **Drizzle support lives in one place.** Drizzle-specific helpers belong to the
|
||||
`@ekklesia/module-database` Drizzle adapter (ADR 0050).
|
||||
|
||||
### Dependency rules
|
||||
|
||||
These rules apply to the published packages:
|
||||
|
||||
- **React and ReactDOM** are peer dependencies of the public UI packages, plus development
|
||||
dependencies for those packages' own builds and tests. This does not change ADR 0023: a module UI
|
||||
artifact still bundles React. The peer only ensures there is one React in that bundle, the
|
||||
module's own.
|
||||
- **Drizzle** is an optional peer dependency only where the Drizzle adapter needs it.
|
||||
- **Real implementation dependencies,** at run time or build time, remain normal dependencies.
|
||||
|
||||
These rules apply to a module repository:
|
||||
|
||||
- **Runtime and module-code packages** go in `dependencies`: the Author API packages, `@ekklesia/ui`,
|
||||
React, and the module's ORM if it uses one.
|
||||
- **Build tooling and configuration** go in `devDependencies`: `@ekklesia/module-ui-build`,
|
||||
`@ekklesia/module-pack`, `@ekklesia/typescript-config`, and type packages.
|
||||
|
||||
### Versioning
|
||||
|
||||
- **One coordinated version during 0.x.** Every public Ekklesia module-development package uses one
|
||||
release version, equal to the platform release version. The version the platform reports for
|
||||
`moduleSdk` compatibility is therefore the version of the packages a module was built with.
|
||||
- **No mixed trains.** Within a published train, Ekklesia-owned dependencies name the coordinated,
|
||||
tested version, never a version from another train.
|
||||
- **Ranges in source, exact artifacts in lockfiles.** A module may declare compatible ranges in its
|
||||
source `package.json`; its lockfile pins exact artifacts with their integrity hashes.
|
||||
- **`@ekklesia/module-pack` is pinned exactly.** A module repository pins it in its source
|
||||
`package.json`, not only in its lockfile, because its version affects the package it produces.
|
||||
- **Independent package versions are deferred.**
|
||||
|
||||
### Standalone packaging
|
||||
|
||||
- **No monorepo checkout.** A standalone module builds and packages from its own repository and its
|
||||
installed dependencies.
|
||||
- **Its own Node.js range.** The module declares its own Node.js compatibility, preferably in
|
||||
`engines.node`, and the canonical packager reads it from there or from an explicit option. It
|
||||
never requires an enclosing `pnpm-workspace.yaml`.
|
||||
- **Ekklesia packages recognized by identity.** The canonical packager recognizes Ekklesia-owned
|
||||
installed packages by explicit package identity and metadata, never by workspace path. So
|
||||
`incorporates` and the Module SDK compatibility check (ADR 0049) work the same whether a package
|
||||
came from the monorepo or from a registry. Ekklesia packages are never reported as third-party
|
||||
software (ADR 0051).
|
||||
- **No monorepo-root commands at install time.** Publishing workflows for the public packages run
|
||||
their build in the platform's release pipeline. Nothing in a published package runs a
|
||||
monorepo-root command when a consumer installs or packs it.
|
||||
|
||||
### Supply chain
|
||||
|
||||
- **Immutable versions.** A version is never republished with other content, and never withdrawn to
|
||||
be reused.
|
||||
- **Lockfiles.** pnpm lockfile integrity is part of the reproducibility model: modules install with a
|
||||
frozen lockfile.
|
||||
- **Release CI proves the tarballs before publication.** It must:
|
||||
1. build and pack the public package set;
|
||||
2. inspect every tarball: only the intended contents, a `LICENSE`, and no `workspace:` range;
|
||||
3. install those tarballs into a clean standalone module fixture, outside any monorepo;
|
||||
4. prove there that the fixture builds, packages with the canonical packager, and verifies.
|
||||
|
||||
Only then are the versions published.
|
||||
- **Signing and provenance are deferred.**
|
||||
|
||||
### Registry access and the private phase
|
||||
|
||||
- **No secrets in module repositories.** Registry configuration for the scope is normal npm/pnpm
|
||||
configuration.
|
||||
- **Credentials from the environment.** Read and publish credentials come from environment or user
|
||||
configuration: a read credential in module CI where reads need one, and a publish credential only
|
||||
in Ekklesia's release pipeline.
|
||||
- **No permanent provider.**
|
||||
- **Verify access before the first publication.** Check the chosen registry's actual private and
|
||||
read-access behaviour first. Ekklesia's packages must not be published unintentionally to a
|
||||
registry that serves them anonymously while Ekklesia is not public.
|
||||
|
||||
### The release workflow
|
||||
|
||||
During 0.x, public package releases form a **coordinated SDK train** associated with platform
|
||||
releases:
|
||||
|
||||
1. build;
|
||||
2. pack;
|
||||
3. standalone tarball smoke (see [Supply chain](#supply-chain));
|
||||
4. publish immutable versions;
|
||||
5. downstream module CI installs and tests those versions.
|
||||
|
||||
### Signage
|
||||
|
||||
The intended result is a normal standalone Signage repository:
|
||||
- versioned `@ekklesia/*` dependencies;
|
||||
- its own lockfile;
|
||||
- continuous integration that installs, builds, packages, and verifies on its own.
|
||||
|
||||
That CI must not clone Ekklesia or place Signage under `modules/`. The migration itself is a separate
|
||||
change.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A module repository is an ordinary pnpm project. Clone it, install, build, and package it with the
|
||||
canonical packager, with no Ekklesia checkout.
|
||||
- Module authors see a smaller, deliberate API, and the platform can change its internal contracts
|
||||
without breaking them.
|
||||
- One coordinated version keeps compatibility simple: SDK version X is what platform version X
|
||||
reports and accepts. The cost is republishing unchanged packages with each platform release.
|
||||
- A registry move costs each module a configuration change and a lockfile regeneration, but no
|
||||
source change. Package content stays verifiable by integrity hash throughout.
|
||||
- Before the first publication, the SDK surface must be curated and the packager's two monorepo
|
||||
assumptions removed. Both are breaking for code that relied on them.
|
||||
- ADR 0047, still proposed, assembles a module into the platform workspace to test it. Before it is
|
||||
accepted it must instead consume published packages and canonical module packages, consistent with
|
||||
this decision and with ADR 0048.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **Git URL, tag, or commit dependencies.** Rejected: they tie modules to one Git host, need a build
|
||||
on every install because `dist` is not committed, and cannot express version ranges.
|
||||
- **Release-asset tarball URLs.** Rejected: each dependency becomes a host-specific URL, and ranges
|
||||
cannot be resolved.
|
||||
- **OCI artifacts for packages.** Rejected: npm and pnpm cannot install from them. (A harness image
|
||||
is a different matter; see ADR 0047.)
|
||||
- **Vendoring Ekklesia packages into each module repository.** Rejected: the copies drift, updates
|
||||
are manual, and there would be no single canonical SDK.
|
||||
- **Requiring host-neutral lockfiles, or an Ekklesia registry proxy that rewrites tarball URLs.** Not
|
||||
required: a one-time lockfile regeneration on a registry move is an acceptable cost, and integrity
|
||||
hashes already keep content verifiable. Ekklesia may still choose a stable endpoint later.
|
||||
- **Publishing `@ekklesia/contracts` as a direct author API.** Rejected: it holds platform-wide
|
||||
contracts. Modules get a curated surface through the SDK packages.
|
||||
- **Externalizing React from module UI bundles to share one runtime.** Rejected again: ADR 0023's
|
||||
reasoning stands, and peers only prevent duplicates within one bundle.
|
||||
- **Independent versions per package now.** Deferred: during 0.x a coordinated train is simpler for
|
||||
module authors and matches how the platform reports its SDK version.
|
||||
|
||||
## Deferred
|
||||
|
||||
- Provisioning a registry, and its credentials.
|
||||
- Public publication on npmjs or any other public registry.
|
||||
- Package signing and provenance.
|
||||
- Independent package versioning.
|
||||
- Dashboard and UI redesign.
|
||||
- The Signage migration itself.
|
||||
@@ -0,0 +1,85 @@
|
||||
# 0054: Ekklesia's module-development packages are publicly readable before launch
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-10-08
|
||||
- Supersedes (partial): [0053](0053-standalone-module-development-packages.md) — only where it
|
||||
describes the package phase before launch as private: "Gitea may host the private phase", the
|
||||
read credential for module CI in "Registry access and the private phase", and its requirement
|
||||
that packages not be served anonymously while Ekklesia is not public. Every other part of that ADR
|
||||
remains accepted and unchanged: the npm registry protocol, the public package set and its
|
||||
boundaries, the curated SDK, the dependency rules, the coordinated package train, exact release
|
||||
artifacts, standalone module builds, provider portability, supply chain and immutability, and the
|
||||
credential handling for publishing.
|
||||
- Related: [ADR 0049](0049-module-package-v1.md)
|
||||
|
||||
## Context
|
||||
|
||||
ADR 0053 decided how Ekklesia's module-development packages (`@ekklesia/*`) are built, released, and
|
||||
distributed over the npm registry protocol. It assumed that the phase before Ekklesia's public
|
||||
launch would be private: module CI would carry a read credential where reads needed one, and the
|
||||
first publication had to make sure the registry would not serve the packages anonymously.
|
||||
|
||||
Checking the chosen host before the first publication showed what keeping the packages private would
|
||||
take:
|
||||
|
||||
- Gitea 1.27.2 decides package read access by the package **owner's** visibility, never a
|
||||
repository's. The `River_of_Glory_Church` owner is a public organization, so its packages are
|
||||
readable anonymously.
|
||||
- That organization's repositories, Ekklesia's source among them, are already publicly readable.
|
||||
- The instance allows self-registration, so "limited" visibility, readable by any signed-in user,
|
||||
is readable by anyone who creates an account.
|
||||
- Private packages would therefore need a separate private package owner, plus reader accounts and
|
||||
read tokens for every module repository, every module's CI, and every developer.
|
||||
|
||||
The project owner has decided that Ekklesia is not secret. Its source is public; the project is
|
||||
simply not being launched or promoted yet. Hiding the packages built from that public source would
|
||||
protect nothing, and would still cost every module author an account and a token.
|
||||
|
||||
## Decision
|
||||
|
||||
1. **Ekklesia's module-development packages may be anonymously readable before the project's public
|
||||
launch.** This covers the public package set of ADR 0053: search, metadata, and download.
|
||||
2. **Readability is not a launch, and promises nothing more.** Public readability does not mean:
|
||||
- that Ekklesia has been publicly launched;
|
||||
- that the APIs are stable beyond the project's pre-1.0 SemVer policy;
|
||||
- that third-party production use is supported;
|
||||
- that a marketplace or public module ecosystem exists.
|
||||
3. **Reading needs no authentication in this phase.** Installing `@ekklesia/*` needs no account,
|
||||
reader account, read token, or authenticated install workflow. A module repository configures only
|
||||
the scope's registry.
|
||||
4. **Publication stays authenticated and tightly controlled.** Only Ekklesia's release process
|
||||
publishes, with one dedicated publisher credential that holds nothing beyond package write
|
||||
access, used only by that process, as ADR 0053's credential handling for publishing already
|
||||
requires.
|
||||
5. **Published versions stay immutable by Ekklesia policy.** A published version is never deleted or
|
||||
republished with other bytes, whatever the registry technically allows.
|
||||
6. **The current Gitea owner is an acceptable host.** The `River_of_Glory_Church` package namespace
|
||||
on the current Gitea may host this phase, as long as publishing permission stays restricted to
|
||||
the publisher. No private package organization is needed.
|
||||
7. **A public launch is a separate decision.** Launching or promoting Ekklesia publicly, and anything
|
||||
that changes for its packages then, is a product and release decision this ADR does not make.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Module authors and module CI need no Ekklesia credential.** The committed `.npmrc` scope mapping
|
||||
is the whole registry configuration. ADR 0053's example of a read credential in user or CI
|
||||
configuration is no longer needed in this phase.
|
||||
- **ADR 0053's pre-publication access check changes meaning.** It now confirms that anonymous reads
|
||||
work as intended and that publishing is refused without the publisher's permission, rather than
|
||||
that reads are private. The disposable-registry suite proves both, and the publication tool can
|
||||
require an anonymous download to match each published artifact.
|
||||
- **Restricting publishing is now the whole access boundary**, so the publisher credential's scope,
|
||||
where it is held, and who can reach it matter all the more.
|
||||
- **Making the packages private again would be a new decision.** Package names, versions, and
|
||||
contents already published stay public, because a published version is never deleted.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **A private package organization on the same Gitea.** Rejected: it hides nothing that is not
|
||||
already public in the source, and it requires reader accounts and tokens for every module
|
||||
repository, its CI, and every developer.
|
||||
- **"Limited" package visibility.** Rejected: with self-registration open, anyone can read anyway,
|
||||
while the configuration suggests otherwise.
|
||||
- **Requiring sign-in for the whole instance.** Rejected: it would also hide the public
|
||||
repositories.
|
||||
- **Another registry for a private phase.** Unnecessary once reads are public.
|
||||
@@ -58,3 +58,8 @@ ADRs capture durable choices that affect more than one package or phase.
|
||||
- [0047: Every module proves itself inside Ekklesia, with one harness the platform publishes](0047-module-ci-harness.md) — Proposed
|
||||
- [0048: Ekklesia ships one generic image, and installed modules live outside it](0048-generic-image-and-installed-module-storage.md) — Accepted
|
||||
- [0049: A module release is one immutable, self-contained package: Ekklesia Module Package v1](0049-module-package-v1.md) — Accepted
|
||||
- [0050: Module Database ABI v1: modules and Core exchange SQL, never ORM objects](0050-module-database-abi-v1.md) — Accepted
|
||||
- [0051: Module packages declare their own license and carry its text](0051-module-package-license-material.md) — Accepted
|
||||
- [0052: Reviewed license supplements cover redistributed dependencies whose packages omit required material](0052-reviewed-license-supplements.md) — Proposed
|
||||
- [0053: A module builds outside the monorepo, from Ekklesia packages published over the npm registry protocol](0053-standalone-module-development-packages.md) — Accepted
|
||||
- [0054: Ekklesia's module-development packages are publicly readable before launch](0054-public-package-reads-before-launch.md) — Accepted
|
||||
|
||||
@@ -609,7 +609,7 @@ a sandbox: it is `open` precisely because module code is trusted, same-realm cod
|
||||
|
||||
[ADR 0041](decisions/0041-module-browser-contributions.md) lets one module contribute a browser
|
||||
renderer to a capability another module provides — the browser half of the capability system Core has
|
||||
had since ADR 0006. `manifestVersion: 7` declares a namespaced id, its own version, a target
|
||||
had since ADR 0006. `manifestVersion: 8` declares a namespaced id, its own version, a target
|
||||
capability, a compatible range, and one artifact. That is the complete list: there is no payload
|
||||
schema, slot, ordering, or rendering hint anywhere in the platform, so Core cannot learn what a
|
||||
"slide" is because the declaration cannot express it. Discovery resolves the artifact through the
|
||||
@@ -679,11 +679,31 @@ Module Package v1, a deterministic `.tar.gz` with its SHA-256.
|
||||
- **The packager.** `@ekklesia/module-pack` bundles the module's backend with every pure-JavaScript
|
||||
dependency, the SDK, contracts, and Drizzle included. It copies the built browser artifacts and
|
||||
migrations, generates the runtime `package.json`, `ekklesia.package.json` with every file's digest,
|
||||
and `THIRD_PARTY_NOTICES`, and writes the archive.
|
||||
copies the explicitly supplied module `LICENSE` bytes, generates `THIRD_PARTY_NOTICES` for bundled
|
||||
third parties, and writes the archive. The source module's string SPDX expression is required and
|
||||
becomes the package metadata's authoritative `license`; the packager neither infers nor downloads
|
||||
license material.
|
||||
- **Native target (`linux-x64-glibc`).** Native or platform-specific dependencies stay outside
|
||||
the bundle. Each one is copied with exactly its production runtime closure, as real files flat
|
||||
under the package's `node_modules/`, and only the platform packages that match the target are
|
||||
included. The package's `runtime` records the Node-API version and minimum glibc version, both
|
||||
read from the native binaries themselves.
|
||||
- **License supplements.** Where a redistributed dependency omits required licence material,
|
||||
reviewed supplements checked in with the packager add it
|
||||
([ADR 0052](decisions/0052-reviewed-license-supplements.md)). Each supplement matches one exact
|
||||
package name, version, and content digest; anything else fails closed.
|
||||
- **The verifier.** Core's `verifyModulePackage` reads the archive in memory and refuses links,
|
||||
special files, and escaping paths. It checks every digest and identity, the target and runtime,
|
||||
and the transitional Drizzle agreement, runs the platform's own module and migration validation,
|
||||
and loads the backend once in a short-lived process with an empty environment. Only then does it
|
||||
extract anything.
|
||||
- **What exists today.** Only the `any` target. Drizzle agreement is recorded as transitional, not a
|
||||
database ABI.
|
||||
runs the platform's own module, schema, and migration validation, and loads the backend once in a
|
||||
short-lived process with an empty environment. Only then does it extract anything.
|
||||
- **A native package's host checks.** It is accepted only on a host whose operating system, CPU,
|
||||
C library family and version, and Node-API version Node.js itself reports as compatible.
|
||||
- **ELF files.** Every ELF file in it must be built for the target's machine.
|
||||
- **What an `any` package may not hold:** `node_modules`, addons, and ELF files.
|
||||
- **What exists today.** The `any` and `linux-x64-glibc` targets. No other operating system or CPU
|
||||
target exists yet. A manifest-v8 ABI module carries no
|
||||
`transitional.databaseRuntime`; Drizzle may be bundled as its private implementation detail, and
|
||||
Core validates plain schema metadata without comparing ORM versions. A version 8 module that
|
||||
omits `database` remains on the temporary legacy facade and keeps ADR 0049's exact transitional
|
||||
Drizzle agreement. The canonical template now demonstrates ABI v1. The legacy facade still
|
||||
exists.
|
||||
|
||||
@@ -136,11 +136,16 @@ These rules map platform ownership to filesystem and package boundaries. The exe
|
||||
declared by the consumer.
|
||||
- Code a workspace package ships (its `src/` and `bin/`, excluding tests and declaration files)
|
||||
declares every runtime import in `dependencies` or `optionalDependencies`. A peer declaration may
|
||||
stay beside it, but never replaces it: a workspace package is linked into its consumers as a
|
||||
directory, so its imports resolve from its own `node_modules`, where a consumer never satisfies a
|
||||
peer, and a production install (`pnpm install --prod`, as the production image runs) omits
|
||||
development dependencies. Type-only imports are exempt; a `.tsx`/`.jsx` file counts as importing
|
||||
`react`, because the automatic JSX runtime imports `react/jsx-runtime` implicitly
|
||||
stay beside it, but ordinarily never replaces it: a workspace package is linked into its consumers
|
||||
as a directory, so its imports resolve from its own `node_modules`, where a consumer never
|
||||
satisfies a peer, and a production install (`pnpm install --prod`, as the production image runs)
|
||||
omits development dependencies. There are two narrow exceptions, each for one package and one
|
||||
peer. `@ekklesia/module-database`'s `drizzle-orm` peer: modules choose that compatible runtime and
|
||||
the Module Package v1 bundler incorporates it into their self-contained backend; Core never loads
|
||||
the adapter package. And `@ekklesia/ui`'s `react` peer (ADR 0053): the shared elements are only
|
||||
ever bundled into browser artifacts, and the consumer's own React must be the one in the bundle;
|
||||
no production Node.js process loads them. Type-only imports are exempt; a `.tsx`/`.jsx` file counts as importing `react`,
|
||||
because the automatic JSX runtime imports `react/jsx-runtime` implicitly
|
||||
(`runtime-imports-are-production-dependencies`).
|
||||
- Workspace aliases are forbidden because they obscure package ownership in the dependency graph.
|
||||
- Workspace package names are unique and shared packages declare a recognized architectural role.
|
||||
|
||||
@@ -1403,7 +1403,8 @@ stays declarative and database-free. See
|
||||
contract; this section covers only how `apps/api` composes and wires it.
|
||||
|
||||
- **`ModuleDatabase`.** A restricted, typed view over `NodePgDatabase`
|
||||
(`packages/contracts/src/module-database.ts`), exposing exactly `select`, `selectDistinct`,
|
||||
(`core/src/modules/database.ts`, Core's own type: no published package references Drizzle, per
|
||||
ADR 0053), exposing exactly `select`, `selectDistinct`,
|
||||
`selectDistinctOn`, `insert`, `update`, `delete`, `execute`, and `transaction`, each preserving
|
||||
Drizzle's own table-aware argument and result inference. It exposes no `$client`, no internal
|
||||
session/dialect state, no schema-bound relational `query` builder, and no connection string,
|
||||
|
||||
@@ -138,7 +138,7 @@ enforces those permissions while the module is disabled, because it mounts no ro
|
||||
## Module permission composition
|
||||
|
||||
A module manifest's `permissions` array (introduced in `manifestVersion: 4`; the current strict
|
||||
manifest is version 7 — see
|
||||
manifest is version 8 — see
|
||||
[Module development](../module-development/README.md#canonical-manifest)) declares permissions in
|
||||
the exact same `PermissionDeclaration` shape — but declaring one there only proves it is
|
||||
well-formed and correctly namespaced under the module's own id; it does not, by itself, register
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
# Releases and the public package train
|
||||
|
||||
An Ekklesia platform release is one version. The platform image, the private workspace packages,
|
||||
and the public `@ekklesia/*` module-development packages all carry it. This page covers the order a
|
||||
release happens in and the public package train
|
||||
([ADR 0053](../architecture/decisions/0053-standalone-module-development-packages.md)): how its
|
||||
artifacts are produced, what proves them, and how exactly those artifacts are published.
|
||||
|
||||
> **The packages are publicly readable, on purpose**
|
||||
> ([ADR 0054](../architecture/decisions/0054-public-package-reads-before-launch.md)). Anyone can
|
||||
> search, read, and install `@ekklesia/*` from the registry with no account and no token, like any
|
||||
> public npm package. That is not a public launch: Ekklesia's source is already publicly readable,
|
||||
> and the project is simply not promoted yet. Only **publishing** is restricted, to one manual,
|
||||
> authenticated workflow. Before 1.0, the APIs may change between versions under the project's
|
||||
> SemVer policy: a 0.x minor version may break them.
|
||||
|
||||
## Where each step sits
|
||||
|
||||
```text
|
||||
version set ─▶ build ─▶ test ─▶ pack the public package train ─▶ standalone consumer proof
|
||||
│
|
||||
tag and release (maintainer) ◀─ publish the exact train (manual) ◀───┘
|
||||
```
|
||||
|
||||
1. **Version set.** A `chore(release): prepare X` pull request sets the release version: the root
|
||||
`package.json`, every platform workspace package (the module template versions as a module, not
|
||||
with the platform), the image build example in `deploy/README.md`, and the standalone fixture's
|
||||
exact `@ekklesia/module-pack` pin and `@ekklesia/*` ranges
|
||||
(`tests/fixtures/standalone-module/package.json`). The tag must point at a commit that carries
|
||||
the version it names.
|
||||
|
||||
A minor version (0.x → 0.(x+1)) is a breaking release before 1.0, so manifests that declare
|
||||
`compatibility.platform` and `compatibility.moduleSdk` as `^0.x.0` no longer match it. In the
|
||||
same pull request, move to the new range every module built for this release (the template, the
|
||||
standalone fixture, the native proof module), the test fixtures that model a module of the
|
||||
current release or the current platform, and the module-development guide's examples. A patch
|
||||
version changes none of these.
|
||||
2. **Build and test.** `pnpm verify`, the integration suite, and the deploy smoke, as for any change.
|
||||
3. **Pack the public package train.** `pnpm packages:release` (below).
|
||||
4. **Standalone consumer proof.** A module outside the repository consumes exactly those
|
||||
artifacts (below).
|
||||
5. **Publish the exact train.** By hand, with the `Publish packages` workflow on the version-set
|
||||
commit on `main` ([Publishing the train](#publishing-the-train)). It publishes the artifacts its
|
||||
own run's gate (steps 3 and 4) proved, and nothing else.
|
||||
6. **Tag and release.** Unchanged: the maintainer tags the merge commit of the version-set pull
|
||||
request. Nothing here creates, moves, or requires a tag.
|
||||
|
||||
Steps 2 to 4 run in CI on every pull request and on `main`, and CI also publishes each proven train
|
||||
to a disposable registry (`package-publication`), never the real one. Step 5 runs only when a
|
||||
maintainer starts it.
|
||||
|
||||
## The public package train
|
||||
|
||||
| Package | Role |
|
||||
| --- | --- |
|
||||
| `@ekklesia/module-sdk`, `@ekklesia/module-database`, `@ekklesia/module-ui-sdk`, `@ekklesia/design-tokens` | author API |
|
||||
| `@ekklesia/module-ui-build`, `@ekklesia/module-pack`, `@ekklesia/typescript-config` | tooling |
|
||||
| `@ekklesia/ui` | publishable, pre-1.0 component API |
|
||||
| `@ekklesia/contracts` | implementation dependency of the packages above: published only because they need it, never a supported direct dependency of a module |
|
||||
|
||||
The list lives in `scripts/package-release.ts` (`PUBLIC_PACKAGES`). Every other workspace package is
|
||||
`private`, and the release builder refuses to run if a package outside the list is publishable or
|
||||
carries the `module-development` distribution marker.
|
||||
|
||||
- **One version.** Every package in the train has the platform release version.
|
||||
- **One train.** Every `@ekklesia/*` dependency in a packed manifest names a package of the same
|
||||
train at exactly that version. `workspace:` ranges become those exact versions when packed.
|
||||
- **No independent versions.** A package cannot be released on its own.
|
||||
|
||||
## Producing the release artifacts
|
||||
|
||||
```bash
|
||||
pnpm packages:release # writes .package-release/ekklesia-packages-<version>/
|
||||
```
|
||||
|
||||
`scripts/package-release.ts`:
|
||||
|
||||
1. **Refuses a modified source tree, before it builds anything.** A release is built from a
|
||||
commit. A tree is clean when `git status` shows no change to a tracked file and no untracked file
|
||||
that Git does not ignore. Ignored files are build output and package-manager state:
|
||||
`node_modules`, `dist`, `.turbo`, and pnpm's `.pnpm-store`, which pnpm creates at the checkout's
|
||||
root when the checkout is its own mount (a CI runner's workspace volume). The failure lists every
|
||||
change. `--allow-modified` exists for local development only; its manifest then says
|
||||
`"modified": true`, and such a release must never be published.
|
||||
2. **Checks the release set and versions** in the workspace.
|
||||
3. **Rebuilds from clean output.** It removes each public package's `dist/` and rebuilds without the
|
||||
build cache, so output from a deleted source file can never ship. It then checks the source tree
|
||||
again: a build that changed a tracked file, or wrote an untracked one Git does not ignore, fails
|
||||
the release, because what is packed must be exactly the commit.
|
||||
4. **Packs each package with `pnpm pack`.** Pack-only pnpm hooks (`scripts/lib/pack-hooks.cjs`)
|
||||
apply, never the workspace's install settings ([Reproducibility](#reproducibility)).
|
||||
5. **Inspects every tarball.** It checks:
|
||||
- the expected name, version, and file name;
|
||||
- a `LICENSE`, and only the `files` the manifest declares, each built file with the source it
|
||||
came from, and no source, test, or configuration file;
|
||||
- no `workspace:` range, no `devDependencies`, no `publishConfig`, and no install-time lifecycle
|
||||
script;
|
||||
- only public `@ekklesia/*` dependencies, at the train's version;
|
||||
- the distribution marker;
|
||||
- no local path (the checkout, the home directory, or the temporary directory) in any file.
|
||||
6. **Checks the set.** The release must hold exactly the expected tarballs: none missing, none extra.
|
||||
7. **Writes the release directory only when everything passed,** in one step. It refuses to
|
||||
overwrite one that exists.
|
||||
|
||||
```text
|
||||
ekklesia-packages-<version>/
|
||||
├── ekklesia-contracts-<version>.tgz
|
||||
├── ekklesia-design-tokens-<version>.tgz
|
||||
├── … one .tgz per package in the train
|
||||
├── SHA256SUMS `sha256sum -c SHA256SUMS` compatible
|
||||
└── ekklesia-package-train.json the release manifest
|
||||
```
|
||||
|
||||
### The release manifest
|
||||
|
||||
`ekklesia-package-train.json` describes the train by content. It is provider-neutral: it names no
|
||||
registry and is not a registry catalog.
|
||||
|
||||
```json
|
||||
{
|
||||
"packageTrain": 1,
|
||||
"version": "0.2.0",
|
||||
"source": { "modified": false, "revision": "<full commit id>" },
|
||||
"packages": [
|
||||
{
|
||||
"file": "ekklesia-contracts-0.2.0.tgz",
|
||||
"integrity": "sha512-<base64>",
|
||||
"name": "@ekklesia/contracts",
|
||||
"role": "implementation",
|
||||
"sha256": "<hex>",
|
||||
"size": 87379,
|
||||
"version": "0.2.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`integrity` is the npm `dist.integrity` form: a registry computes the same value from the same
|
||||
bytes, and a module's lockfile records it. The packages are sorted by name, and the file's bytes
|
||||
depend only on the commit.
|
||||
|
||||
## Proving the artifacts
|
||||
|
||||
```bash
|
||||
pnpm test:package-release # scripts/test-package-release.sh
|
||||
```
|
||||
|
||||
1. **Builds the train** from this checkout. Nothing is built before it.
|
||||
2. **Builds it again** from a second worktree of the same commit at another absolute path, and
|
||||
requires both release directories to be byte-identical: every tarball, `SHA256SUMS`, and the
|
||||
manifest.
|
||||
3. **Builds Core's verifier**, then **runs the standalone consumer proof** (`scripts/test-standalone-module.sh --release <dir>`) on
|
||||
that release:
|
||||
- **The release is checked first.** Every artifact must match the manifest's size, SHA-256, and
|
||||
integrity, and the release must not be built from a modified tree.
|
||||
- **The fixture module installs from those tarballs alone.** It is
|
||||
`tests/fixtures/standalone-module`, copied outside the repository. Every `@ekklesia/*`
|
||||
package, direct or transitive, resolves to its release tarball. There is no workspace link and
|
||||
no Ekklesia source checkout. The install runs offline: third-party packages come from the
|
||||
workspace install's own pnpm store, named explicitly because pnpm otherwise picks a store per
|
||||
file system, so no registry is involved at all.
|
||||
- **The lockfile matches the release.** It must record each artifact's own integrity. The module
|
||||
then reinstalls from the lockfile alone with `--frozen-lockfile`.
|
||||
- **The install has the right shape.** There is no `drizzle-orm`, and exactly one `react`, which
|
||||
`@ekklesia/ui` resolves to as well. `@ekklesia/contracts` is installed only transitively: it is
|
||||
not in the module's `package.json` and not directly under `node_modules/@ekklesia`. Every
|
||||
installed `@ekklesia/*` package, `module-pack` included, is exactly the release version, and
|
||||
the fixture pins `module-pack` exactly.
|
||||
- **The module builds.** It typechecks with `skipLibCheck: false`, so every published declaration
|
||||
it reaches is checked, then builds its backend and UI.
|
||||
- **It packages and verifies.** It is packed with the release's own `ekklesia-pack-module`, with
|
||||
no workspace file present. Core's verifier then verifies it. The package must:
|
||||
- incorporate the train's packages at the release version;
|
||||
- name the release's module-pack as its packager;
|
||||
- credit no Ekklesia package and exactly one React;
|
||||
- contain no local path.
|
||||
|
||||
## Reproducibility
|
||||
|
||||
Two builds of the same commit produce byte-identical tarballs, and CI proves it on every run.
|
||||
|
||||
- **pnpm normalizes the tar entries.** Every entry gets the fixed modification time 1985-10-26, owner
|
||||
0, and a mode of 0644, or 0755 when executable. The gzip header records no time.
|
||||
- **The build output is deterministic,** and source maps use relative paths.
|
||||
- **One nondeterminism came from pnpm itself.** pnpm rewrites `workspace:` ranges concurrently and
|
||||
inserts each dependency as its lookup resolves. A package with more than one `workspace:`
|
||||
dependency in one field could therefore be packed with those keys in a different order. Builds
|
||||
from two checkouts of one commit differed in exactly that way: `@ekklesia/module-pack`'s
|
||||
`package.json` ordered `@ekklesia/module-sdk` and `@ekklesia/module-database` differently.
|
||||
- **The fix is pnpm's own hook.** The pack-only `beforePacking` hook puts each dependency map back
|
||||
in the source `package.json`'s key order. It changes no name, range, or field. The tarball stays
|
||||
an ordinary `pnpm pack` tarball.
|
||||
- **The inspection enforces it.** It fails any packed map whose order differs from its source.
|
||||
- **The hook also drops `devDependencies`** from packed manifests. A consumer never installs them,
|
||||
and a tooling package's test-only development dependencies named private workspace packages
|
||||
outside the train.
|
||||
|
||||
**What reproducibility does not cover:**
|
||||
- The module package the consumer proof builds is deterministic only for a fixed install layout.
|
||||
pnpm names a `file:` tarball's install directory after its path, and bundlers keep those names in
|
||||
comments. So the proof always installs from the same relative path. From a registry, the directory
|
||||
is simply `<name>@<version>`.
|
||||
- Byte identity is proven for the pinned toolchain: Node.js from `.node-version`, and pnpm from
|
||||
`packageManager`. Another Node.js or zlib build may compress differently. Each artifact's
|
||||
integrity is recorded either way, and that, not byte identity, is what a publication step must
|
||||
verify.
|
||||
|
||||
## Publishing the train
|
||||
|
||||
The registry is the Gitea npm registry under the `River_of_Glory_Church` owner:
|
||||
|
||||
```text
|
||||
https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/
|
||||
```
|
||||
|
||||
| Who | May |
|
||||
| --- | --- |
|
||||
| Anyone, signed in or not | search, read metadata, download, and install `@ekklesia/*` |
|
||||
| The publisher account, through the publish workflow | publish new versions and move dist-tags |
|
||||
| Anyone else | nothing more: publishing needs the publisher team's package write permission |
|
||||
|
||||
### The workflow
|
||||
|
||||
`.github/workflows/publish-packages.yml` (Actions → Publish packages → Run workflow, on `main`,
|
||||
with the release version). Nothing else can start it: no pull request or push does, and CI holds no
|
||||
registry credential at all.
|
||||
|
||||
1. **The gate.** `package-release` runs the same credential-free gate as CI (build, reproduce,
|
||||
consume) and hands the proven release on as a workflow artifact, with its manifest's SHA-256.
|
||||
2. **The publish job.** It builds and installs nothing. It downloads the artifact and runs
|
||||
`scripts/publish-package-train.ts` with the secret `EKKLESIA_PACKAGES_PUBLISH_TOKEN`, which only
|
||||
this step receives.
|
||||
3. **The publication record** is kept as an artifact of the run.
|
||||
|
||||
### What the publication tool does
|
||||
|
||||
`scripts/publish-package-train.ts` publishes exactly the `.tgz` files in the release directory. It
|
||||
never builds, packs, or edits a package, and it never deletes, unpublishes, or overwrites anything.
|
||||
|
||||
1. **Validates the whole train before sending anything.** The manifest must be the one the gate
|
||||
proved (`--manifest-sha256`), for the requested version and this commit (`--revision`), from a
|
||||
clean tree (`"modified": false`), with exactly the nine expected packages. Every artifact's file
|
||||
name, size, SHA-256, integrity, `SHA256SUMS` line, and the `package.json` inside it must match.
|
||||
2. **Publishes in dependency order.** Each package comes after the train packages it depends on,
|
||||
then alphabetically. For each package:
|
||||
- **absent:** `npm publish <file.tgz>` under the staging dist-tag `ekklesia-staging`, given the
|
||||
artifact's absolute path (npm runs in a temporary directory of its own), after the file is
|
||||
checked once more against the manifest. npm sends the file's own bytes and runs no script for a
|
||||
file; the registry checks them against their integrity and stores them as they are;
|
||||
- **present with the same bytes:** accepted as already published, and verified;
|
||||
- **present with other bytes:** a hard stop. Nothing is deleted or overwritten, nothing after it
|
||||
is published, and the version is burned ([Immutability](#immutability)).
|
||||
3. **Reads every package back.** The registry's metadata must name the package and version, report
|
||||
the artifact's integrity, and list the same `dependencies`, `peerDependencies`,
|
||||
`optionalDependencies`, and `bin` as the tarball's `package.json` (npm writes `bin` paths without
|
||||
a leading `./`). Its tarball URL must be on the configured registry, and the tarball it serves must
|
||||
be the release artifact, byte for byte. With `--require-public-read`, an anonymous download must
|
||||
give the same bytes too. A package is published only once all of that holds.
|
||||
4. **Moves `latest` last.** Only when all nine are verified does `latest` move to the train, never
|
||||
backwards past a newer version. The train is published when every package's `latest` agrees.
|
||||
5. **Writes the publication record** (`--record`).
|
||||
|
||||
A rerun is always safe. It publishes only what is missing, accepts what is already there and
|
||||
identical, and moves only the tags that are not yet right. A train interrupted after five packages,
|
||||
or after four tags, finishes on the next run. No retry ever deletes anything.
|
||||
|
||||
### The publication record
|
||||
|
||||
Evidence that one train was published, not a second package catalog, and never a secret:
|
||||
|
||||
```json
|
||||
{
|
||||
"publicationRecord": 1,
|
||||
"packageTrain": { "version": "0.2.0", "revision": "<commit>", "manifestSha256": "<hex>" },
|
||||
"registry": "https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/",
|
||||
"finalTag": "latest",
|
||||
"completedAt": "<ISO time>",
|
||||
"packages": [
|
||||
{
|
||||
"name": "@ekklesia/contracts",
|
||||
"version": "0.2.0",
|
||||
"file": "ekklesia-contracts-0.2.0.tgz",
|
||||
"outcome": "published",
|
||||
"artifact": { "sha256": "<hex>", "integrity": "sha512-…", "size": 87379 },
|
||||
"registry": { "tarball": "<url>", "sha256": "<hex>", "integrity": "sha512-…", "publicRead": true },
|
||||
"finalTag": "moved",
|
||||
"distTags": { "ekklesia-staging": "0.2.0", "latest": "0.2.0" }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`outcome` is `published` or `already-present`. `finalTag` is `moved`, `already-current`, or
|
||||
`kept-newer` (a newer version already holds `latest`).
|
||||
|
||||
### Credentials
|
||||
|
||||
- **One credential:** `EKKLESIA_PACKAGES_PUBLISH_TOKEN`, a repository Actions secret, used only by
|
||||
the publish step. Nobody needs a token to read or install.
|
||||
- **The publisher** is a dedicated account, member only of a `River_of_Glory_Church` team whose one
|
||||
permission is Packages: Write. Its token has only the `write:package` scope. Gitea's
|
||||
`write:package` also allows deleting packages; the tool never does, and nothing else uses the
|
||||
token.
|
||||
- **No token in git or in an `.npmrc`.** The tool writes a temporary npm configuration that refers to
|
||||
the variable (`_authToken=${EKKLESIA_PACKAGES_PUBLISH_TOKEN}`), ignores every user, global, and
|
||||
project npm configuration, sends the token only to the registry itself, and removes the
|
||||
configuration afterwards.
|
||||
- **What remains trusted:** Gitea gives repository secrets to workflows run for pull requests from
|
||||
branches of this same repository, so anyone who can push a branch here could read the secret
|
||||
through a changed workflow. Keep write access to this repository to the maintainers.
|
||||
|
||||
### Immutability
|
||||
|
||||
Gitea refuses to upload a version that exists (409). But it lets anyone with package write
|
||||
permission delete a version, deletion leaves nothing behind, and an owner's package cleanup rules
|
||||
can delete versions on a schedule. So immutability is Ekklesia's policy, not the registry's
|
||||
guarantee:
|
||||
|
||||
- **A published version is never deleted,** by anyone, for any reason short of a security incident.
|
||||
- **A version number is never reused with other bytes.** A version found on the registry with other
|
||||
bytes is burned: investigate how it got there, and publish the train under a new version.
|
||||
- **No package cleanup rule** may be configured for the `River_of_Glory_Church` owner.
|
||||
- **The publisher never deletes.** The tool has no delete, unpublish, or overwrite, and its tests
|
||||
check that.
|
||||
|
||||
Module lockfiles record each package's integrity, so a replaced package would fail every install of
|
||||
it, rather than install silently.
|
||||
|
||||
### Moving to another registry
|
||||
|
||||
Only configuration changes: the registry URL (the workflow's `EKKLESIA_PACKAGES_REGISTRY`), the
|
||||
publisher credential, and modules' `@ekklesia:registry`. Modules then regenerate their lockfiles,
|
||||
because Gitea's tarball URLs are its own. Package sources, the train, its manifests, and every
|
||||
artifact already built stay as they are; the same bytes keep the same integrity.
|
||||
|
||||
### Proving the publication machinery
|
||||
|
||||
`pnpm test:package-publication` (`scripts/test-package-publication.sh`, the CI `package-publication`
|
||||
job) publishes a real release train to a disposable Gitea 1.27.2 configured like the real one:
|
||||
public owners, a publisher team, and an outsider. In CI it publishes the very artifact the
|
||||
`package-release` job handed on, after checking its manifest hash. It proves:
|
||||
|
||||
- anonymous search, metadata, and download work;
|
||||
- publishing without credentials, or by a user outside the publisher team, fails;
|
||||
- the round trip is byte-identical, also anonymously;
|
||||
- the registry refuses a second upload of a published version;
|
||||
- a train interrupted before its sixth package resumes, verifying the five and publishing the rest,
|
||||
with `latest` untouched until all nine are verified;
|
||||
- a version already published with other bytes stops the train, deleting and overwriting nothing,
|
||||
and publishing nothing after it, on every retry;
|
||||
- a train interrupted while moving `latest` converges on a rerun, moving only the missing tags.
|
||||
|
||||
### The first publication
|
||||
|
||||
The first train published is **0.2.0**, the next platform release, not 0.1.2. The `v0.1.2` tag
|
||||
predates the curated SDK and this release tooling, so packages built now and called 0.1.2 would
|
||||
claim to be source that 0.1.2 never contained.
|
||||
|
||||
Before it, an administrator of the Gitea instance:
|
||||
|
||||
1. creates the dedicated publisher account;
|
||||
2. creates a `River_of_Glory_Church` team, for example `package-publishers`, with only the Packages
|
||||
unit at Write, and adds the account to it;
|
||||
3. creates a token for the account with only `write:package`, and stores it as the repository
|
||||
Actions secret `EKKLESIA_PACKAGES_PUBLISH_TOKEN`;
|
||||
4. checks that the owner has no package cleanup rule.
|
||||
|
||||
Then the release proceeds as usual: the version-set pull request merges, a maintainer starts
|
||||
Publish packages on `main` with that version, and tags the commit once the train is published.
|
||||
|
||||
+93
-12
@@ -509,6 +509,36 @@ an `authenticated` baseline, from platform ownership, or from a user grant. Its
|
||||
answer "yes" to every human question, so any accidental fall-through into a human path would flip
|
||||
those assertions.
|
||||
|
||||
A further suite (`core/tests/module-database-abi.integration.ts`, same harness) proves Core's Module
|
||||
Database ABI v1 executor against real PostgreSQL
|
||||
([ADR 0050](../architecture/decisions/0050-module-database-abi-v1.md)). Nothing it asserts is mocked,
|
||||
because every claim is a property of PostgreSQL or of the protocol:
|
||||
- one statement per `query`, enforced by PostgreSQL's extended protocol rather than by parsing, so
|
||||
semicolons inside literals, identifiers, and comments are never mistaken for a second statement;
|
||||
- every allowed parameter category bound exactly, and every refused one refused before PostgreSQL
|
||||
sees it;
|
||||
- the decoding table for each type the ADR names;
|
||||
- PostgreSQL's SQLSTATE preserved while its message, detail, constraint, and values are hidden;
|
||||
- commit, rollback, isolation levels, read-only, no retries, and an aborted transaction never being
|
||||
committed;
|
||||
- callback-scoped savepoints;
|
||||
- a forgotten `await` rolling back instead of committing, while a rejected callback (one half of a
|
||||
failed `Promise.all`) keeps its own error;
|
||||
- transaction-control SQL sent through `query` detected only after PostgreSQL has run it: Core
|
||||
cannot undo a module's own `COMMIT`, so the suite pins what it does do: it refuses everything
|
||||
after, never commits, and destroys the connection;
|
||||
- duplicate object-mode columns found only after a mutation ran, unless it ran in a transaction;
|
||||
- revoked executors reaching nothing.
|
||||
|
||||
The suite's database is created with deliberately hostile defaults: a non-UTC time zone, a non-ISO
|
||||
date style, `sql_standard` intervals, `escape` bytea output, and reduced float digits. So every
|
||||
decoding assertion also proves the executor pins its own session settings. The pool-safety cases use
|
||||
a one-connection pool, which proves the next borrower gets a connection with exactly the database's
|
||||
defaults after every failure class: no transaction, setting, temporary table, listener, or advisory
|
||||
lock carries over. The same is true after a connection the server terminated. Parameter validation,
|
||||
decoding from text, option validation, error sanitization, and scope bookkeeping are also unit-tested
|
||||
without a database in `core/tests/module-database-abi.test.ts`.
|
||||
|
||||
Outbound email is proven against a **real SMTP server** rather than a mock
|
||||
(`core/tests/mail-smtp.integration.test.ts`, [ADR 0037](../architecture/decisions/0037-host-provided-outbound-email.md)).
|
||||
`smtp-server` — the reference implementation from the same project as the client — runs in-process,
|
||||
@@ -568,24 +598,74 @@ CI / migration-smoke (pull_request)
|
||||
|
||||
Ekklesia Module Package v1 (ADR 0049) is proven at three levels, all in `pnpm verify`:
|
||||
|
||||
- `packages/contracts/tests/module-package.test.ts`: the `ekklesia.package.json` schema, target
|
||||
names, and the generated runtime `package.json`.
|
||||
- `packages/contracts/tests/module-package.test.ts`: the `ekklesia.package.json` schema, required
|
||||
SPDX expression and `LICENSE` inventory entry, target names, and the generated runtime
|
||||
`package.json`.
|
||||
- `core/tests/module-package-verification.test.ts`: the verifier against hand-built archives,
|
||||
including what the packager never produces. A valid package verifies and extracts with normalized
|
||||
modes. A wrong digest, a corrupted or missing or unlisted file, disagreeing identities, an unknown
|
||||
format, unknown transitional metadata, an incompatible target, Node.js range, SDK, or database
|
||||
runtime, a missing entrypoint, a backend that imports something the package lacks, absolute and
|
||||
format, unknown transitional metadata, an incompatible target, Node.js range, SDK, or legacy
|
||||
database runtime, ABI metadata carrying a forbidden transitional runtime, malformed ABI schema
|
||||
metadata, missing or malformed license metadata, missing, unlisted, duplicated, or corrupted
|
||||
`LICENSE`, a missing entrypoint, a backend that imports something the package lacks, absolute and
|
||||
`..` paths, symbolic and hard links, device nodes and FIFOs, setuid and setgid entries, a second
|
||||
top-level directory, and a duplicate entry each fail with their own sanitized code. The backend
|
||||
load check runs in a real child process.
|
||||
load check runs in a real child process and selects the schema contract from the manifest.
|
||||
- `packages/module-pack/tests/native-target.test.ts`: the `linux-x64-glibc` target, with a real
|
||||
native dependency (`sharp`). The checked-in proof module (`tests/fixtures/native-module`) is
|
||||
packed, and the test proves:
|
||||
- the package carries exactly sharp's discovered runtime closure, as real files;
|
||||
- nothing from another platform, and no development dependency, is included;
|
||||
- `runtime` matches what the binaries declare;
|
||||
- the reviewed libvips supplement is merged and labelled;
|
||||
- two packs from unrelated copies are byte-identical.
|
||||
|
||||
Core's own verifier then accepts the package, and the extracted package runs a real image
|
||||
operation in a separate process with an empty environment. Node's resolution hooks and the
|
||||
process's memory map show that nothing resolves or loads from outside the package except the
|
||||
host's system libraries.
|
||||
- `packages/module-pack/tests/native-closure.test.ts` breaks the closure rules one at a time, with
|
||||
fake native packages built around the real sharp addon's bytes:
|
||||
- which platform packages are kept and which are left out;
|
||||
- missing and foreign-platform dependencies;
|
||||
- version conflicts, links, a foreign machine, and Node.js range conflicts;
|
||||
- an unneeded native target.
|
||||
- `packages/module-pack/tests/supplements.test.ts` proves ADR 0052's mechanism:
|
||||
- the documented content-digest algorithm;
|
||||
- that the loader refuses anything unreviewed;
|
||||
- that supplement material is merged after the package's own and labelled;
|
||||
- that a supplement for another version, digest, or licence fails closed;
|
||||
- that the shipped libvips supplement is intact and pins the installed package exactly.
|
||||
- `packages/module-pack/tests/`: the packager against generated fixture modules (payload contents,
|
||||
generated `package.json`, metadata, bundled backend with only built-in imports, refusal of native
|
||||
and platform-specific dependencies and of unknown or textless licences, notices, byte-identical
|
||||
archives, normalized tar and gzip headers), and end to end against the built module template:
|
||||
and platform-specific dependencies and of unknown or textless third-party licences, explicit
|
||||
module SPDX and license-file validation, unchanged `LICENSE` bytes and inventory digest, notices,
|
||||
byte-identical archives, normalized tar and gzip headers), and end to end against the built module template:
|
||||
packed twice to identical bytes without changing the workspace's install state, verified by Core's
|
||||
verifier, extracted elsewhere, and loaded with Node's own resolution hooks proving nothing outside
|
||||
the package is resolved. Turbo builds the template and Core first, and runs the template's own
|
||||
tests (which rebuild its UI in place) before these.
|
||||
verifier with a deliberately different Core Drizzle version, extracted elsewhere, and loaded with
|
||||
Node's own resolution hooks proving nothing outside the package is resolved. The package contains
|
||||
the repository's exact AGPL `LICENSE`, authoritative `AGPL-3.0-or-later` metadata, module-side
|
||||
Drizzle but no `transitional.databaseRuntime`, and no tooling-only SPDX parser dependency. Turbo
|
||||
builds the template and Core first, and runs the template's own tests (which rebuild its UI in
|
||||
place) before these.
|
||||
|
||||
## The public package train
|
||||
|
||||
`pnpm test:package-release` (the CI `package-release` job) builds the public `@ekklesia/*` package
|
||||
train, proves a second build from another checkout of the commit byte-identical, and installs
|
||||
exactly those tarballs into the standalone fixture module, offline, which must typecheck with
|
||||
`skipLibCheck: false`, build, package, and verify. It needs a clean source tree; pass
|
||||
`-- --allow-modified` to try uncommitted changes locally. The release rules themselves (set,
|
||||
versions, manifest inspection, release directory, manifest bytes, the pack hook) are unit-tested in
|
||||
`tests/public-packages.test.ts`, part of `pnpm verify`. See
|
||||
[Releases and the public package train](releases.md).
|
||||
|
||||
`pnpm test:package-publication` (the CI `package-publication` job, given the `package-release` job's
|
||||
artifact) publishes a release train to a disposable Gitea 1.27.2 with Docker, never the real
|
||||
registry, and proves the publication tool: byte-identical round trips, anonymous reads, refused
|
||||
unauthorized publishing, partial-train recovery, the hard stop on a burned version, and dist-tag
|
||||
convergence (`tests/package-publication.integration.ts`). The tool's own rules are unit-tested in
|
||||
`tests/package-publication.test.ts`, part of `pnpm verify`.
|
||||
|
||||
## The generic platform image and the reference deployment
|
||||
|
||||
@@ -604,8 +684,9 @@ These need Docker and build images locally; nothing is pushed or published.
|
||||
- `pnpm deploy:smoke` (`scripts/smoke-deploy.sh`) runs the reference deployment end to end on the
|
||||
generic image with empty module storage. With `EKKLESIA_SMOKE_TOPOLOGY=split`, the same image also
|
||||
runs the installed-module lifecycle:
|
||||
- the module template is packaged by the canonical packager (Module Package v1, ADR 0049),
|
||||
verified by the image's own verifier, and installed into module storage;
|
||||
- the module template is packaged by the canonical packager (Module Package v1, ADR 0049 and
|
||||
ADR 0051), with its explicit AGPL declaration and license bytes, as ABI v1, verified by the
|
||||
image's own verifier, and installed into module storage;
|
||||
- its migration is planned and explicitly applied, and the API restarted;
|
||||
- a real browser mounts it;
|
||||
- it is removed and the API restarted, and Core still runs, migrations still plan and apply, and
|
||||
|
||||
@@ -66,8 +66,126 @@ module with no migration history omits `entrypoints.migrations` and needs no pla
|
||||
}
|
||||
```
|
||||
|
||||
The package version must match the manifest and runtime-definition versions. Internal package
|
||||
dependencies use `workspace:` ranges and supported public package entrypoints.
|
||||
The package version must match the manifest and runtime-definition versions. Inside this
|
||||
repository, Ekklesia package dependencies use `workspace:` ranges and supported public package
|
||||
entrypoints. A module in its own repository depends on the published packages instead
|
||||
([Developing a module in its own repository](#developing-a-module-in-its-own-repository)).
|
||||
|
||||
### Developing a module in its own repository
|
||||
|
||||
> **Status:** the packages are published by release
|
||||
> ([ADR 0053](../architecture/decisions/0053-standalone-module-development-packages.md),
|
||||
> [Releases and the public package train](../development/releases.md)). The first published train
|
||||
> is 0.2.0, the platform release after 0.1.2.
|
||||
|
||||
A module does not need an Ekklesia checkout. Do not clone Ekklesia and put your module in its
|
||||
`modules/` directory: your module is its own repository, and it depends on Ekklesia's
|
||||
module-development packages like any other npm packages.
|
||||
|
||||
The packages are **publicly readable**
|
||||
([ADR 0054](../architecture/decisions/0054-public-package-reads-before-launch.md)): installing them
|
||||
needs no account, no token, and no access to Ekklesia's Gitea. That is not a public launch, and it
|
||||
promises no API stability beyond the pre-1.0 SemVer policy. Only Ekklesia's release workflow can publish them. Point the `@ekklesia` scope at
|
||||
the registry in your repository's committed `.npmrc`; that is all the configuration there is:
|
||||
|
||||
```ini
|
||||
@ekklesia:registry=https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/
|
||||
```
|
||||
|
||||
Then the module builds and packages like any other:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm build
|
||||
pnpm exec ekklesia-pack-module --module . --target any --out ../packages \
|
||||
--license-file ./LICENSE --source-revision "$(git rev-parse HEAD)"
|
||||
```
|
||||
|
||||
Before 1.0, a minor version may change these APIs (the project's SemVer policy). Pin the train you
|
||||
build against with ranges in `package.json` and exact versions in your lockfile.
|
||||
|
||||
| Package | Where it goes | What it is |
|
||||
| --- | --- | --- |
|
||||
| `@ekklesia/module-sdk` | `dependencies` | `defineModuleRuntime`, routes, configuration, storage, realtime, principals |
|
||||
| `@ekklesia/module-database` | `dependencies` | Module Database ABI v1 types, `sql`, schema metadata; `/drizzle` for Drizzle |
|
||||
| `@ekklesia/module-ui-sdk` | `dependencies` | `defineModuleUi` and the host context |
|
||||
| `@ekklesia/design-tokens` | `dependencies` | design-token names and helpers |
|
||||
| `@ekklesia/ui` | `dependencies` | shared elements; a pre-1.0 API that may still change |
|
||||
| `react`, `react-dom`, your ORM if any | `dependencies` | your own copies, bundled into your artifacts |
|
||||
| `@ekklesia/module-ui-build` | `devDependencies` | `ekklesia-build-module-ui` |
|
||||
| `@ekklesia/module-pack` | `devDependencies`, **exact version** | `ekklesia-pack-module` |
|
||||
| `@ekklesia/typescript-config` | `devDependencies` | `node.json`, `browser-react.json`, ... |
|
||||
|
||||
`@ekklesia/contracts` is not a direct dependency. The packages above install it; never add or
|
||||
import it yourself, because what it exports is the platform's, not a supported author API.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@acme/notes",
|
||||
"version": "0.1.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": { "node": ">=24.11.0 <25" },
|
||||
"scripts": {
|
||||
"build": "tsc --project tsconfig.build.json && ekklesia-build-module-ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ekklesia/module-database": "^0.2.0",
|
||||
"@ekklesia/module-sdk": "^0.2.0",
|
||||
"@ekklesia/module-ui-sdk": "^0.2.0",
|
||||
"@ekklesia/ui": "^0.2.0",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ekklesia/module-pack": "0.2.0",
|
||||
"@ekklesia/module-ui-build": "^0.2.0",
|
||||
"@ekklesia/typescript-config": "^0.2.0",
|
||||
"typescript": "7.0.2"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **One coordinated version.** Every Ekklesia module-development package has the same version,
|
||||
equal to the Ekklesia release it belongs to, and the platform checks your manifest's
|
||||
`compatibility.moduleSdk` against it. Ranges in your `package.json` are fine; your lockfile pins
|
||||
exact artifacts, and you install with a frozen lockfile.
|
||||
- **Pin `@ekklesia/module-pack` exactly** in `package.json`, not only in the lockfile: its version
|
||||
decides the package it produces.
|
||||
- **`engines.node` is required.** The packager records your module's supported Node.js range from
|
||||
`engines.node` in your `package.json`, and refuses to package without it
|
||||
(`MODULE_PACK_RUNTIME_UNKNOWN`). It never looks for a workspace file.
|
||||
- **No Drizzle unless you use it.** The Module SDK does not install Drizzle. A module that uses the
|
||||
Drizzle adapter, `@ekklesia/module-database/drizzle`, adds `drizzle-orm` to its own
|
||||
dependencies; it is an optional peer of that adapter only.
|
||||
- **One React.** `@ekklesia/ui` takes React as a peer, so your bundle contains one React, your own.
|
||||
Your UI artifacts still bundle it
|
||||
([ADR 0023](../architecture/decisions/0023-module-ui-build-pipeline.md)).
|
||||
- **esbuild's install script.** The UI build and the packager use esbuild, which runs an install
|
||||
script. With pnpm, allow it in your repository's `pnpm-workspace.yaml`:
|
||||
|
||||
```yaml
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
```
|
||||
- **Lockfiles name the registry.** pnpm records Gitea's tarball URLs in your lockfile. If the
|
||||
packages ever move to another registry, change `@ekklesia:registry` and regenerate the lockfile;
|
||||
nothing else changes, and the integrity of every package stays the same.
|
||||
- **TypeScript.** Extend the shared configurations by package name, for example
|
||||
`"extends": "@ekklesia/typescript-config/node.json"` for the backend and
|
||||
`@ekklesia/typescript-config/browser-react.json` for the UI.
|
||||
|
||||
Packaging and verification work exactly as for a module in this repository
|
||||
([Packaging your module](#packaging-your-module)). The packager recognizes Ekklesia's packages by
|
||||
their identity, not by where they are installed, so they are recorded under `incorporates` and never
|
||||
credited as third-party software, wherever they came from.
|
||||
|
||||
Ekklesia proves this on every CI run: `pnpm test:package-release` builds the public packages of a
|
||||
release as one train of tarballs, proves two builds identical, and installs exactly those tarballs
|
||||
into a module outside the repository (`tests/fixtures/standalone-module`), with no workspace link
|
||||
and no registry, then typechecks, builds, packages, and verifies it. Those exact artifacts are what
|
||||
the release publishes ([Releases and the public package train](../development/releases.md)).
|
||||
|
||||
### An installed module is self-contained
|
||||
|
||||
@@ -92,15 +210,17 @@ through no link, `NODE_PATH`, loader hook, or alias. The packager therefore bund
|
||||
|
||||
#### Packaging your module
|
||||
|
||||
Build your module, then run the packager from the workspace your module is built in. It is a
|
||||
development dependency like `@ekklesia/module-ui-build`:
|
||||
Build your module, then run the packager from your module's directory. It is a development
|
||||
dependency like `@ekklesia/module-ui-build`, and it takes your module's supported Node.js range from
|
||||
`engines.node` in its `package.json`:
|
||||
|
||||
```bash
|
||||
pnpm exec ekklesia-pack-module --module . --target any --out ../packages \
|
||||
--license-file ./LICENSE \
|
||||
--source-revision "$(git rev-parse HEAD)"
|
||||
```
|
||||
|
||||
It writes `<id>-<version>-any.tar.gz` and its `.sha256` line, and nothing else anywhere: it never
|
||||
It writes `<id>-<version>-<target>.tar.gz` and its `.sha256` line, and nothing else anywhere: it never
|
||||
installs, links, or deploys, so your workspace is left exactly as it was. Never run `pnpm deploy` in
|
||||
a workspace instead: it rewrites that workspace's install state to production-only.
|
||||
|
||||
@@ -110,13 +230,15 @@ The archive holds one directory named with your module id:
|
||||
<id>/
|
||||
├── package.json generated: name, version, private, "type": "module", the layer marker
|
||||
├── ekklesia.module.json your manifest, byte for byte
|
||||
├── ekklesia.package.json package metadata: target, runtime, provenance, file digests
|
||||
├── ekklesia.package.json package metadata: license, target, runtime, provenance, file digests
|
||||
├── LICENSE your explicitly supplied module license material, byte for byte
|
||||
├── THIRD_PARTY_NOTICES licence and notice text of every third-party package bundled
|
||||
├── dist/server/index.js your backend entrypoints, bundled
|
||||
├── dist/schema/index.js
|
||||
├── dist/chunks/ code your backend entrypoints share
|
||||
├── dist/ui/ … dist/display/ … dist/contributions/ your browser artifacts, exactly as built
|
||||
└── drizzle/ your migrations
|
||||
├── drizzle/ your migrations
|
||||
└── node_modules/ a native target only: native runtime dependencies, as real files
|
||||
```
|
||||
|
||||
- **Bundled backend.** Your server, schema, and jobs entrypoints are bundled with every
|
||||
@@ -127,23 +249,68 @@ The archive holds one directory named with your module id:
|
||||
your development `package.json`, any `workspace:` range, and links of any kind.
|
||||
- **Browser artifacts must be current.** The packager rebuilds them in memory and refuses an artifact
|
||||
on disk that differs, so run your build first.
|
||||
- **`any` is the only target today.** A dependency that is native or platform-specific (a compiled
|
||||
addon, a package that declares an operating system or CPU, or one that loads a per-platform binary
|
||||
such as `sharp`) cannot be in an `any` package, and packaging fails. Native targets come later.
|
||||
- **Licences.** `THIRD_PARTY_NOTICES` credits every third-party package your bundles contain, with
|
||||
its declared licence and the licence and notice files it ships. A package that declares no licence
|
||||
fails packaging, and so does one that ships no licence text, unless its licence's standard text is
|
||||
complete without a copyright line (Apache-2.0, which the packager then supplies and says so). The
|
||||
Ekklesia platform's own packages are listed in `ekklesia.package.json` under `incorporates`.
|
||||
- **Targets.**
|
||||
- **`any`** is for pure-JavaScript modules. A dependency that is native or platform-specific (a
|
||||
compiled addon, a package that declares an operating system or CPU, or one that loads a
|
||||
per-platform binary such as `sharp`) cannot be in an `any` package, and packaging fails.
|
||||
- **`linux-x64-glibc`** is for modules with such a dependency, on Linux, x86-64, and glibc. Pack
|
||||
with `--target linux-x64-glibc` on a matching host, after installing your dependencies.
|
||||
- **What a `linux-x64-glibc` package contains.**
|
||||
- **Native packages stay outside the bundle.** Your ordinary JavaScript dependencies are bundled
|
||||
as before. Each native package is copied instead, with exactly its production runtime
|
||||
dependencies, as real files flat under `node_modules/<name>`.
|
||||
- **Only this target's platform packages are copied.** Optional platform packages for other
|
||||
systems, development dependencies, declarations, and source maps are left out. Two versions of
|
||||
one package, a link inside a native package, or a required dependency for another platform fail
|
||||
packaging.
|
||||
- **`runtime` is read from the binaries themselves.** It records the Node-API version your addons
|
||||
declare (or the exact Node.js ABI of an ABI-specific addon) and the newest glibc symbol version
|
||||
any copied binary needs. A native target for a module with no native addon is refused: use
|
||||
`any`.
|
||||
- **A native package fits one environment.** A `linux-x64-glibc` artifact runs only on Linux,
|
||||
x86-64, glibc hosts that meet its recorded glibc and Node-API minimums. The verifier checks all of
|
||||
that before it loads anything. Other operating systems and CPUs, including ARM, are not supported
|
||||
yet.
|
||||
- **Your module's licence.** Your source `package.json` must contain a non-empty string SPDX
|
||||
expression. The packager validates its syntax, rejects `UNLICENSED`, and copies it unchanged to
|
||||
the required `ekklesia.package.json.license`. `--license-file` is also required: it names the
|
||||
regular file whose exact bytes become the root `LICENSE` payload and inventory entry. The
|
||||
packager never searches parent directories, guesses text from the expression, or downloads it.
|
||||
- **Third-party licences.** `THIRD_PARTY_NOTICES` separately credits every third-party package your
|
||||
bundles contain, with its declared licence and the licence and notice files it ships. A dependency
|
||||
that declares no licence fails packaging, and so does one that ships no licence text, unless its
|
||||
licence's standard text is complete without a copyright line (Apache-2.0, which the packager then
|
||||
supplies and says so). The module itself is not added to this notice file. Bundled Ekklesia
|
||||
packages remain listed in `ekklesia.package.json` under `incorporates`; one applicable AGPL text
|
||||
is sufficient for current official modules. An Ekklesia package is one named `@ekklesia/*` whose
|
||||
`package.json` also carries `"ekklesia": { "distribution": "module-development" }`, whether it is
|
||||
linked from this repository or installed from a registry; a package with only the name is
|
||||
third-party.
|
||||
- **Reviewed licence supplements.** Some dependencies omit material that redistribution requires.
|
||||
For one of those, the packager adds material from a reviewed supplement checked in with it, after
|
||||
the package's own material and labelled as such
|
||||
([ADR 0052](../architecture/decisions/0052-reviewed-license-supplements.md)).
|
||||
- **Exact match.** A supplement is used only for an exact package name, version, and content
|
||||
digest. The digest covers the redistributed files' relative paths and bytes, and is documented in
|
||||
`packages/module-pack/src/supplements.ts`. Any mismatch fails.
|
||||
- **What it does not do.** A supplement never changes the licence the package declares. The
|
||||
supplements cover `@img/sharp-libvips-linux-x64` 1.3.3 and 1.3.4, the libvips binaries that
|
||||
`sharp` 0.35.4 and 0.35.5 load; each matches only its own version and digest, and the packager's
|
||||
native proof builds with sharp 0.35.5. They record upstream sources, but whether the LGPL and MPL
|
||||
source obligations for those binaries are met is an open determination. A package that carries
|
||||
one is not yet treated as publicly distributable.
|
||||
- **Deterministic.** The same packager version and the same built inputs produce the same archive,
|
||||
byte for byte, wherever the checkout is.
|
||||
|
||||
**Drizzle, for now.** You define tables with your own copy of Drizzle, and Core runs your queries
|
||||
through the platform's Drizzle instance (see [Database access](#database-access)). Objects from two
|
||||
copies interoperate only when the copies are interchangeable, so the current implementation requires
|
||||
**exactly the Drizzle version the platform uses**: the one `@ekklesia/module-sdk` depends on. The
|
||||
package records it under `transitional`, and an installation refuses a package whose Drizzle differs.
|
||||
This is a constraint of today's implementation, not a permanent contract.
|
||||
**Database ABI v1 is the current path.** A module that declares `"database": { "abi": 1 }` keeps
|
||||
Drizzle and its exact version inside its own bundle as an implementation detail. Its package omits
|
||||
`transitional.databaseRuntime`; Core validates the module's plain schema metadata and never compares
|
||||
the bundled Drizzle version with its own. The canonical template demonstrates this path with
|
||||
`@ekklesia/module-database/drizzle`.
|
||||
|
||||
A manifest-v8 module that omits `database` remains on the temporary legacy facade. Only that legacy
|
||||
package records bundled Drizzle under `transitional.databaseRuntime`, and the verifier continues to
|
||||
require exact agreement with Core. The legacy facade has not been removed.
|
||||
|
||||
#### Verifying a package
|
||||
|
||||
@@ -157,6 +324,21 @@ installed package normally has no source, so the platform cannot check it the sa
|
||||
module's own CI is where its boundary rules, its production dependencies, and its behaviour inside
|
||||
Ekklesia are proven ([ADR 0047](../architecture/decisions/0047-module-ci-harness.md)).
|
||||
|
||||
### License
|
||||
|
||||
Ekklesia is licensed under the GNU Affero General Public License, version 3 or (at your option) any
|
||||
later version, `AGPL-3.0-or-later` ([LICENSE](../../LICENSE)), and official Ekklesia modules use the
|
||||
same license. Declare it in your module's development `package.json`
|
||||
(`"license": "AGPL-3.0-or-later"`), as the module template does.
|
||||
|
||||
A Module Package v1 records that validated expression in its required
|
||||
`ekklesia.package.json.license` and contains the exact explicitly supplied bytes at `LICENSE`
|
||||
([ADR 0051](../architecture/decisions/0051-module-package-license-material.md)). The generated
|
||||
runtime `package.json` is not authoritative for licensing. `THIRD_PARTY_NOTICES` remains only for
|
||||
redistributed third-party dependencies, while bundled Ekklesia components remain represented by
|
||||
`incorporates`. This strict-reader addition keeps `packageFormat` at `1`; older pre-0051 artifacts
|
||||
without the field and payload fail closed.
|
||||
|
||||
### Removing a module keeps its data
|
||||
|
||||
Removing your module's directory from module storage is not deleting its data:
|
||||
@@ -180,13 +362,13 @@ without executing module code:
|
||||
|
||||
```json
|
||||
{
|
||||
"manifestVersion": 7,
|
||||
"manifestVersion": 8,
|
||||
"id": "calendar",
|
||||
"name": "Calendar",
|
||||
"version": "0.1.0",
|
||||
"compatibility": {
|
||||
"platform": "^0.1.0",
|
||||
"moduleSdk": "^0.1.0"
|
||||
"platform": "^0.2.0",
|
||||
"moduleSdk": "^0.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"modules": {
|
||||
@@ -257,10 +439,10 @@ has its own exact semantic version because its public contract can evolve indepe
|
||||
module release. Capability dependencies use semantic-version ranges. Optional means the provider
|
||||
may be absent; if it is installed, its capability version must be compatible.
|
||||
|
||||
`manifestVersion: 7` (`packages/contracts/src/module-manifest.ts`) is a required, exact literal — a
|
||||
version 6 manifest, and any other version, fails validation outright rather than being silently
|
||||
`manifestVersion: 8` (`packages/contracts/src/module-manifest.ts`) is a required, exact literal — a
|
||||
version 7 manifest, and any other version, fails validation outright rather than being silently
|
||||
reinterpreted; there is no compatibility adapter or automatic migration between manifest versions.
|
||||
Version 7 adds the optional `contributions` array
|
||||
Already-built version 7 packages must be rebuilt. Version 7 added the optional `contributions` array
|
||||
([ADR 0041](../architecture/decisions/0041-module-browser-contributions.md)). A version 6 module that
|
||||
contributes nothing changes only the version literal; every browser artifact remains independently
|
||||
optional ([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)).
|
||||
@@ -1399,27 +1581,27 @@ export const events = moduleTable("events", {
|
||||
});
|
||||
```
|
||||
|
||||
The production schema entrypoint in `src/schema/index.ts` wraps those same objects with the Module
|
||||
SDK ownership marker:
|
||||
The production schema entrypoint in `src/schema/index.ts` turns those same objects into the plain
|
||||
schema metadata an ABI-v1 module declares:
|
||||
|
||||
```ts
|
||||
import { defineModuleSchema } from "@ekklesia/module-sdk";
|
||||
import { moduleDrizzleSchemaMetadata } from "@ekklesia/module-database/drizzle";
|
||||
import { events } from "./tables.js";
|
||||
|
||||
export { events } from "./tables.js";
|
||||
|
||||
export default defineModuleSchema({
|
||||
moduleId: "calendar",
|
||||
tables: { events }
|
||||
});
|
||||
export default moduleDrizzleSchemaMetadata("calendar", { events });
|
||||
```
|
||||
|
||||
A module without an ORM declares the same metadata directly with `defineModuleSchemaMetadata` from
|
||||
`@ekklesia/module-database`. A legacy-v8 module's schema entrypoint default-exports
|
||||
`{ moduleId, tables }` with the Drizzle table objects themselves.
|
||||
|
||||
The prefix callback converts the module ID to a snake-case namespace and prefixes every local table
|
||||
name. Here the physical PostgreSQL name is `calendar_events`; `care-team` plus `members` becomes
|
||||
`care_team_members`. Local names must be lowercase snake case, and the complete physical identifier
|
||||
must not exceed 63 characters. The SDK's `createModuleTable` provides the same convenience when a
|
||||
generator-facing pure declaration file is not needed; Core validates built metadata independently
|
||||
of either helper.
|
||||
must not exceed 63 characters. `createModuleTable` from `@ekklesia/module-database/drizzle` provides
|
||||
the same convenience; Core validates built metadata independently of either helper.
|
||||
|
||||
Schema definitions use PostgreSQL tables in the platform-managed default/public schema. They must
|
||||
be side-effect-free: do not connect to a database, read configuration, or perform I/O during import.
|
||||
@@ -1450,9 +1632,10 @@ export default defineConfig({
|
||||
```
|
||||
|
||||
Keep `tables.ts` limited to Drizzle declarations so the stable generator can load it without
|
||||
executing the ESM Module SDK wrapper. `src/schema/index.ts` remains the production compiled schema
|
||||
entrypoint and wraps those same exported table objects with `defineModuleSchema`; tables are not
|
||||
declared twice.
|
||||
executing the ESM module wrapper. For an ABI-v1 module, `src/schema/index.ts` remains the production
|
||||
compiled schema entrypoint and converts those same table objects to plain metadata with
|
||||
`moduleDrizzleSchemaMetadata`; tables are not declared twice. A legacy-v8 module continues to export
|
||||
them as `{ moduleId, tables }` while that facade exists.
|
||||
|
||||
Run the module's `migration:generate` script with a lowercase snake-case name, review the generated
|
||||
SQL and snapshot, and add the matching entry to `drizzle/meta/_ekklesia.json`. Every entry must
|
||||
@@ -1473,9 +1656,119 @@ destructive behavior. Review those properties explicitly. Core is the only migra
|
||||
follows Drizzle's exact `--> statement-breakpoint` delimiter and never naively splits semicolons or
|
||||
falls back to non-transactional execution.
|
||||
|
||||
## Database access
|
||||
## Module Database ABI v1
|
||||
|
||||
`context.services.database` — available in `start`, `stop`, and every authorized route handler,
|
||||
The stable database boundary is Module Database ABI v1: SQL text, ordered bound values, an explicit
|
||||
row mode, low-level PostgreSQL result values, and callback-scoped transactions. ORM objects never
|
||||
cross it. A manifest version 8 module opts in with `"database": { "abi": 1 }`; omitting `database`
|
||||
selects the temporary legacy facade. “Legacy” here describes that facade, never a version 7
|
||||
manifest. Core selects the service from the validated manifest and gives each ABI module its own
|
||||
executor, reused by that module's lifecycle hooks and route handlers.
|
||||
|
||||
A Drizzle module imports the optional adapter subpath and declares its own exact compatible
|
||||
`drizzle-orm` dependency. The adapter's peer range (`>=0.45.2 <0.46.0`) describes adapter API
|
||||
compatibility; it is not a Core or platform compatibility range:
|
||||
|
||||
```ts
|
||||
import { moduleDrizzle } from "@ekklesia/module-database/drizzle";
|
||||
import { defineModuleRuntime } from "@ekklesia/module-sdk";
|
||||
import * as schema from "./schema/tables.js";
|
||||
|
||||
export default defineModuleRuntime({
|
||||
database: { abi: 1 },
|
||||
id: "calendar",
|
||||
version: "0.1.0",
|
||||
async start({ services }) {
|
||||
const db = moduleDrizzle(services.database, { schema });
|
||||
const events = await db.select().from(schema.events);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The repeated runtime discriminator is what gives lifecycle and route-handler code the exact
|
||||
`ModuleDatabaseExecutor` type instead of a legacy-or-ABI union. Core still chooses from the static
|
||||
manifest and refuses startup when the runtime discriminator disagrees with it; neither contract can
|
||||
silently receive the other service.
|
||||
|
||||
Drizzle compiles SQL and parameters before calling the ABI. The adapter always requests positional
|
||||
(`rowMode: "array"`) results, gives those rows back to Drizzle in column order, and Drizzle applies
|
||||
the module's own column decoders for bigint, numeric, dates/timestamps, JSON, byte arrays, nulls, and
|
||||
supported arrays. This also makes joins with duplicate PostgreSQL column names unambiguous. Core
|
||||
never imports this adapter and has no dependency on the module's Drizzle version. The adapter surface
|
||||
is deliberately limited to `select`, `insert`, `update`, `delete`, and `transaction`; returning,
|
||||
joins, and SQL expressions come from those normal Drizzle builders.
|
||||
|
||||
`db.transaction(callback)` maps to the ABI transaction callback. The callback receives another
|
||||
Drizzle-facing database backed by the scoped ABI executor; a nested `.transaction()` maps to the
|
||||
ABI's `savepoint` callback. Callback values and errors propagate, Core remains authoritative for
|
||||
commit/rollback and escaped-scope behavior, and the adapter adds no retries or transaction-control
|
||||
SQL.
|
||||
|
||||
Drizzle wraps execution failures in `DrizzleQueryError`. Recover the stable sanitized ABI error by
|
||||
shape instead of using a PostgreSQL error instance, parsing a message, or reading `.cause.code`:
|
||||
|
||||
```ts
|
||||
import { findEkklesiaDatabaseError } from "@ekklesia/module-database";
|
||||
|
||||
try {
|
||||
await db.insert(schema.events).values(event);
|
||||
} catch (error) {
|
||||
const databaseError = findEkklesiaDatabaseError(error);
|
||||
if (databaseError?.sqlState === "23505") {
|
||||
// Handle a uniqueness conflict.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Modules that do not want Drizzle use the package's ORM-neutral tag. Every interpolation is one bound
|
||||
value; v1 deliberately provides no dynamic-identifier or trusted-fragment escape hatch:
|
||||
|
||||
```ts
|
||||
import { sql } from "@ekklesia/module-database";
|
||||
|
||||
const statement = sql`
|
||||
SELECT id, title
|
||||
FROM calendar_events
|
||||
WHERE id = ${eventId}
|
||||
`;
|
||||
|
||||
const result = await executor.query({ ...statement, rowMode: "object" });
|
||||
```
|
||||
|
||||
The tag does not stringify values. Unsupported values pass through unchanged and the ABI executor
|
||||
rejects them; strings containing quotes or SQL syntax always remain parameters.
|
||||
|
||||
ABI modules also export plain schema metadata rather than Drizzle objects. Drizzle users can derive
|
||||
it on the module side; raw-SQL modules can write the same inert shape directly:
|
||||
|
||||
```ts
|
||||
import { moduleDrizzleSchemaMetadata } from "@ekklesia/module-database/drizzle";
|
||||
import * as tables from "./tables.js";
|
||||
|
||||
export default moduleDrizzleSchemaMetadata("calendar", tables);
|
||||
```
|
||||
|
||||
The result is `{ moduleId, tables: [{ name, foreignKeys: [{ table }] }] }`. Core validates table
|
||||
prefixes, duplicates, and same-module foreign-key targets from those strings and never inspects an
|
||||
ABI module's ORM objects.
|
||||
|
||||
The canonical module template declares ABI v1, uses the module-side Drizzle adapter, and exports
|
||||
plain schema metadata derived with `moduleDrizzleSchemaMetadata`. Its Module Package v1 still
|
||||
bundles Drizzle, but carries no `transitional.databaseRuntime`: that version belongs to the module,
|
||||
not Core.
|
||||
|
||||
## Legacy database access
|
||||
|
||||
> **New modules use [Module Database ABI v1](#module-database-abi-v1).** Core still serves the
|
||||
> legacy facade described here for its deprecation window
|
||||
> ([ADR 0050](../architecture/decisions/0050-module-database-abi-v1.md)), so existing legacy modules
|
||||
> keep running. But the Module SDK no longer exposes the facade's type
|
||||
> ([ADR 0053](../architecture/decisions/0053-standalone-module-development-packages.md)): built
|
||||
> against the current SDK, a module that declares no database sees `services.database` as `never`,
|
||||
> and the example below no longer typechecks. Migrate to ABI v1 rather than reaching for the type
|
||||
> elsewhere.
|
||||
|
||||
For a version 8 manifest that omits `database`, `context.services.database` — available in `start`, `stop`, and every authorized route handler,
|
||||
never in `register` — is a `ModuleDatabase`: a typed Drizzle query surface over exactly `select`,
|
||||
`selectDistinct`, `selectDistinctOn`, `insert`, `update`, `delete`, `execute`, and `transaction`,
|
||||
preserving Drizzle's own table-aware argument and result inference. It is the platform's only
|
||||
|
||||
+5
-1
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "ekklesia",
|
||||
"version": "0.1.2",
|
||||
"version": "0.2.1",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Open, modular church technology platform",
|
||||
@@ -37,9 +38,12 @@
|
||||
"modules:pack": "turbo run build && node packages/module-pack/bin/ekklesia-pack-module.mjs",
|
||||
"modules:validate": "turbo run build && node core/dist/cli/validate-modules.js",
|
||||
"modules:verify-package": "turbo run build && node core/dist/cli/verify-module-package.js",
|
||||
"packages:release": "node scripts/package-release.ts --out .package-release",
|
||||
"test": "turbo run test test:root",
|
||||
"test:migrations:integration": "pnpm build && bash scripts/test-migrations-integration.sh",
|
||||
"test:root": "vitest run --config vitest.config.ts",
|
||||
"test:package-release": "bash scripts/test-package-release.sh",
|
||||
"test:package-publication": "bash scripts/test-package-publication.sh",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "turbo run typecheck typecheck:root",
|
||||
"typecheck:root": "tsc --noEmit --project tsconfig.json",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "@ekklesia/contracts",
|
||||
"version": "0.1.2",
|
||||
"version": "0.2.1",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist"
|
||||
@@ -9,28 +10,33 @@
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./module-package": {
|
||||
"types": "./dist/module-package.d.ts",
|
||||
"import": "./dist/module-package.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --project tsconfig.build.json",
|
||||
"lint": "biome lint .",
|
||||
"prepack": "pnpm --workspace-root exec turbo run build --filter=@ekklesia/contracts",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit --project tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"drizzle-orm": "0.45.2",
|
||||
"semver": "7.8.5",
|
||||
"spdx-expression-parse": "5.0.0",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.3",
|
||||
"@types/node": "24.13.3",
|
||||
"@types/semver": "7.7.1",
|
||||
"@types/spdx-expression-parse": "4.0.0",
|
||||
"typescript": "7.0.2",
|
||||
"vitest": "4.1.10"
|
||||
},
|
||||
"ekklesia": {
|
||||
"layer": "contracts"
|
||||
"layer": "contracts",
|
||||
"distribution": "module-development"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,14 @@ export * from "./identity.js";
|
||||
export * from "./module-configuration.js";
|
||||
export * from "./module-contribution.js";
|
||||
export * from "./module-database.js";
|
||||
export * from "./module-database-abi.js";
|
||||
export * from "./module-http.js";
|
||||
export * from "./module-lifecycle.js";
|
||||
export * from "./module-manifest.js";
|
||||
export * from "./module-migration.js";
|
||||
export * from "./module-package.js";
|
||||
export * from "./module-realtime.js";
|
||||
export * from "./module-runtime.js";
|
||||
export * from "./module-schema.js";
|
||||
export * from "./module-schema-metadata.js";
|
||||
export * from "./module-schema-names.js";
|
||||
export * from "./module-storage.js";
|
||||
export * from "./versions.js";
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* Module Database ABI v1 (ADR 0050): the stable boundary between Core and a module's database code.
|
||||
*
|
||||
* Only PostgreSQL concepts cross it: SQL text, ordered bound parameter values, decoded result rows
|
||||
* with their column metadata, and transaction scopes that Core controls. **Nothing in this file
|
||||
* names an ORM, a query builder, or a driver.** A module may build its SQL with Drizzle, Kysely, raw
|
||||
* text, or anything else, and Core never learns which; Core may change its own ORM without any
|
||||
* module noticing.
|
||||
*
|
||||
* This is a correctness and compatibility boundary, **not a security sandbox**. Installed modules
|
||||
* remain trusted backend code that runs in the API process with its database role (ADR 0048), and
|
||||
* nothing here, including table prefixes, `SET ROLE`, or PostgreSQL roles, can stop a module from
|
||||
* reaching data that is not its own.
|
||||
*/
|
||||
|
||||
/** The ABI version these types describe. A module declares it; every executor reports it. */
|
||||
export const MODULE_DATABASE_ABI_VERSION = 1;
|
||||
export type ModuleDatabaseAbiVersion = typeof MODULE_DATABASE_ABI_VERSION;
|
||||
|
||||
/**
|
||||
* A scalar parameter value. Deliberately small: each has exactly one PostgreSQL text encoding.
|
||||
*
|
||||
* - `number` is sent as its JavaScript decimal text; `NaN`, `Infinity`, and `-Infinity` as
|
||||
* PostgreSQL's own spellings.
|
||||
* - `bigint` is sent as its decimal text.
|
||||
* - `Uint8Array` is sent as `bytea`.
|
||||
*
|
||||
* A JavaScript `Date` is **not** a parameter: whether it means a `timestamp` or a `timestamptz`, and
|
||||
* in which zone, depends on a column the ABI cannot see. Plain objects are not implicit JSON; encode
|
||||
* JSON explicitly as a string. Identifiers are never parameters.
|
||||
*/
|
||||
export type ModuleDatabaseScalar = null | boolean | number | bigint | string | Uint8Array;
|
||||
|
||||
/** A parameter value: a scalar, or a PostgreSQL array of scalars, nested for more dimensions. */
|
||||
export type ModuleDatabaseParameter = ModuleDatabaseScalar | readonly ModuleDatabaseParameter[];
|
||||
|
||||
/**
|
||||
* A decoded result value. Values that a generic conversion could change arrive as PostgreSQL's
|
||||
* exact text: `int8`, `numeric`, every date and time type, `json`, and `jsonb`. `bytea` arrives as a
|
||||
* `Uint8Array`, and arrays of the built-in types decode element by element.
|
||||
*/
|
||||
export type ModuleDatabaseValue =
|
||||
| null
|
||||
| boolean
|
||||
| number
|
||||
| string
|
||||
| Uint8Array
|
||||
| readonly ModuleDatabaseValue[];
|
||||
|
||||
/** `"object"` rows are keyed by column name; `"array"` rows are positional, in `fields` order. */
|
||||
export type ModuleDatabaseRowMode = "object" | "array";
|
||||
|
||||
export type ModuleDatabaseObjectRow = Readonly<Record<string, ModuleDatabaseValue>>;
|
||||
export type ModuleDatabaseArrayRow = readonly ModuleDatabaseValue[];
|
||||
|
||||
/** One query: exactly one PostgreSQL statement with `$1 … $n` placeholders. */
|
||||
export interface ModuleDatabaseQuery {
|
||||
readonly text: string;
|
||||
readonly values?: readonly ModuleDatabaseParameter[];
|
||||
/** Defaults to `"object"`. */
|
||||
readonly rowMode?: ModuleDatabaseRowMode;
|
||||
}
|
||||
|
||||
/** The ordered column metadata of a result, present even when it has no rows. */
|
||||
export interface ModuleDatabaseField {
|
||||
readonly name: string;
|
||||
/** The column's PostgreSQL type OID. */
|
||||
readonly typeOid: number;
|
||||
}
|
||||
|
||||
export interface ModuleDatabaseQueryResult<Row> {
|
||||
/** PostgreSQL's command tag word, such as `SELECT`, `INSERT`, `UPDATE`, or `DELETE`. */
|
||||
readonly command: string;
|
||||
/** The number of rows PostgreSQL reports as processed, or `null` when it reports none. */
|
||||
readonly rowCount: number | null;
|
||||
readonly rows: readonly Row[];
|
||||
readonly fields: readonly ModuleDatabaseField[];
|
||||
}
|
||||
|
||||
/** What every executor can do: run one statement. */
|
||||
export interface ModuleDatabaseQueryable {
|
||||
readonly abiVersion: ModuleDatabaseAbiVersion;
|
||||
query(
|
||||
query: ModuleDatabaseQuery & { readonly rowMode: "array" },
|
||||
): Promise<ModuleDatabaseQueryResult<ModuleDatabaseArrayRow>>;
|
||||
query(
|
||||
query: ModuleDatabaseQuery & { readonly rowMode?: "object" },
|
||||
): Promise<ModuleDatabaseQueryResult<ModuleDatabaseObjectRow>>;
|
||||
query(
|
||||
query: ModuleDatabaseQuery,
|
||||
): Promise<ModuleDatabaseQueryResult<ModuleDatabaseObjectRow | ModuleDatabaseArrayRow>>;
|
||||
}
|
||||
|
||||
export type ModuleDatabaseIsolationLevel = "read committed" | "repeatable read" | "serializable";
|
||||
|
||||
/** Transaction options. Any other key or value is refused, never ignored. Omitted, a transaction is
|
||||
* `read committed` and read-write. */
|
||||
export interface ModuleDatabaseTransactionOptions {
|
||||
readonly isolation?: ModuleDatabaseIsolationLevel;
|
||||
readonly readOnly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The executor inside a transaction or a savepoint. It is valid only while its callback runs: once
|
||||
* that callback settles it is revoked, and every later call fails without reaching PostgreSQL.
|
||||
*
|
||||
* Only the innermost open scope may run statements: while a savepoint is open, its parent refuses
|
||||
* statements and new savepoints. A callback must await every operation it starts before it returns.
|
||||
* One that resolves with work outstanding forgot an `await`: it fails with
|
||||
* `MODULE_DATABASE_SCOPE_WORK_OUTSTANDING` and is rolled back. One that rejects keeps its own error.
|
||||
* Either way, outstanding work never leads to a commit or a release.
|
||||
*
|
||||
* Transaction-control SQL (`BEGIN`, `COMMIT`, `ROLLBACK`, `SAVEPOINT`, `RELEASE SAVEPOINT`, …) is
|
||||
* forbidden through `query`; the callbacks control the transaction.
|
||||
*/
|
||||
export interface ModuleDatabaseTransaction extends ModuleDatabaseQueryable {
|
||||
/** Core names the savepoint. A resolved callback with no work outstanding releases it; a rejected
|
||||
* one rolls back to it and keeps its own error. */
|
||||
savepoint<T>(callback: (savepoint: ModuleDatabaseTransaction) => Promise<T> | T): Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A module's database service under ABI v1. Top-level `query` calls are separate autocommit
|
||||
* statements on whichever pooled connection is free: no session state carries from one to the
|
||||
* next, and none survives the operation that made it.
|
||||
*/
|
||||
export interface ModuleDatabaseExecutor extends ModuleDatabaseQueryable {
|
||||
/** Core begins the transaction, commits when `callback` resolves with no work outstanding, and
|
||||
* rolls back otherwise; a rejected callback's own error is the transaction's error. Core never
|
||||
* retries it. These guarantees apply to conforming use: no transaction-control SQL through
|
||||
* `query`. */
|
||||
transaction<T>(
|
||||
callback: (transaction: ModuleDatabaseTransaction) => Promise<T> | T,
|
||||
options?: ModuleDatabaseTransactionOptions,
|
||||
): Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every failure category an ABI v1 executor reports. A code is a stable category for programmatic
|
||||
* handling and keeps its meaning once published; the specific reason is in the static message.
|
||||
* PostgreSQL's own condition, when there is one, is `sqlState`, a separate field.
|
||||
*/
|
||||
export const MODULE_DATABASE_ERROR_CODES = [
|
||||
/** The call is not valid ABI use: a malformed query request or callback, a statement text that
|
||||
* holds no statement, an object-mode result with duplicate column names, or a transaction-control
|
||||
* statement sent through `query`. A programming error.
|
||||
*
|
||||
* The last two are only detectable once PostgreSQL has run the statement, so its effects may
|
||||
* already have happened: a mutation with duplicate result columns has been applied (inside a
|
||||
* transaction, it rolls back with it), and transaction control has taken effect, which Core cannot
|
||||
* undo. */
|
||||
"MODULE_DATABASE_REQUEST_INVALID",
|
||||
/** A parameter value is outside the ABI's parameter set. Refused before anything reaches
|
||||
* PostgreSQL. */
|
||||
"MODULE_DATABASE_PARAMETER_INVALID",
|
||||
/** The statement did not complete. `sqlState` is present when PostgreSQL reported the condition;
|
||||
* absent when the database could not be reached or the connection failed. */
|
||||
"MODULE_DATABASE_QUERY_FAILED",
|
||||
/** A transaction option or value is not one the ABI defines. Refused before the transaction
|
||||
* starts. */
|
||||
"MODULE_DATABASE_TRANSACTION_OPTION_INVALID",
|
||||
/** The transaction could not be begun or completed, or a savepoint could not be completed.
|
||||
* `sqlState` is present when PostgreSQL reported the condition. A transaction left aborted by a
|
||||
* failed statement ends here too: PostgreSQL rolls it back, and nothing is committed. */
|
||||
"MODULE_DATABASE_TRANSACTION_FAILED",
|
||||
/** The executor's scope has finished, so it is revoked. */
|
||||
"MODULE_DATABASE_SCOPE_CLOSED",
|
||||
/** The executor's scope is open but not active, because a savepoint inside it is open. */
|
||||
"MODULE_DATABASE_SCOPE_INACTIVE",
|
||||
/** A transaction or savepoint callback resolved while work it started was still outstanding: a
|
||||
* forgotten `await`. The scope was rolled back, never committed or released. (A callback that
|
||||
* rejects keeps its own error instead.) */
|
||||
"MODULE_DATABASE_SCOPE_WORK_OUTSTANDING",
|
||||
] as const;
|
||||
|
||||
export type ModuleDatabaseErrorCode = (typeof MODULE_DATABASE_ERROR_CODES)[number];
|
||||
|
||||
const databaseErrorMessages: Readonly<Record<ModuleDatabaseErrorCode, string>> = Object.freeze({
|
||||
MODULE_DATABASE_PARAMETER_INVALID: "A database parameter value is not supported.",
|
||||
MODULE_DATABASE_QUERY_FAILED: "The database statement did not complete.",
|
||||
MODULE_DATABASE_REQUEST_INVALID: "The database request is not valid.",
|
||||
MODULE_DATABASE_SCOPE_CLOSED: "The database scope has finished and can no longer be used.",
|
||||
MODULE_DATABASE_SCOPE_INACTIVE: "The database scope cannot be used while a savepoint is open.",
|
||||
MODULE_DATABASE_SCOPE_WORK_OUTSTANDING:
|
||||
"The database scope finished with work still outstanding, so it was rolled back.",
|
||||
MODULE_DATABASE_TRANSACTION_FAILED: "The database transaction could not be completed.",
|
||||
MODULE_DATABASE_TRANSACTION_OPTION_INVALID: "A database transaction option is not supported.",
|
||||
});
|
||||
|
||||
const sqlStatePattern = /^[0-9A-Z]{5}$/u;
|
||||
|
||||
/**
|
||||
* The one error an ABI v1 executor reports, sanitized by construction: a stable `code`, PostgreSQL's
|
||||
* `sqlState` when the database supplied one, and a static message. It never carries SQL text,
|
||||
* parameter values, the driver's message, detail, hint, or object names, a cause, a stack beyond
|
||||
* its own name and message, or any driver or ORM object.
|
||||
*
|
||||
* Recognize it with `isModuleDatabaseError`, never `instanceof`: a module bundles its own copy of
|
||||
* this package (ADR 0049), so class identity across the boundary is not a contract.
|
||||
*/
|
||||
export class ModuleDatabaseError extends Error {
|
||||
readonly code: ModuleDatabaseErrorCode;
|
||||
/** Declared, not initialized, so the property exists only when PostgreSQL supplied a SQLSTATE. */
|
||||
declare readonly sqlState?: string;
|
||||
|
||||
constructor(code: ModuleDatabaseErrorCode, options: { readonly sqlState?: string } = {}) {
|
||||
super(databaseErrorMessages[code]);
|
||||
this.name = "ModuleDatabaseError";
|
||||
this.code = code;
|
||||
if (options.sqlState !== undefined && sqlStatePattern.test(options.sqlState)) {
|
||||
this.sqlState = options.sqlState;
|
||||
}
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether `value` is a Module Database ABI error, judged by its shape rather than its class. */
|
||||
export function isModuleDatabaseError(
|
||||
value: unknown,
|
||||
): value is Pick<ModuleDatabaseError, "code" | "message" | "name" | "sqlState"> {
|
||||
if (!(value instanceof Error) || value.name !== "ModuleDatabaseError") {
|
||||
return false;
|
||||
}
|
||||
const { code, sqlState } = value as { code?: unknown; sqlState?: unknown };
|
||||
return (
|
||||
typeof code === "string" &&
|
||||
(MODULE_DATABASE_ERROR_CODES as readonly string[]).includes(code) &&
|
||||
(sqlState === undefined || (typeof sqlState === "string" && sqlStatePattern.test(sqlState)))
|
||||
);
|
||||
}
|
||||
@@ -1,50 +1,19 @@
|
||||
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||
|
||||
/**
|
||||
* A trusted backend module's typed Drizzle query surface — the same query-building methods a
|
||||
* platform-owned Drizzle instance exposes, restricted to exactly the operations a module needs to
|
||||
* read and write its own tables. Derived with `Pick` from the real `drizzle-orm/node-postgres`
|
||||
* database type rather than hand-declared, so every method keeps Drizzle's own table-aware
|
||||
* argument and result inference exactly as-is — a module calling `database.select().from(table)`
|
||||
* or `database.insert(table).values(...)` gets the identical typed builder a direct Drizzle caller
|
||||
* would, with no `any`/`unknown` erasure anywhere in the chain.
|
||||
*
|
||||
* Deliberately excludes everything `NodePgDatabase` or the `drizzle(...)` factory otherwise
|
||||
* exposes: `$client` (the raw `pg` client/pool), `_` (internal session/dialect/schema state),
|
||||
* `query` (a relational-query builder tied to one schema, which would have to span every
|
||||
* installed module's tables to exist here at all), `$cache`, `$count`, `$with`, `with`, and
|
||||
* `refreshMaterializedView`. There is no connection string, pool, session, or dialect object
|
||||
* reachable through this type. Carries no schema generic — a module brings its own Drizzle table
|
||||
* objects (`@ekklesia/module-sdk`'s `defineModuleSchema`) to each call, so there is no global
|
||||
* relational schema spanning every installed module for this type to leak.
|
||||
*
|
||||
* This is an integration boundary, not a security sandbox: modules are trusted, compiled backend
|
||||
* plugins that already run arbitrary code in this process, exactly like every other lifecycle hook
|
||||
* and HTTP handler they define. `ModuleDatabase` exists to keep a module's code Drizzle-typed and
|
||||
* decoupled from how the platform physically connects to PostgreSQL — not to defend against a
|
||||
* malicious module, which this facade cannot do and does not attempt to do.
|
||||
*/
|
||||
export type ModuleDatabase = Pick<
|
||||
NodePgDatabase,
|
||||
| "select"
|
||||
| "selectDistinct"
|
||||
| "selectDistinctOn"
|
||||
| "insert"
|
||||
| "update"
|
||||
| "delete"
|
||||
| "execute"
|
||||
| "transaction"
|
||||
>;
|
||||
import type { ModuleDatabaseExecutor } from "./module-database-abi.js";
|
||||
|
||||
/**
|
||||
* The complete set of platform-provided services a trusted module's `start`/`stop` lifecycle hooks
|
||||
* and authorized HTTP route handlers receive — for this phase, exactly one: `database`. Frozen, and
|
||||
* and authorized HTTP route handlers receive — for this phase, exactly one: `database`. For a
|
||||
* module that declares `database: { abi: 1 }` it is the Module Database ABI v1 executor; for a
|
||||
* legacy module it is Core's legacy facade, whose type Core alone owns, so this package never
|
||||
* references an ORM (ADR 0053). Frozen, and
|
||||
* never handed to a module's `register` hook (`ModuleRegistrationContext` has no `services` field):
|
||||
* registration stays declarative and database-free, exactly as before this contract existed.
|
||||
* Deliberately has no storage, jobs, configuration, secrets, or authorization field yet — those
|
||||
* remain out of scope until a later phase explicitly adds them here, one at a time, the same way
|
||||
* `database` was added.
|
||||
*/
|
||||
export interface ModuleRuntimeServices {
|
||||
readonly database: ModuleDatabase;
|
||||
export interface ModuleRuntimeServices<Database = ModuleDatabaseExecutor> {
|
||||
readonly database: Database;
|
||||
}
|
||||
|
||||
export type ModuleDatabaseAbiRuntimeServices = ModuleRuntimeServices<ModuleDatabaseExecutor>;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type ModuleId, type PermissionId, permissionIdSchema } from "./identifi
|
||||
import type { EkklesiaPrincipal } from "./identity.js";
|
||||
import type { ModuleConfiguration } from "./module-configuration.js";
|
||||
import type { ModuleRuntimeServices } from "./module-database.js";
|
||||
import type { ModuleDatabaseExecutor } from "./module-database-abi.js";
|
||||
import type { ModuleRealtimePublisher } from "./module-realtime.js";
|
||||
import type { ModuleStorage } from "./module-storage.js";
|
||||
|
||||
@@ -271,7 +272,7 @@ export function moduleRoutesOverlap(
|
||||
* behavior, and uncaught-handler sanitization; permission enforcement always completes before a
|
||||
* handler — and therefore any parameter validation it performs — can run.
|
||||
*/
|
||||
export interface ModuleRouteHandlerContext {
|
||||
export interface ModuleRouteHandlerContext<Database = ModuleDatabaseExecutor> {
|
||||
readonly moduleId: ModuleId;
|
||||
readonly principal: EkklesiaPrincipal;
|
||||
readonly params: Readonly<Record<string, string>>;
|
||||
@@ -279,7 +280,7 @@ export interface ModuleRouteHandlerContext {
|
||||
* object, not a re-resolved one (ADR 0030). This is what lets a module stream an object straight
|
||||
* into a `Response` without the browser ever learning that object storage exists. */
|
||||
readonly storage: ModuleStorage;
|
||||
readonly services: ModuleRuntimeServices;
|
||||
readonly services: ModuleRuntimeServices<Database>;
|
||||
/** The identical module-scoped realtime publisher used by lifecycle hooks. */
|
||||
readonly realtime: ModuleRealtimePublisher;
|
||||
/** The identical frozen configuration snapshot this module's `start`/`stop` hooks received — the
|
||||
@@ -293,9 +294,9 @@ export interface ModuleRouteHandlerContext {
|
||||
/** A framework-neutral Fetch handler: standard `Request` in, standard `Response` out. The full
|
||||
* query string remains available through `Request.url`; there is no competing query or body
|
||||
* abstraction. */
|
||||
export type ModuleRouteHandler = (
|
||||
export type ModuleRouteHandler<Database = ModuleDatabaseExecutor> = (
|
||||
request: Request,
|
||||
context: ModuleRouteHandlerContext,
|
||||
context: ModuleRouteHandlerContext<Database>,
|
||||
) => Awaitable<Response>;
|
||||
|
||||
/**
|
||||
@@ -311,7 +312,7 @@ export type ModuleRouteHandler = (
|
||||
* path, CORS/origin policy, authentication behavior, grant/platform-owner state, middleware, audit
|
||||
* metadata, another module's namespace, or an unprotected/no-permission route.
|
||||
*/
|
||||
export interface ModuleRouteDefinitionInput {
|
||||
export interface ModuleRouteDefinitionInput<Database = ModuleDatabaseExecutor> {
|
||||
readonly method: ModuleHttpMethod;
|
||||
readonly path: string;
|
||||
readonly permission: string;
|
||||
@@ -322,12 +323,12 @@ export interface ModuleRouteDefinitionInput {
|
||||
* existed meant, so adding this field changed no existing route's behaviour.
|
||||
*/
|
||||
readonly bodyMode?: ModuleRouteBodyMode;
|
||||
readonly handler: ModuleRouteHandler;
|
||||
readonly handler: ModuleRouteHandler<Database>;
|
||||
}
|
||||
|
||||
/** The validated, canonical form of a `ModuleRouteDefinitionInput` — what `http.route` produces
|
||||
* only after every field passes its own grammar. */
|
||||
export interface ModuleRouteDefinition {
|
||||
export interface ModuleRouteDefinition<Database = ModuleDatabaseExecutor> {
|
||||
readonly method: ModuleHttpMethod;
|
||||
readonly path: ModuleRoutePath;
|
||||
readonly permission: PermissionId;
|
||||
@@ -335,13 +336,13 @@ export interface ModuleRouteDefinition {
|
||||
/** Always present in the canonical form, defaulted to `buffered`, so no consumer downstream has
|
||||
* to re-apply the default and none of them can disagree about it. */
|
||||
readonly bodyMode: ModuleRouteBodyMode;
|
||||
readonly handler: ModuleRouteHandler;
|
||||
readonly handler: ModuleRouteHandler<Database>;
|
||||
}
|
||||
|
||||
/** The Fetch-based registrar exposed as `context.http` during `register` only — never on
|
||||
* `ModuleRuntimeContext`, so `start`/`stop` cannot register a route after the fact. */
|
||||
export interface ModuleHttpRouteRegistrar {
|
||||
route(definition: ModuleRouteDefinitionInput): void;
|
||||
export interface ModuleHttpRouteRegistrar<Database = ModuleDatabaseExecutor> {
|
||||
route(definition: ModuleRouteDefinitionInput<Database>): void;
|
||||
}
|
||||
|
||||
/** One published route in `StartedModuleRuntime`'s immutable route snapshot — the same validated
|
||||
@@ -355,6 +356,8 @@ export interface RegisteredModuleHttpRoute {
|
||||
readonly permission: PermissionId;
|
||||
readonly maxBodyBytes: number;
|
||||
readonly bodyMode: ModuleRouteBodyMode;
|
||||
/** Runtime-erased callback. Module authors get the database-mode-specific handler type through
|
||||
* `ModuleHttpRouteRegistrar`; Core stores the validated callback opaquely in this snapshot. */
|
||||
readonly handler: ModuleRouteHandler;
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,14 @@ const moduleStorageSchema = z
|
||||
.strict()
|
||||
.default(() => ({ buckets: [] }));
|
||||
|
||||
/** The database contract Core delivers to this module. Omitted means the temporary ADR 0016
|
||||
* legacy facade; ABI v1 is the stable SQL boundary from ADR 0050. */
|
||||
export const moduleDatabaseDeclarationSchema = z
|
||||
.object({
|
||||
abi: z.literal(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* A module's optional full-viewport display UI. The artifact is kept beside its access policy so a
|
||||
* display can never be declared without saying which one of the module's own permissions protects
|
||||
@@ -122,9 +130,9 @@ function reportDuplicates(
|
||||
}
|
||||
|
||||
/**
|
||||
* The one manifest shape Core accepts. Version 6's strict top-level object rejects `contributions`,
|
||||
* so the static browser-contribution declaration is an incompatible shape change and the canonical
|
||||
* manifest moves to `manifestVersion: 7` rather than silently reinterpreting version 6.
|
||||
* The one manifest shape Core accepts. Version 7's strict top-level object rejects `database`, so
|
||||
* the static ABI declaration is an incompatible shape change and the canonical manifest moves to
|
||||
* `manifestVersion: 8` without silently reinterpreting version 7.
|
||||
*
|
||||
* Configuration is declared **statically, in this inert document**, rather than registered by
|
||||
* executable module code, for one decisive reason: the platform validates a module's configuration
|
||||
@@ -133,14 +141,13 @@ function reportDuplicates(
|
||||
* inside its own `start` hook. Discovery, `pnpm modules:validate`, and Doctor all read this without
|
||||
* importing a single line of module code, exactly as they always have.
|
||||
*
|
||||
* Version 6, and every other version, fails validation: there is no compatibility adapter, no
|
||||
* Version 7, and every other version, fails validation: there is no compatibility adapter, no
|
||||
* automatic conversion, and no dual acceptance, exactly as version 1 was rejected by version 2
|
||||
* (ADR 0006), version 2 by version 3 (ADR 0014), version 3 by version 4 (ADR 0021), and version 5
|
||||
* by version 6 (ADR 0033). One manifest document can therefore never be read as two different
|
||||
* versions. Upgrading a version 6 manifest is a one-character edit for a module that contributes
|
||||
* nothing: the new field defaults to empty.
|
||||
* (ADR 0006), version 2 by version 3 (ADR 0014), version 3 by version 4 (ADR 0021), and version 7
|
||||
* by version 8 (ADR 0050). One manifest document can therefore never be read as two different
|
||||
* versions. A version 7 package must be rebuilt; omitting `database` selects the legacy facade.
|
||||
*/
|
||||
export const MODULE_MANIFEST_VERSION = 7;
|
||||
export const MODULE_MANIFEST_VERSION = 8;
|
||||
|
||||
export const moduleManifestSchema = z
|
||||
.object({
|
||||
@@ -163,6 +170,7 @@ export const moduleManifestSchema = z
|
||||
* module code during discovery. A module that needs nothing configured omits the field. */
|
||||
configuration: z.array(moduleConfigurationDeclarationSchema).default(() => []),
|
||||
storage: moduleStorageSchema,
|
||||
database: moduleDatabaseDeclarationSchema.optional(),
|
||||
/** Compiled browser renderers this module offers to capabilities **other** modules provide
|
||||
* (ADR 0041). Inert data: Core validates identity, compatibility, and the artifact path, and
|
||||
* never learns what a contribution renders. A module that contributes nothing omits the field. */
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import parseSpdxExpression from "spdx-expression-parse";
|
||||
import { moduleIdSchema } from "./identifiers.js";
|
||||
import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js";
|
||||
|
||||
@@ -11,11 +12,30 @@ import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js
|
||||
*/
|
||||
|
||||
export const MODULE_PACKAGE_FORMAT = 1;
|
||||
export const MODULE_PACKAGE_LICENSE_FILENAME = "LICENSE";
|
||||
export const MODULE_PACKAGE_METADATA_FILENAME = "ekklesia.package.json";
|
||||
export const MODULE_PACKAGE_NOTICES_FILENAME = "THIRD_PARTY_NOTICES";
|
||||
/** The portable target: a package with no native or platform-specific file. */
|
||||
export const MODULE_PACKAGE_ANY_TARGET = "any";
|
||||
|
||||
/** The module publisher's package-level license declaration (ADR 0051). This validates SPDX syntax,
|
||||
* not whether Ekklesia approves, recommends, or considers the license legally compatible. */
|
||||
export const modulePackageLicenseExpressionSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(1024)
|
||||
.superRefine((value, context) => {
|
||||
if (value === "UNLICENSED") {
|
||||
context.addIssue({ code: "custom", message: "Must be an SPDX license expression." });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
parseSpdxExpression(value);
|
||||
} catch {
|
||||
context.addIssue({ code: "custom", message: "Must be an SPDX license expression." });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* `any`, or `<platform>-<arch>[-<libc>]` in Node.js's own `process.platform` and `process.arch`
|
||||
* vocabulary. On Linux the C library is required, because a native addon built against one does
|
||||
@@ -87,10 +107,10 @@ const ekklesiaPackageNameSchema = z
|
||||
* (ADR 0049). Strict at every level: a reader that finds a fact it does not understand must refuse
|
||||
* the package rather than ignore it.
|
||||
*
|
||||
* `databaseRuntime` records the Drizzle version bundled with the module's backend, because Core runs
|
||||
* a module's queries through its own Drizzle instance over tables the module defined with its own
|
||||
* copy. That is an implementation constraint pending a Module Database ABI decision, **not** a
|
||||
* database contract a module may rely on.
|
||||
* For a manifest-v8 module that omits `database`, `databaseRuntime` records the Drizzle version
|
||||
* bundled with the module's backend because Core runs that legacy module's queries through its own
|
||||
* Drizzle instance over the module's table objects. ABI-v1 packages must omit this fact: their ORM
|
||||
* stays module-side and Core receives only the stable SQL ABI.
|
||||
*/
|
||||
const modulePackageTransitionalSchema = z.strictObject({
|
||||
databaseRuntime: z
|
||||
@@ -104,6 +124,8 @@ export const modulePackageMetadataSchema = z
|
||||
.strictObject({
|
||||
packageFormat: z.literal(MODULE_PACKAGE_FORMAT),
|
||||
module: z.strictObject({ id: moduleIdSchema, version: semanticVersionSchema }),
|
||||
/** The module's authoritative package-release license declaration (ADR 0051). */
|
||||
license: modulePackageLicenseExpressionSchema,
|
||||
target: modulePackageTargetSchema,
|
||||
runtime: modulePackageRuntimeSchema,
|
||||
provenance: z.strictObject({
|
||||
@@ -124,6 +146,16 @@ export const modulePackageMetadataSchema = z
|
||||
files: z.array(modulePackageFileSchema),
|
||||
})
|
||||
.superRefine((metadata, context) => {
|
||||
const licenseFiles = metadata.files.filter(
|
||||
(file) => file.path === MODULE_PACKAGE_LICENSE_FILENAME,
|
||||
);
|
||||
if (licenseFiles.length !== 1 || licenseFiles[0]?.size === 0) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "The payload must contain exactly one non-empty root LICENSE file.",
|
||||
path: ["files"],
|
||||
});
|
||||
}
|
||||
if (
|
||||
metadata.target === MODULE_PACKAGE_ANY_TARGET &&
|
||||
(metadata.runtime.nodeApi !== undefined ||
|
||||
@@ -136,6 +168,24 @@ export const modulePackageMetadataSchema = z
|
||||
path: ["runtime"],
|
||||
});
|
||||
}
|
||||
if (metadata.target !== MODULE_PACKAGE_ANY_TARGET) {
|
||||
// ADR 0049: a native package states what its addons need from Node.js and, on Linux, the
|
||||
// minimum C library version.
|
||||
if (metadata.runtime.nodeApi === undefined && metadata.runtime.nodeAbi === undefined) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "A native package states the Node-API version or Node.js ABI its addons need.",
|
||||
path: ["runtime"],
|
||||
});
|
||||
}
|
||||
if (metadata.target.startsWith("linux-") && metadata.runtime.libcVersion === undefined) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "A native Linux package states the minimum C library version it needs.",
|
||||
path: ["runtime", "libcVersion"],
|
||||
});
|
||||
}
|
||||
}
|
||||
for (let index = 1; index < metadata.files.length; index += 1) {
|
||||
const previous = metadata.files[index - 1]?.path ?? "";
|
||||
const current = metadata.files[index]?.path ?? "";
|
||||
@@ -157,6 +207,10 @@ export function safeParseModulePackageMetadata(input: unknown) {
|
||||
return modulePackageMetadataSchema.safeParse(input);
|
||||
}
|
||||
|
||||
export function safeParseModulePackageLicenseExpression(input: unknown) {
|
||||
return modulePackageLicenseExpressionSchema.safeParse(input);
|
||||
}
|
||||
|
||||
export function safeParseModulePackageTarget(input: unknown) {
|
||||
return modulePackageTargetSchema.safeParse(input);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { CapabilityId, ModuleId } from "./identifiers.js";
|
||||
import { moduleIdSchema } from "./identifiers.js";
|
||||
import type { ModuleConfiguration } from "./module-configuration.js";
|
||||
import type { ModuleRuntimeServices } from "./module-database.js";
|
||||
import type { ModuleDatabaseExecutor } from "./module-database-abi.js";
|
||||
import type { ModuleHttpRouteRegistrar } from "./module-http.js";
|
||||
import type { ModuleRealtimeChannelRegistrar, ModuleRealtimePublisher } from "./module-realtime.js";
|
||||
import type { ModuleStorage } from "./module-storage.js";
|
||||
@@ -24,12 +25,12 @@ export interface CapabilityRegistrar extends CapabilityResolver {
|
||||
provide(capabilityId: CapabilityId, implementation: unknown): void;
|
||||
}
|
||||
|
||||
export interface ModuleRegistrationContext {
|
||||
export interface ModuleRegistrationContext<Database = ModuleDatabaseExecutor> {
|
||||
readonly capabilities: CapabilityRegistrar;
|
||||
/** Available only during `register` — never on `ModuleRuntimeContext` — so a route can only ever
|
||||
* be declared while Core is still building the immutable startup snapshot, never added or removed
|
||||
* afterward from `start`, `stop`, or any other post-registration code path. */
|
||||
readonly http: ModuleHttpRouteRegistrar;
|
||||
readonly http: ModuleHttpRouteRegistrar<Database>;
|
||||
/** Declarative, registration-only channel publication. Live publishing is available only from
|
||||
* runtime and request-handler contexts. */
|
||||
readonly realtime: ModuleRealtimeChannelRegistrar;
|
||||
@@ -49,10 +50,10 @@ export interface ModuleRegistrationContext {
|
||||
* same frozen `services` object identity as the other, for a given API runtime — never a fresh
|
||||
* `ModuleRuntimeServices` built per hook invocation. Unlike `ModuleRegistrationContext`, this never
|
||||
* carries `http`: a route can only ever be declared during `register`. */
|
||||
export interface ModuleRuntimeContext {
|
||||
export interface ModuleRuntimeContext<Database = ModuleDatabaseExecutor> {
|
||||
readonly capabilities: CapabilityResolver;
|
||||
readonly moduleId: ModuleId;
|
||||
readonly services: ModuleRuntimeServices;
|
||||
readonly services: ModuleRuntimeServices<Database>;
|
||||
/**
|
||||
* This module's own scoped object storage (ADR 0030).
|
||||
*
|
||||
@@ -70,8 +71,8 @@ export interface ModuleRuntimeContext {
|
||||
/**
|
||||
* This module's own validated configuration, frozen for the lifetime of the process.
|
||||
*
|
||||
* Deliberately **not** a field on `ModuleRuntimeServices`: that object is one shared instance
|
||||
* every module receives, and configuration is per-module by definition. It sits beside `moduleId`
|
||||
* Deliberately **not** a field on `ModuleRuntimeServices`: that object holds live platform
|
||||
* services while configuration is a separately resolved snapshot. It sits beside `moduleId`
|
||||
* — the context's other per-module fact — so the scoping is visible in the shape rather than
|
||||
* asserted in a comment.
|
||||
*
|
||||
@@ -82,15 +83,31 @@ export interface ModuleRuntimeContext {
|
||||
readonly config: ModuleConfiguration;
|
||||
}
|
||||
|
||||
export interface ModuleRuntimeDefinition {
|
||||
interface ModuleRuntimeDefinitionBase<Database> {
|
||||
readonly id: ModuleId;
|
||||
readonly register?: (context: ModuleRegistrationContext) => Awaitable<void>;
|
||||
readonly start?: (context: ModuleRuntimeContext) => Awaitable<void>;
|
||||
readonly stop?: (context: ModuleRuntimeContext) => Awaitable<void>;
|
||||
readonly register?: (context: ModuleRegistrationContext<Database>) => Awaitable<void>;
|
||||
readonly start?: (context: ModuleRuntimeContext<Database>) => Awaitable<void>;
|
||||
readonly stop?: (context: ModuleRuntimeContext<Database>) => Awaitable<void>;
|
||||
readonly version: string;
|
||||
}
|
||||
|
||||
const runtimeDefinitionKeys = new Set(["id", "register", "start", "stop", "version"]);
|
||||
/** A module on the legacy database facade (ADR 0016), until its deprecation window ends (ADR 0050).
|
||||
* The facade's type is Core's own, so here its database is opaque: Core specializes `Database`. */
|
||||
export interface LegacyModuleRuntimeDefinition<Database = unknown>
|
||||
extends ModuleRuntimeDefinitionBase<Database> {
|
||||
readonly database?: never;
|
||||
}
|
||||
|
||||
export interface ModuleDatabaseAbiRuntimeDefinition
|
||||
extends ModuleRuntimeDefinitionBase<ModuleDatabaseExecutor> {
|
||||
readonly database: { readonly abi: 1 };
|
||||
}
|
||||
|
||||
export type ModuleRuntimeDefinition =
|
||||
| LegacyModuleRuntimeDefinition
|
||||
| ModuleDatabaseAbiRuntimeDefinition;
|
||||
|
||||
const runtimeDefinitionKeys = new Set(["database", "id", "register", "start", "stop", "version"]);
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
@@ -107,6 +124,14 @@ export function isModuleRuntimeDefinition(value: unknown): value is ModuleRuntim
|
||||
if (Object.keys(value).some((key) => !runtimeDefinitionKeys.has(key))) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
value.database !== undefined &&
|
||||
(!isRecord(value.database) ||
|
||||
Reflect.ownKeys(value.database).length !== 1 ||
|
||||
value.database.abi !== 1)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return [value.register, value.start, value.stop].every(
|
||||
(hook) => hook === undefined || typeof hook === "function",
|
||||
);
|
||||
@@ -116,11 +141,22 @@ export function parseModuleRuntimeDefinition(value: unknown): ModuleRuntimeDefin
|
||||
if (!isModuleRuntimeDefinition(value)) {
|
||||
throw new TypeError("Invalid Ekklesia module runtime definition.");
|
||||
}
|
||||
return Object.freeze({
|
||||
id: value.id,
|
||||
version: value.version,
|
||||
const hooks = {
|
||||
...(value.register ? { register: value.register } : {}),
|
||||
...(value.start ? { start: value.start } : {}),
|
||||
...(value.stop ? { stop: value.stop } : {}),
|
||||
});
|
||||
};
|
||||
if (value.database) {
|
||||
return Object.freeze({
|
||||
database: Object.freeze({ abi: 1 as const }),
|
||||
id: value.id,
|
||||
version: value.version,
|
||||
...hooks,
|
||||
}) as ModuleDatabaseAbiRuntimeDefinition;
|
||||
}
|
||||
return Object.freeze({
|
||||
id: value.id,
|
||||
version: value.version,
|
||||
...hooks,
|
||||
}) as LegacyModuleRuntimeDefinition;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { z } from "zod";
|
||||
import { moduleIdSchema } from "./identifiers.js";
|
||||
import { physicalModuleTableNameSchema } from "./module-schema-names.js";
|
||||
|
||||
export const moduleSchemaForeignKeyMetadataSchema = z
|
||||
.object({ table: physicalModuleTableNameSchema })
|
||||
.strict();
|
||||
|
||||
export const moduleSchemaTableMetadataSchema = z
|
||||
.object({
|
||||
name: physicalModuleTableNameSchema,
|
||||
foreignKeys: z.array(moduleSchemaForeignKeyMetadataSchema).default(() => []),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** The ABI-v1 schema entrypoint: inert Ekklesia-owned data, with no ORM object or executable
|
||||
* callback anywhere in its shape. Ownership and foreign-key targets are validated by Core against
|
||||
* the manifest module id and the complete table list. */
|
||||
export const moduleSchemaMetadataDefinitionSchema = z
|
||||
.object({
|
||||
moduleId: moduleIdSchema,
|
||||
tables: z.array(moduleSchemaTableMetadataSchema).min(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export interface ModuleSchemaForeignKeyMetadata {
|
||||
readonly table: string;
|
||||
}
|
||||
export interface ModuleSchemaTableMetadata {
|
||||
readonly name: string;
|
||||
readonly foreignKeys: readonly ModuleSchemaForeignKeyMetadata[];
|
||||
}
|
||||
export interface ModuleSchemaMetadataDefinition {
|
||||
readonly moduleId: z.output<typeof moduleIdSchema>;
|
||||
readonly tables: readonly ModuleSchemaTableMetadata[];
|
||||
}
|
||||
|
||||
function isPlainData(value: unknown): boolean {
|
||||
if (value === null || typeof value === "string") {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.every(isPlainData);
|
||||
}
|
||||
if (typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return (
|
||||
(prototype === Object.prototype || prototype === null) &&
|
||||
Reflect.ownKeys(value).every(
|
||||
(key) => typeof key === "string" && isPlainData((value as Record<string, unknown>)[key]),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function parseModuleSchemaMetadataDefinition(
|
||||
value: unknown,
|
||||
): ModuleSchemaMetadataDefinition {
|
||||
if (!isPlainData(value)) {
|
||||
throw new TypeError("Invalid Ekklesia module schema metadata definition.");
|
||||
}
|
||||
const parsed = moduleSchemaMetadataDefinitionSchema.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
throw new TypeError("Invalid Ekklesia module schema metadata definition.");
|
||||
}
|
||||
return Object.freeze({
|
||||
moduleId: parsed.data.moduleId,
|
||||
tables: Object.freeze(
|
||||
parsed.data.tables.map((table) =>
|
||||
Object.freeze({
|
||||
foreignKeys: Object.freeze(
|
||||
table.foreignKeys.map((foreignKey) => Object.freeze(foreignKey)),
|
||||
),
|
||||
name: table.name,
|
||||
}),
|
||||
),
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { z } from "zod";
|
||||
import { type ModuleId, moduleIdSchema } from "./identifiers.js";
|
||||
|
||||
export const POSTGRES_IDENTIFIER_MAX_LENGTH = 63;
|
||||
|
||||
export const moduleTableLocalNameSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(/^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/u, "Must be a lowercase snake-case table name.");
|
||||
|
||||
export const physicalModuleTableNameSchema = moduleTableLocalNameSchema.max(
|
||||
POSTGRES_IDENTIFIER_MAX_LENGTH,
|
||||
`Must not exceed ${POSTGRES_IDENTIFIER_MAX_LENGTH} characters.`,
|
||||
);
|
||||
|
||||
export function moduleTableNamespace(moduleId: ModuleId): string {
|
||||
return moduleIdSchema.parse(moduleId).replaceAll("-", "_");
|
||||
}
|
||||
|
||||
export function moduleTablePrefix(moduleId: ModuleId): string {
|
||||
return `${moduleTableNamespace(moduleId)}_`;
|
||||
}
|
||||
|
||||
export function moduleTableName(moduleId: ModuleId, localName: string): string {
|
||||
const parsedLocalName = moduleTableLocalNameSchema.parse(localName);
|
||||
const tableName = `${moduleTablePrefix(moduleId)}${parsedLocalName}`;
|
||||
if (!physicalModuleTableNameSchema.safeParse(tableName).success) {
|
||||
throw new RangeError("The namespaced PostgreSQL table name is invalid or too long.");
|
||||
}
|
||||
return tableName;
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { describe, expect, expectTypeOf, it } from "vitest";
|
||||
import {
|
||||
isModuleDatabaseError,
|
||||
MODULE_DATABASE_ABI_VERSION,
|
||||
MODULE_DATABASE_ERROR_CODES,
|
||||
type ModuleDatabaseError,
|
||||
ModuleDatabaseError as ModuleDatabaseErrorClass,
|
||||
type ModuleDatabaseExecutor,
|
||||
type ModuleDatabaseParameter,
|
||||
type ModuleDatabaseQueryable,
|
||||
type ModuleDatabaseTransaction,
|
||||
type ModuleDatabaseValue,
|
||||
} from "../src/index.js";
|
||||
|
||||
/**
|
||||
* Never invoked: proves at typecheck time that the ABI's executors carry exactly the ABI's members,
|
||||
* and that no ORM, driver, pool, or client escape hatch is reachable through their types. Re-adding
|
||||
* any of them would make an `@ts-expect-error` below unused, failing `pnpm typecheck`.
|
||||
*/
|
||||
function typeOnlyExclusions(
|
||||
executor: ModuleDatabaseExecutor,
|
||||
transaction: ModuleDatabaseTransaction,
|
||||
): void {
|
||||
// @ts-expect-error No raw pg pool or client.
|
||||
void executor.pool;
|
||||
// @ts-expect-error No raw pg client.
|
||||
void executor.$client;
|
||||
// @ts-expect-error No ORM internals.
|
||||
void executor._;
|
||||
// @ts-expect-error No query builder.
|
||||
void executor.select;
|
||||
// @ts-expect-error No relational query object.
|
||||
void executor.execute;
|
||||
// @ts-expect-error A transaction executor has no rollback(); a module throws instead.
|
||||
void transaction.rollback;
|
||||
// @ts-expect-error Nested transactions are savepoints, not transaction().
|
||||
void transaction.transaction;
|
||||
// @ts-expect-error Savepoints are not named by modules.
|
||||
void transaction.savepoint("name", async () => undefined);
|
||||
// @ts-expect-error A Date is not an ABI parameter.
|
||||
void executor.query({ text: "SELECT $1", values: [new Date()] });
|
||||
// @ts-expect-error A plain object is not implicit JSON.
|
||||
void executor.query({ text: "SELECT $1", values: [{ a: 1 }] });
|
||||
// @ts-expect-error undefined is not a parameter.
|
||||
void executor.query({ text: "SELECT $1", values: [undefined] });
|
||||
}
|
||||
void typeOnlyExclusions;
|
||||
|
||||
describe("Module Database ABI v1 types", () => {
|
||||
it("names exactly the ABI's members on every executor", () => {
|
||||
expectTypeOf<keyof ModuleDatabaseExecutor>().toEqualTypeOf<
|
||||
"abiVersion" | "query" | "transaction"
|
||||
>();
|
||||
expectTypeOf<keyof ModuleDatabaseTransaction>().toEqualTypeOf<
|
||||
"abiVersion" | "query" | "savepoint"
|
||||
>();
|
||||
expectTypeOf<keyof ModuleDatabaseQueryable>().toEqualTypeOf<"abiVersion" | "query">();
|
||||
expectTypeOf<ModuleDatabaseExecutor["abiVersion"]>().toEqualTypeOf<1>();
|
||||
expect(MODULE_DATABASE_ABI_VERSION).toBe(1);
|
||||
});
|
||||
|
||||
it("types array-mode rows positionally and object-mode rows by name", () => {
|
||||
expectTypeOf<Awaited<ReturnType<typeof arrayQuery>>["rows"][number]>().toEqualTypeOf<
|
||||
readonly ModuleDatabaseValue[]
|
||||
>();
|
||||
expectTypeOf<Awaited<ReturnType<typeof objectQuery>>["rows"][number]>().toEqualTypeOf<
|
||||
Readonly<Record<string, ModuleDatabaseValue>>
|
||||
>();
|
||||
});
|
||||
|
||||
it("keeps parameter and result values to PostgreSQL wire-safe primitives", () => {
|
||||
expectTypeOf<Date>().not.toMatchTypeOf<ModuleDatabaseParameter>();
|
||||
expectTypeOf<Record<string, number>>().not.toMatchTypeOf<ModuleDatabaseParameter>();
|
||||
expectTypeOf<undefined>().not.toMatchTypeOf<ModuleDatabaseParameter>();
|
||||
expectTypeOf<[bigint, Uint8Array, [string | null]]>().toMatchTypeOf<ModuleDatabaseParameter>();
|
||||
expectTypeOf<bigint>().not.toMatchTypeOf<ModuleDatabaseValue>();
|
||||
expectTypeOf<Date>().not.toMatchTypeOf<ModuleDatabaseValue>();
|
||||
});
|
||||
|
||||
it("imports nothing at all, so no ORM or driver type can reach the ABI", async () => {
|
||||
const source = await readFile(
|
||||
new URL("../src/module-database-abi.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
expect(source).not.toMatch(/^\s*import\b/mu);
|
||||
expect(source).not.toMatch(/\bfrom\s+["']/u);
|
||||
expect(source).not.toMatch(/drizzle|kysely|NodePg|PgTable|PgColumn/u);
|
||||
});
|
||||
});
|
||||
|
||||
function arrayQuery(executor: ModuleDatabaseExecutor) {
|
||||
return executor.query({ rowMode: "array", text: "SELECT 1" });
|
||||
}
|
||||
|
||||
function objectQuery(executor: ModuleDatabaseExecutor) {
|
||||
return executor.query({ text: "SELECT 1" });
|
||||
}
|
||||
|
||||
describe("ModuleDatabaseError", () => {
|
||||
it("separates the Ekklesia category from PostgreSQL's own SQLSTATE", () => {
|
||||
const failed = new ModuleDatabaseErrorClass("MODULE_DATABASE_QUERY_FAILED", {
|
||||
sqlState: "23505",
|
||||
});
|
||||
expect(failed.code).toBe("MODULE_DATABASE_QUERY_FAILED");
|
||||
expect(failed.sqlState).toBe("23505");
|
||||
|
||||
const closed = new ModuleDatabaseErrorClass("MODULE_DATABASE_SCOPE_CLOSED");
|
||||
expect(closed.code).toBe("MODULE_DATABASE_SCOPE_CLOSED");
|
||||
expect(closed.sqlState).toBeUndefined();
|
||||
expect("sqlState" in closed).toBe(false);
|
||||
});
|
||||
|
||||
it("never records anything but a five-character SQLSTATE", () => {
|
||||
for (const candidate of ["EPIPE1", "2350", "23505 ", "duplicate key", "econn"]) {
|
||||
const error = new ModuleDatabaseErrorClass("MODULE_DATABASE_QUERY_FAILED", {
|
||||
sqlState: candidate,
|
||||
});
|
||||
expect(error.sqlState).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("is sanitized by construction: a static message, its own stack line, no cause, frozen", () => {
|
||||
for (const code of MODULE_DATABASE_ERROR_CODES) {
|
||||
const error = new ModuleDatabaseErrorClass(code, { sqlState: "40001" });
|
||||
expect(error.name).toBe("ModuleDatabaseError");
|
||||
expect(error.stack).toBe(`ModuleDatabaseError: ${error.message}`);
|
||||
expect(error.cause).toBeUndefined();
|
||||
expect(Object.keys(error).sort()).toEqual(["code", "name", "sqlState"]);
|
||||
expect(Object.isFrozen(error)).toBe(true);
|
||||
expect(error.message).not.toMatch(/select|insert|\$1|password|postgres:\/\//iu);
|
||||
}
|
||||
});
|
||||
|
||||
it("has one distinct message per code", () => {
|
||||
const messages = MODULE_DATABASE_ERROR_CODES.map(
|
||||
(code) => new ModuleDatabaseErrorClass(code).message,
|
||||
);
|
||||
expect(new Set(messages).size).toBe(MODULE_DATABASE_ERROR_CODES.length);
|
||||
});
|
||||
|
||||
it("is recognized by shape, so a module's own bundled copy of this package agrees", () => {
|
||||
const fromAnotherCopy = Object.assign(new Error("The database request is not valid."), {
|
||||
code: "MODULE_DATABASE_REQUEST_INVALID",
|
||||
name: "ModuleDatabaseError",
|
||||
});
|
||||
expect(isModuleDatabaseError(fromAnotherCopy)).toBe(true);
|
||||
expect(
|
||||
isModuleDatabaseError(new ModuleDatabaseErrorClass("MODULE_DATABASE_SCOPE_CLOSED")),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isModuleDatabaseError({ code: "MODULE_DATABASE_SCOPE_CLOSED", name: "ModuleDatabaseError" }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isModuleDatabaseError(
|
||||
Object.assign(new Error("x"), { code: "SOMETHING_ELSE", name: "ModuleDatabaseError" }),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isModuleDatabaseError(
|
||||
Object.assign(new Error("x"), {
|
||||
code: "MODULE_DATABASE_QUERY_FAILED",
|
||||
name: "ModuleDatabaseError",
|
||||
sqlState: "not a sqlstate",
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(isModuleDatabaseError(Object.assign(new Error("x"), { code: "23505" }))).toBe(false);
|
||||
|
||||
const narrowed: unknown = fromAnotherCopy;
|
||||
if (isModuleDatabaseError(narrowed)) {
|
||||
expectTypeOf(narrowed.code).toEqualTypeOf<ModuleDatabaseError["code"]>();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
|
||||
function validManifest(): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id: "calendar",
|
||||
name: "Calendar",
|
||||
version: "0.1.0",
|
||||
@@ -42,6 +42,24 @@ function validManifest(): Record<string, unknown> {
|
||||
}
|
||||
|
||||
describe("module manifest contract", () => {
|
||||
it("accepts both manifest-v8 database modes", () => {
|
||||
expect(parseModuleManifest(validManifest()).database).toBeUndefined();
|
||||
expect(parseModuleManifest({ ...validManifest(), database: { abi: 1 } }).database).toEqual({
|
||||
abi: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects manifest v7, unsupported ABIs, malformed declarations, and unknown database fields", () => {
|
||||
expect(safeParseModuleManifest({ ...validManifest(), manifestVersion: 7 }).success).toBe(false);
|
||||
expect(safeParseModuleManifest({ ...validManifest(), database: { abi: 2 } }).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(safeParseModuleManifest({ ...validManifest(), database: "abi-v1" }).success).toBe(false);
|
||||
expect(
|
||||
safeParseModuleManifest({ ...validManifest(), database: { abi: 1, driver: "drizzle" } })
|
||||
.success,
|
||||
).toBe(false);
|
||||
});
|
||||
it("normalizes a valid manifest with safe defaults", () => {
|
||||
const manifest = parseModuleManifest(validManifest());
|
||||
|
||||
@@ -232,15 +250,15 @@ describe("module manifest contract", () => {
|
||||
expect(safeParseModuleManifest(version5Manifest).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a version 6 manifest rather than silently interpreting it as version 7", () => {
|
||||
it("rejects a version 6 manifest rather than silently interpreting it as version 8", () => {
|
||||
const version6Manifest = { ...validManifest(), manifestVersion: 6 };
|
||||
|
||||
expect(safeParseModuleManifest(version6Manifest).success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts exactly one manifest version, so no document can be read as two versions", () => {
|
||||
expect(MODULE_MANIFEST_VERSION).toBe(7);
|
||||
for (const manifestVersion of [1, 2, 3, 4, 5, 6, 8, "7", 7.5, null]) {
|
||||
expect(MODULE_MANIFEST_VERSION).toBe(8);
|
||||
for (const manifestVersion of [1, 2, 3, 4, 5, 6, 7, 9, "8", 8.5, null]) {
|
||||
expect(safeParseModuleManifest({ ...validManifest(), manifestVersion }).success).toBe(false);
|
||||
}
|
||||
expect(
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
MODULE_PACKAGE_FORMAT,
|
||||
MODULE_PACKAGE_LICENSE_FILENAME,
|
||||
MODULE_PACKAGE_METADATA_FILENAME,
|
||||
modulePackageArchiveFileName,
|
||||
modulePackageRuntimePackage,
|
||||
safeParseModulePackageLicenseExpression,
|
||||
safeParseModulePackageMetadata,
|
||||
safeParseModulePackageRuntimePackage,
|
||||
safeParseModulePackageTarget,
|
||||
} from "../src/index.js";
|
||||
} from "../src/module-package.js";
|
||||
|
||||
const digest = "a".repeat(64);
|
||||
|
||||
@@ -15,6 +17,7 @@ function metadata(overrides: Record<string, unknown> = {}): Record<string, unkno
|
||||
return {
|
||||
packageFormat: 1,
|
||||
module: { id: "example", version: "0.1.0" },
|
||||
license: "AGPL-3.0-or-later",
|
||||
target: "any",
|
||||
runtime: { node: ">=24.11.0 <25" },
|
||||
provenance: {
|
||||
@@ -24,6 +27,7 @@ function metadata(overrides: Record<string, unknown> = {}): Record<string, unkno
|
||||
incorporates: { "@ekklesia/contracts": "0.1.2", "@ekklesia/module-sdk": "0.1.2" },
|
||||
transitional: { databaseRuntime: { "drizzle-orm": "0.45.2" } },
|
||||
files: [
|
||||
{ path: "LICENSE", size: 9, sha256: digest },
|
||||
{ path: "THIRD_PARTY_NOTICES", size: 10, sha256: digest },
|
||||
{ path: "dist/server/index.js", size: 20, sha256: digest },
|
||||
{ path: "ekklesia.module.json", size: 30, sha256: digest },
|
||||
@@ -36,12 +40,46 @@ function metadata(overrides: Record<string, unknown> = {}): Record<string, unkno
|
||||
describe("Ekklesia Module Package v1 metadata (ADR 0049)", () => {
|
||||
it("names the format, its metadata file, and the recommended archive file name", () => {
|
||||
expect(MODULE_PACKAGE_FORMAT).toBe(1);
|
||||
expect(MODULE_PACKAGE_LICENSE_FILENAME).toBe("LICENSE");
|
||||
expect(MODULE_PACKAGE_METADATA_FILENAME).toBe("ekklesia.package.json");
|
||||
expect(modulePackageArchiveFileName("example", "0.1.0", "any")).toBe(
|
||||
"example-0.1.0-any.tar.gz",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts SPDX license expressions and rejects non-expressions without a policy allowlist", () => {
|
||||
for (const expression of [
|
||||
"AGPL-3.0-or-later",
|
||||
"MIT OR Apache-2.0",
|
||||
"(MIT OR Apache-2.0) AND BSD-3-Clause",
|
||||
]) {
|
||||
expect(safeParseModulePackageLicenseExpression(expression).success, expression).toBe(true);
|
||||
}
|
||||
for (const expression of ["", "UNLICENSED", "MIT OR", "not a license", {}]) {
|
||||
expect(safeParseModulePackageLicenseExpression(expression).success, String(expression)).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("requires the package license declaration and canonical LICENSE payload entry", () => {
|
||||
const withoutLicense = metadata();
|
||||
delete withoutLicense.license;
|
||||
expect(safeParseModulePackageMetadata(withoutLicense).success).toBe(false);
|
||||
|
||||
const withoutLicenseFile = metadata();
|
||||
withoutLicenseFile.files = (withoutLicenseFile.files as { path: string }[]).filter(
|
||||
(file) => file.path !== "LICENSE",
|
||||
);
|
||||
expect(safeParseModulePackageMetadata(withoutLicenseFile).success).toBe(false);
|
||||
|
||||
const emptyLicenseFile = metadata();
|
||||
emptyLicenseFile.files = (emptyLicenseFile.files as { path: string; size: number }[]).map(
|
||||
(file) => (file.path === "LICENSE" ? { ...file, size: 0 } : file),
|
||||
);
|
||||
expect(safeParseModulePackageMetadata(emptyLicenseFile).success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a complete any-target package description", () => {
|
||||
expect(safeParseModulePackageMetadata(metadata()).success).toBe(true);
|
||||
});
|
||||
@@ -97,7 +135,9 @@ describe("Ekklesia Module Package v1 metadata (ADR 0049)", () => {
|
||||
).toBe(false);
|
||||
// Byte-wise, not locale order: upper case sorts before lower case.
|
||||
expect(
|
||||
safeParseModulePackageMetadata(metadata({ files: [entry("THIRD"), entry("dist")] })).success,
|
||||
safeParseModulePackageMetadata(
|
||||
metadata({ files: [entry("LICENSE"), entry("THIRD"), entry("dist")] }),
|
||||
).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseModuleSchemaMetadataDefinition } from "../src/index.js";
|
||||
|
||||
describe("module schema metadata contract", () => {
|
||||
it("accepts and freezes plain ABI-v1 metadata", () => {
|
||||
const definition = parseModuleSchemaMetadataDefinition({
|
||||
moduleId: "calendar",
|
||||
tables: [
|
||||
{ name: "calendar_events" },
|
||||
{ name: "calendar_attendees", foreignKeys: [{ table: "calendar_events" }] },
|
||||
],
|
||||
});
|
||||
|
||||
expect(definition).toEqual({
|
||||
moduleId: "calendar",
|
||||
tables: [
|
||||
{ name: "calendar_events", foreignKeys: [] },
|
||||
{ name: "calendar_attendees", foreignKeys: [{ table: "calendar_events" }] },
|
||||
],
|
||||
});
|
||||
expect(Object.isFrozen(definition)).toBe(true);
|
||||
expect(Object.isFrozen(definition.tables)).toBe(true);
|
||||
expect(Object.isFrozen(definition.tables[1]?.foreignKeys)).toBe(true);
|
||||
});
|
||||
|
||||
it("strictly rejects malformed, executable, and class-backed metadata", () => {
|
||||
class Metadata {
|
||||
moduleId = "calendar";
|
||||
tables = [{ name: "calendar_events" }];
|
||||
}
|
||||
|
||||
for (const value of [
|
||||
{ moduleId: "calendar", tables: [] },
|
||||
{ moduleId: "calendar", tables: [{ name: "calendar_events", unknown: true }] },
|
||||
{ moduleId: "calendar", tables: [{ name: "calendar_events", foreignKeys: [{}] }] },
|
||||
{ moduleId: "calendar", tables: [{ name: "calendar_events", inspect: () => undefined }] },
|
||||
new Metadata(),
|
||||
]) {
|
||||
expect(() => parseModuleSchemaMetadataDefinition(value)).toThrow(
|
||||
"Invalid Ekklesia module schema metadata definition.",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user