This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Local development only. Replace every credential outside a developer workstation.
|
||||||
|
POSTGRES_DB=ekklesia
|
||||||
|
POSTGRES_USER=ekklesia
|
||||||
|
POSTGRES_PASSWORD=ekklesia-development-only
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
DATABASE_URL=postgresql://ekklesia:[email protected]:5432/ekklesia
|
||||||
|
|
||||||
|
S3_ACCESS_KEY=ekklesia-development
|
||||||
|
S3_SECRET_KEY=ekklesia-development-secret
|
||||||
|
S3_BUCKET=ekklesia-development
|
||||||
|
S3_ENDPOINT=http://127.0.0.1:8333
|
||||||
|
S3_PORT=8333
|
||||||
|
S3_REGION=us-east-1
|
||||||
|
S3_FORCE_PATH_STYLE=true
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.webp binary
|
||||||
|
*.woff2 binary
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verify:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
|
- 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: Verify repository
|
||||||
|
run: pnpm ci
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
node_modules/
|
||||||
|
.turbo/
|
||||||
|
coverage/
|
||||||
|
dist/
|
||||||
|
.next/
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
generated/*
|
||||||
|
!generated/.gitkeep
|
||||||
|
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
24.18.0
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
engine-strict=true
|
||||||
|
save-exact=true
|
||||||
|
save-workspace-protocol=rolling
|
||||||
|
shared-workspace-lockfile=true
|
||||||
|
strict-peer-dependencies=true
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
Ekklesia is being built in verified, reviewable phases. Changes should preserve the dependency
|
||||||
|
boundaries described in [repository-boundaries.md](docs/architecture/repository-boundaries.md).
|
||||||
|
|
||||||
|
Before opening a change, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm verify
|
||||||
|
```
|
||||||
|
|
||||||
|
If Docker is available, also validate the development infrastructure:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm infrastructure:config
|
||||||
|
```
|
||||||
|
|
||||||
|
## Package rules
|
||||||
|
|
||||||
|
- Use strict TypeScript for production packages.
|
||||||
|
- Expose supported entry points through package `exports`; do not import another package's
|
||||||
|
private source paths.
|
||||||
|
- Use `workspace:` ranges for internal package dependencies.
|
||||||
|
- Keep modules backend-only. Module packages cannot contain React, mobile, stylesheet, or other
|
||||||
|
UI source files.
|
||||||
|
- Put rendering, layout, navigation, accessibility, and all other presentation concerns in a
|
||||||
|
surface.
|
||||||
|
- Add or update tests when changing a contract or architectural rule.
|
||||||
|
- Record major architectural changes in an ADR rather than burying them in an implementation.
|
||||||
|
|
||||||
|
Generated registries and schemas will be produced by later phases. Do not add a manually
|
||||||
|
maintained feature-module registry.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Ekklesia
|
||||||
|
|
||||||
|
Ekklesia is an open, modular church technology platform for composing backend capabilities
|
||||||
|
into independently developed dashboards, websites, mobile apps, kiosks, displays, and other
|
||||||
|
surfaces.
|
||||||
|
|
||||||
|
## Current status
|
||||||
|
|
||||||
|
Phase 1 establishes the repository foundation only. It includes reproducible workspace
|
||||||
|
tooling, strict TypeScript defaults, executable architectural boundary checks, tests, CI, and
|
||||||
|
local PostgreSQL/S3-compatible infrastructure. Core runtime, module discovery, identity,
|
||||||
|
Module SDK, surfaces, and feature modules intentionally begin in later phases.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
cp .env.example .env
|
||||||
|
pnpm infrastructure:up
|
||||||
|
pnpm verify
|
||||||
|
```
|
||||||
|
|
||||||
|
The development services bind only to loopback:
|
||||||
|
|
||||||
|
- PostgreSQL: `127.0.0.1:5432`
|
||||||
|
- S3-compatible object storage: `127.0.0.1:8333`
|
||||||
|
|
||||||
|
The values in `.env.example` are development-only and must never be reused in a deployed
|
||||||
|
environment.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- [Platform architecture](docs/architecture/platform-architecture.md)
|
||||||
|
- [Repository boundaries](docs/architecture/repository-boundaries.md)
|
||||||
|
- [Repository workflow](docs/development/repository-workflow.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.
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/2.5.3/schema.json",
|
||||||
|
"files": {
|
||||||
|
"includes": ["**", "!tests/fixtures", "!pnpm-lock.yaml"]
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"indentStyle": "space",
|
||||||
|
"indentWidth": 2,
|
||||||
|
"lineWidth": 100
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"preset": "recommended"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"formatter": {
|
||||||
|
"quoteStyle": "double",
|
||||||
|
"semicolons": "always",
|
||||||
|
"trailingCommas": "all"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# 0001: Four-layer platform boundary
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-07-12
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Ekklesia must remain understandable and independently extensible after many backend capabilities
|
||||||
|
and user experiences have been added. A conventional feature monolith or UI-bearing plugin model
|
||||||
|
would couple business behavior to a particular presentation framework.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Adopt four explicit ownership layers: Core orchestration, backend Modules, presentation Surfaces,
|
||||||
|
and declarative Surface Builder configuration. Begin as a modular monolith. Modules expose data
|
||||||
|
and behavior only; every renderer and UI concern belongs to a surface.
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
- A conventional feature monolith was rejected because boundaries degrade as features grow.
|
||||||
|
- UI-bearing plugins were rejected because they bind capabilities to frameworks and visual design.
|
||||||
|
- Initial microservices were rejected because distribution adds failure modes before contracts
|
||||||
|
have been proven.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Stable contracts, SDK ports, generated composition, and automated boundary checks are required.
|
||||||
|
Some integrations take more deliberate service or event design, but modules and surfaces remain
|
||||||
|
independently replaceable.
|
||||||
|
|
||||||
|
## Migration implications
|
||||||
|
|
||||||
|
Future boundary violations must move behind public contracts before the affected package can be
|
||||||
|
treated as independently installable or upgradeable.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# 0002: pnpm workspace and strict TypeScript
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-07-12
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The platform needs atomic cross-package changes, reproducible installs, independent package
|
||||||
|
builds, and guardrails that do not force every surface onto one framework.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Use a pnpm workspace with one committed lockfile and `workspace:` ranges for internal packages.
|
||||||
|
Use Turborepo for task ordering and local caching. Standardize production packages on strict
|
||||||
|
TypeScript and native ESM, with separate Node, browser, and React Native base configurations. Use
|
||||||
|
Biome and Vitest for the initial repository quality gates.
|
||||||
|
|
||||||
|
Node 24 LTS and pnpm 11.12 are pinned for Phase 1. Package exports are the supported cross-package
|
||||||
|
API; aliases may not expose another package's private `src` tree.
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
Polyrepos would slow coordinated contract changes. npm and Yarn workspaces are viable but do not
|
||||||
|
match the selected pnpm workflow. A universal browser-oriented TypeScript configuration was
|
||||||
|
rejected because server and native packages have different resolution and library requirements.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Repository-wide changes are straightforward, while package manifests and automated checks must
|
||||||
|
prevent monorepo convenience from becoming hidden coupling. Remote cache and independent-release
|
||||||
|
automation are deferred until they solve an observed need.
|
||||||
|
|
||||||
|
## Migration implications
|
||||||
|
|
||||||
|
Packages can move to separate repositories later because consumers use package names and public
|
||||||
|
exports instead of filesystem aliases.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# 0003: Development infrastructure
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-07-12
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Developers need reproducible PostgreSQL and object-storage services without requiring host-level
|
||||||
|
database installation. Application source does not yet exist, so containerizing the whole
|
||||||
|
toolchain would add friction without value.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Run PostgreSQL 18.4 and an S3-compatible object store in Docker Compose. Pin reviewed image
|
||||||
|
versions, enable health checks, use persistent named volumes, bind host ports to loopback, and use
|
||||||
|
explicitly development-only credentials. Run Node applications on the host during normal
|
||||||
|
development.
|
||||||
|
|
||||||
|
This Compose file is a development baseline, not the production deployment topology.
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
Locally installed services make onboarding inconsistent. Containerizing editors and the complete
|
||||||
|
Node toolchain is unnecessary at this phase. Kubernetes is premature for a modular monolith
|
||||||
|
foundation.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Docker is required for infrastructure testing but not for code-only validation. Production
|
||||||
|
backups, high availability, secret injection, image digests, and deployment topology require a
|
||||||
|
later decision.
|
||||||
|
|
||||||
|
## Migration implications
|
||||||
|
|
||||||
|
The future Core storage service targets S3 behavior rather than a provider-specific client, so
|
||||||
|
development and production providers can change after contract testing.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# 0004: Replace unmaintained MinIO baseline
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-07-12
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The original Ekklesia brief selected MinIO as the default self-hosted S3-compatible service. The
|
||||||
|
upstream `minio/minio` repository was archived on April 25, 2026 and states that historical
|
||||||
|
community binary releases are no longer maintained. New church deployments may store highly
|
||||||
|
sensitive content, so beginning on an unmaintained security-sensitive service is unacceptable.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Keep Ekklesia's future storage contract provider-neutral and use SeaweedFS 4.28 in the Phase 1
|
||||||
|
development Compose environment. SeaweedFS is actively maintained and offers an S3-compatible
|
||||||
|
endpoint and a small single-node development mode.
|
||||||
|
|
||||||
|
Before treating any provider as production-ready, add storage contract tests covering put, get,
|
||||||
|
head, delete, ranges, multipart uploads, metadata/content type, signed URLs, bucket and prefix
|
||||||
|
isolation, and denied access. A production provider decision remains separate.
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
- Retaining the final MinIO community image was rejected because it no longer receives upstream
|
||||||
|
maintenance.
|
||||||
|
- Provider-specific Core APIs were rejected because they would couple every module to this choice.
|
||||||
|
- Deferring all object storage was rejected because a reproducible S3 development endpoint is part
|
||||||
|
of the repository foundation.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
The implementation intentionally uses `S3_*` configuration rather than `MINIO_*` names. Some
|
||||||
|
SeaweedFS behavior may differ from AWS S3 or historical MinIO and must be captured by contract
|
||||||
|
tests before the storage abstraction is implemented.
|
||||||
|
|
||||||
|
## Migration implications
|
||||||
|
|
||||||
|
No product data exists in Phase 1. Later provider changes must follow an object-copy and integrity
|
||||||
|
verification plan while preserving stable Core storage contracts.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Architecture decision records
|
||||||
|
|
||||||
|
ADRs capture durable choices that affect more than one package or phase.
|
||||||
|
|
||||||
|
- Filenames use a four-digit sequence followed by a short kebab-case title.
|
||||||
|
- Status is `Proposed`, `Accepted`, `Superseded`, or `Rejected`.
|
||||||
|
- Accepted ADRs are immutable except for status and cross-reference corrections.
|
||||||
|
- A changed decision receives a new ADR that links to and supersedes the old one.
|
||||||
|
|
||||||
|
## Index
|
||||||
|
|
||||||
|
- [0001: Four-layer platform boundary](0001-four-layer-platform-boundary.md) — Accepted
|
||||||
|
- [0002: pnpm workspace and strict TypeScript](0002-pnpm-workspace-and-typescript.md) — Accepted
|
||||||
|
- [0003: Development infrastructure](0003-development-infrastructure.md) — Accepted
|
||||||
|
- [0004: Replace unmaintained MinIO baseline](0004-replace-unmaintained-minio.md) — Accepted
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Platform architecture
|
||||||
|
|
||||||
|
Ekklesia is a long-lived platform from which churches can compose many applications and user
|
||||||
|
experiences. It begins as a modular monolith so contracts can stabilize before distribution is
|
||||||
|
introduced.
|
||||||
|
|
||||||
|
## Dependency model
|
||||||
|
|
||||||
|
The arrows below mean "depends on." Presentation and composition depend on stable lower-level
|
||||||
|
contracts; infrastructure never learns individual feature names.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Surface Builder configuration -> Surfaces -> public capability contracts
|
||||||
|
^
|
||||||
|
|
|
||||||
|
HTTP/API and client contracts
|
||||||
|
|
|
||||||
|
Feature Modules -> Module SDK / platform contracts <- Core implementations
|
||||||
|
```
|
||||||
|
|
||||||
|
The four ownership layers are:
|
||||||
|
|
||||||
|
1. **Core** orchestrates generic platform infrastructure: startup, identity, permissions,
|
||||||
|
configuration, database connectivity, storage, events, jobs, module discovery, and lifecycle.
|
||||||
|
2. **Modules** own backend domain behavior, data schemas, resources, actions, routes, events,
|
||||||
|
permissions, and jobs.
|
||||||
|
3. **Surfaces** own every presentation concern: components, renderers, layout, navigation,
|
||||||
|
accessibility, interaction, branding, and client state.
|
||||||
|
4. **Surface Builder** stores validated declarative composition. It selects capabilities and
|
||||||
|
surface-owned renderers but contains neither executable code nor module business logic.
|
||||||
|
|
||||||
|
Modules do not own UI. For example, Calendar data, actions, and permissions will live in
|
||||||
|
`modules/calendar`; Calendar renderers for a dashboard or public website will live in those
|
||||||
|
surfaces.
|
||||||
|
|
||||||
|
## Stable seams
|
||||||
|
|
||||||
|
- Modules use public SDK ports and injected Core services, never undocumented Core internals.
|
||||||
|
- Modules do not import another module's schema or private code. Integrations use public service
|
||||||
|
contracts, capabilities, events, or APIs.
|
||||||
|
- Surfaces consume API/client and capability contracts, never feature-module packages.
|
||||||
|
- Core discovers feature modules generically. It contains no feature-specific imports or
|
||||||
|
conditional branches.
|
||||||
|
- Deployable apps are thin composition roots and do not become domain-logic owners.
|
||||||
|
- Generated bootstrap artifacts may connect discovered entry points at the application edge;
|
||||||
|
they do not become manually maintained Core registries.
|
||||||
|
|
||||||
|
## Security invariants
|
||||||
|
|
||||||
|
- Authorization is enforced server-side.
|
||||||
|
- Secrets are isolated from public configuration and logs.
|
||||||
|
- Authentication tokens, private form content, prayer requests, and pastoral information are not
|
||||||
|
logged.
|
||||||
|
- Sensitive actions are designed for auditability without copying sensitive content into audit
|
||||||
|
records.
|
||||||
|
- Private objects use signed access and namespaced storage paths.
|
||||||
|
- Every network service starts from least-privilege and local-only development defaults.
|
||||||
|
|
||||||
|
## Phase 1 status
|
||||||
|
|
||||||
|
Implemented now: monorepo/tooling, strict TypeScript policy, repository-boundary validation,
|
||||||
|
tests, base CI, PostgreSQL, and a development S3-compatible object store.
|
||||||
|
|
||||||
|
Target architecture, not yet implemented: Core runtime, module discovery and lifecycle, Drizzle
|
||||||
|
schema aggregation/migrations, Better Auth, permissions, storage service, Module SDK, Surface
|
||||||
|
SDK, capability/resource/action registries, API server, dashboard, Builder, and reference modules.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Repository boundaries
|
||||||
|
|
||||||
|
These rules map platform ownership to filesystem and package boundaries. The executable check is
|
||||||
|
`pnpm boundaries`; CI must reject violations.
|
||||||
|
|
||||||
|
| Area | Owns | May depend on |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `apps/*` | Thin deployable composition roots | Public Core APIs, generated bootstrap, or a surface as required by the app |
|
||||||
|
| `core` | Feature-agnostic platform implementations | Public contracts and external infrastructure libraries |
|
||||||
|
| `modules/*` | Backend domain capability and data | Module SDK, public contracts, and external backend libraries |
|
||||||
|
| `surfaces/*` | UI, renderers, navigation, and interaction | Surface SDK, API/auth clients, public contracts, and design packages |
|
||||||
|
| `builders/*` | Declarative surface composition tooling | Builder/surface SDKs and public contracts |
|
||||||
|
| `packages/*` | Explicitly scoped SDKs, contracts, clients, design, testing, or tooling | Only dependencies appropriate to the package's declared level |
|
||||||
|
| `templates/module` | A small example backend module | The same dependencies allowed for any module |
|
||||||
|
|
||||||
|
## Mandatory rules
|
||||||
|
|
||||||
|
- Core never imports a feature module, surface, Builder implementation, or app.
|
||||||
|
- A module never imports Core implementations, another module, a surface, Builder code, or an app.
|
||||||
|
- A module cannot contain `.tsx`, `.jsx`, `.css`, `.scss`, `.less`, `.vue`, or `.svelte` source
|
||||||
|
and cannot depend on a UI framework. Modules expose data and behavior only.
|
||||||
|
- A surface never imports Core or a feature module. It uses public API/client contracts.
|
||||||
|
- Separate surfaces do not import each other's internals.
|
||||||
|
- Builder code composes contracts and surface renderer identifiers; it does not import module or
|
||||||
|
surface internals.
|
||||||
|
- Contract and SDK packages cannot depend upward on implementations.
|
||||||
|
- Workspace package consumers use declared `exports`, not private subpaths.
|
||||||
|
- Circular source and workspace-package dependencies are rejected.
|
||||||
|
- Cross-module data access uses services, events, capabilities, or opaque identifiers rather than
|
||||||
|
direct table imports and foreign keys.
|
||||||
|
|
||||||
|
The boundary test suite includes intentionally invalid fixture graphs. This proves that the guard
|
||||||
|
rejects forbidden dependency directions even before production packages fill every directory.
|
||||||
|
|
||||||
|
## Generated code
|
||||||
|
|
||||||
|
Generated artifacts are reproducible outputs with a named generator and validation step. A
|
||||||
|
generated module bootstrap may import discovered module entry points at an application
|
||||||
|
composition edge, but Core itself remains feature-agnostic. Generated files are never an excuse
|
||||||
|
for a hand-maintained central module registry.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Repository workflow
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js 24 LTS (`.node-version` and `.nvmrc` pin the reviewed patch)
|
||||||
|
- pnpm 11.12.0
|
||||||
|
- Docker with the Compose plugin for local infrastructure
|
||||||
|
|
||||||
|
Install dependencies from the committed lockfile:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quality gates
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm format:check # formatting without mutation
|
||||||
|
pnpm lint # Biome recommended rules
|
||||||
|
pnpm boundaries # architectural directions, UI ownership, and cycles
|
||||||
|
pnpm typecheck # strict TypeScript
|
||||||
|
pnpm test # Vitest
|
||||||
|
pnpm build # Turborepo package build graph
|
||||||
|
pnpm verify # every code gate above
|
||||||
|
```
|
||||||
|
|
||||||
|
CI runs `pnpm ci`, which adds Compose configuration validation to `pnpm verify`.
|
||||||
|
|
||||||
|
## Development infrastructure
|
||||||
|
|
||||||
|
Create an untracked local environment file before using Compose:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
pnpm infrastructure:up
|
||||||
|
```
|
||||||
|
|
||||||
|
Stop services or follow their logs with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm infrastructure:down
|
||||||
|
pnpm infrastructure:logs
|
||||||
|
```
|
||||||
|
|
||||||
|
PostgreSQL and the S3 endpoint bind to `127.0.0.1` only. Compose uses persistent named volumes;
|
||||||
|
`infrastructure:down` preserves data. The S3 service creates the configured development bucket
|
||||||
|
idempotently at startup.
|
||||||
|
|
||||||
|
The checked-in credentials are safe only for local development. Deployed secrets must come from
|
||||||
|
an environment-appropriate secret provider and must not be committed.
|
||||||
|
|
||||||
|
Docker is not installed in every contributor environment. `pnpm infrastructure:config` and live
|
||||||
|
service health therefore run separately from the code-only `pnpm verify` gate.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
name: ekklesia-development
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:18.4-alpine3.24
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:?POSTGRES_DB is required}
|
||||||
|
POSTGRES_INITDB_ARGS: --data-checksums
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:?POSTGRES_USER is required}
|
||||||
|
TZ: UTC
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${POSTGRES_PORT:-5432}:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
object-storage:
|
||||||
|
image: chrislusf/seaweedfs:4.28
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ["mini", "-dir=/data"]
|
||||||
|
environment:
|
||||||
|
AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY:?S3_ACCESS_KEY is required}
|
||||||
|
AWS_SECRET_ACCESS_KEY: ${S3_SECRET_KEY:?S3_SECRET_KEY is required}
|
||||||
|
S3_BUCKET: ${S3_BUCKET:?S3_BUCKET is required}
|
||||||
|
TZ: UTC
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${S3_PORT:-8333}:8333"
|
||||||
|
volumes:
|
||||||
|
- object-storage-data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:9333/cluster/status"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 18
|
||||||
|
start_period: 15s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
object-storage-data:
|
||||||
|
postgres-data:
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "ekklesia",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Open, modular church technology platform",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24.11.0 <25",
|
||||||
|
"pnpm": ">=11.12.0 <12"
|
||||||
|
},
|
||||||
|
"packageManager": "[email protected]",
|
||||||
|
"scripts": {
|
||||||
|
"boundaries": "node scripts/check-boundaries.ts",
|
||||||
|
"build": "turbo run build",
|
||||||
|
"ci": "pnpm verify && pnpm infrastructure:config",
|
||||||
|
"format": "biome format --write .",
|
||||||
|
"format:check": "biome format .",
|
||||||
|
"infrastructure:config": "docker compose --env-file .env.example -f infrastructure/compose.yaml config --quiet",
|
||||||
|
"infrastructure:down": "docker compose --env-file .env -f infrastructure/compose.yaml down",
|
||||||
|
"infrastructure:logs": "docker compose --env-file .env -f infrastructure/compose.yaml logs --follow",
|
||||||
|
"infrastructure:up": "docker compose --env-file .env -f infrastructure/compose.yaml up --detach --wait",
|
||||||
|
"lint": "biome lint .",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"typecheck": "tsc --noEmit --project tsconfig.json",
|
||||||
|
"verify": "pnpm format:check && pnpm lint && pnpm boundaries && pnpm typecheck && pnpm test && pnpm build"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "2.5.3",
|
||||||
|
"@types/node": "24.13.3",
|
||||||
|
"turbo": "2.10.4",
|
||||||
|
"typescript": "7.0.2",
|
||||||
|
"vitest": "4.1.10",
|
||||||
|
"yaml": "2.9.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/tsconfig",
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowUnreachableCode": false,
|
||||||
|
"allowUnusedLabels": false,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"useUnknownInCatchVariables": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/tsconfig",
|
||||||
|
"extends": "./base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsx": "preserve",
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ES2024"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"target": "ES2022"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/tsconfig",
|
||||||
|
"extends": "./base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["ES2024"],
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"target": "ES2024",
|
||||||
|
"types": ["node"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/typescript-config",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"files": [
|
||||||
|
"base.json",
|
||||||
|
"browser.json",
|
||||||
|
"node.json",
|
||||||
|
"react-native.json"
|
||||||
|
],
|
||||||
|
"exports": {
|
||||||
|
"./base.json": "./base.json",
|
||||||
|
"./browser.json": "./browser.json",
|
||||||
|
"./node.json": "./node.json",
|
||||||
|
"./react-native.json": "./react-native.json"
|
||||||
|
},
|
||||||
|
"ekklesia": {
|
||||||
|
"layer": "tooling"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/tsconfig",
|
||||||
|
"extends": "./base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"lib": ["ES2024"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"target": "ES2022"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1155
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
|||||||
|
packages:
|
||||||
|
- "apps/*"
|
||||||
|
- "core"
|
||||||
|
- "modules/*"
|
||||||
|
- "packages/*"
|
||||||
|
- "surfaces/*"
|
||||||
|
- "templates/*"
|
||||||
|
|
||||||
|
onlyBuiltDependencies:
|
||||||
|
- esbuild
|
||||||
@@ -0,0 +1,498 @@
|
|||||||
|
import { readdir, readFile, stat } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
|
||||||
|
type Layer = "app" | "builder" | "core" | "module" | "neutral" | "shared" | "surface";
|
||||||
|
|
||||||
|
interface Location {
|
||||||
|
layer: Layer;
|
||||||
|
owner: string;
|
||||||
|
relativePath: string;
|
||||||
|
role?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PackageManifest {
|
||||||
|
dependencies?: Record<string, string>;
|
||||||
|
devDependencies?: Record<string, string>;
|
||||||
|
ekklesia?: { layer?: string };
|
||||||
|
exports?: unknown;
|
||||||
|
name?: string;
|
||||||
|
optionalDependencies?: Record<string, string>;
|
||||||
|
peerDependencies?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkspacePackage {
|
||||||
|
directory: string;
|
||||||
|
location: Location;
|
||||||
|
manifest: PackageManifest;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoundaryViolation {
|
||||||
|
from: string;
|
||||||
|
message: string;
|
||||||
|
rule: string;
|
||||||
|
to?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoundaryResult {
|
||||||
|
filesChecked: number;
|
||||||
|
packagesChecked: number;
|
||||||
|
violations: BoundaryViolation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const CODE_EXTENSIONS = new Set([".cjs", ".cts", ".js", ".jsx", ".mjs", ".mts", ".ts", ".tsx"]);
|
||||||
|
const MODULE_UI_EXTENSIONS = new Set([".css", ".jsx", ".less", ".scss", ".svelte", ".tsx", ".vue"]);
|
||||||
|
const MODULE_UI_PACKAGES = new Set([
|
||||||
|
"@angular/core",
|
||||||
|
"expo",
|
||||||
|
"next",
|
||||||
|
"react",
|
||||||
|
"react-dom",
|
||||||
|
"react-native",
|
||||||
|
"solid-js",
|
||||||
|
"svelte",
|
||||||
|
"tailwindcss",
|
||||||
|
"vue",
|
||||||
|
]);
|
||||||
|
const IGNORED_DIRECTORIES = new Set([
|
||||||
|
".git",
|
||||||
|
".next",
|
||||||
|
".turbo",
|
||||||
|
"coverage",
|
||||||
|
"dist",
|
||||||
|
"node_modules",
|
||||||
|
]);
|
||||||
|
const DEPENDENCY_FIELDS = [
|
||||||
|
"dependencies",
|
||||||
|
"devDependencies",
|
||||||
|
"optionalDependencies",
|
||||||
|
"peerDependencies",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function normalizePath(value: string): string {
|
||||||
|
return value.split(path.sep).join("/").replace(/^\.\//, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function describeRepositoryPath(relativePath: string): Location {
|
||||||
|
const normalized = normalizePath(relativePath);
|
||||||
|
const [area, owner = area] = normalized.split("/");
|
||||||
|
|
||||||
|
if (area === "core") {
|
||||||
|
return { layer: "core", owner: "core", relativePath: normalized };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (area === "modules" && owner) {
|
||||||
|
return { layer: "module", owner, relativePath: normalized };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (area === "surfaces" && owner) {
|
||||||
|
return { layer: "surface", owner, relativePath: normalized };
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((area === "builders" || area === "surface-builder") && owner) {
|
||||||
|
return { layer: "builder", owner, relativePath: normalized };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (area === "apps" && owner) {
|
||||||
|
return { layer: "app", owner, relativePath: normalized };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (area === "packages" && owner) {
|
||||||
|
return { layer: "shared", owner, relativePath: normalized, role: owner };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (area === "templates" && owner === "module") {
|
||||||
|
return { layer: "module", owner: "template-module", relativePath: normalized };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { layer: "neutral", owner: area ?? "root", relativePath: normalized };
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundaryRule(from: Location, to: Location): string | undefined {
|
||||||
|
if (from.layer === "neutral" || to.layer === "neutral") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (from.layer === to.layer && from.owner === to.owner) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (from.layer === "core" && ["app", "builder", "module", "surface"].includes(to.layer)) {
|
||||||
|
return "core-is-feature-agnostic";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (from.layer === "module") {
|
||||||
|
if (["app", "builder", "core", "surface"].includes(to.layer)) {
|
||||||
|
return "modules-use-public-platform-contracts";
|
||||||
|
}
|
||||||
|
if (to.layer === "module" && from.owner !== to.owner) {
|
||||||
|
return "modules-do-not-import-other-modules";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (from.layer === "surface") {
|
||||||
|
if (["app", "builder", "core", "module"].includes(to.layer)) {
|
||||||
|
return "surfaces-use-public-contracts";
|
||||||
|
}
|
||||||
|
if (to.layer === "surface" && from.owner !== to.owner) {
|
||||||
|
return "surfaces-are-independent";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (from.layer === "builder" && ["app", "core", "module", "surface"].includes(to.layer)) {
|
||||||
|
return "builder-composes-contracts";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
from.layer === "shared" &&
|
||||||
|
from.role === "contracts" &&
|
||||||
|
["app", "builder", "core", "module", "surface"].includes(to.layer)
|
||||||
|
) {
|
||||||
|
return "contracts-do-not-depend-upward";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
from.layer === "shared" &&
|
||||||
|
["module-sdk", "surface-sdk"].includes(from.role ?? "") &&
|
||||||
|
["app", "builder", "core", "module", "surface"].includes(to.layer)
|
||||||
|
) {
|
||||||
|
return "sdks-do-not-depend-on-implementations";
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function packageNameFromSpecifier(specifier: string): string {
|
||||||
|
if (specifier.startsWith("@")) {
|
||||||
|
return specifier.split("/").slice(0, 2).join("/");
|
||||||
|
}
|
||||||
|
return specifier.split("/")[0] ?? specifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isModuleUiPackage(packageName: string): boolean {
|
||||||
|
return MODULE_UI_PACKAGES.has(packageName) || packageName.startsWith("@angular/");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isFile(filePath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return (await stat(filePath)).isFile();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function walkFiles(root: string, current = root): Promise<string[]> {
|
||||||
|
const entries = await readdir(current, { withFileTypes: true });
|
||||||
|
const files: string[] = [];
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isDirectory() && IGNORED_DIRECTORIES.has(entry.name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const absolutePath = path.join(current, entry.name);
|
||||||
|
const relativePath = normalizePath(path.relative(root, absolutePath));
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (relativePath === "tests/fixtures" || relativePath.startsWith("tests/fixtures/")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
files.push(...(await walkFiles(root, absolutePath)));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.isFile()) {
|
||||||
|
files.push(absolutePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectImportSpecifiers(sourceText: string): string[] {
|
||||||
|
const specifiers = new Set<string>();
|
||||||
|
const staticImport =
|
||||||
|
/\b(?:import|export)\s+(?:type\s+)?(?:[^"'\n;]*?\s+from\s+)?["']([^"'\n]+)["']/gu;
|
||||||
|
const dynamicImport = /\b(?:import|require)\s*\(\s*["']([^"'\n]+)["']\s*\)/gu;
|
||||||
|
|
||||||
|
for (const pattern of [staticImport, dynamicImport]) {
|
||||||
|
for (const match of sourceText.matchAll(pattern)) {
|
||||||
|
const specifier = match[1];
|
||||||
|
if (specifier) {
|
||||||
|
specifiers.add(specifier);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...specifiers];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveSourceImport(
|
||||||
|
fromFile: string,
|
||||||
|
specifier: string,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
if (!specifier.startsWith(".")) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = path.resolve(path.dirname(fromFile), specifier);
|
||||||
|
const extension = path.extname(candidate);
|
||||||
|
const withoutExtension = extension ? candidate.slice(0, -extension.length) : candidate;
|
||||||
|
const candidates = [candidate];
|
||||||
|
|
||||||
|
if ([".js", ".jsx", ".mjs", ".cjs"].includes(extension)) {
|
||||||
|
candidates.push(
|
||||||
|
`${withoutExtension}.ts`,
|
||||||
|
`${withoutExtension}.tsx`,
|
||||||
|
`${withoutExtension}.mts`,
|
||||||
|
`${withoutExtension}.cts`,
|
||||||
|
);
|
||||||
|
} else if (!extension) {
|
||||||
|
for (const codeExtension of CODE_EXTENSIONS) {
|
||||||
|
candidates.push(
|
||||||
|
`${candidate}${codeExtension}`,
|
||||||
|
path.join(candidate, `index${codeExtension}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const possibleFile of candidates) {
|
||||||
|
if (await isFile(possibleFile)) {
|
||||||
|
return possibleFile;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findCycles(graph: Map<string, Set<string>>): string[][] {
|
||||||
|
const visited = new Set<string>();
|
||||||
|
const active = new Set<string>();
|
||||||
|
const stack: string[] = [];
|
||||||
|
const cycles: string[][] = [];
|
||||||
|
const seenCycles = new Set<string>();
|
||||||
|
|
||||||
|
function visit(node: string): void {
|
||||||
|
if (active.has(node)) {
|
||||||
|
const start = stack.indexOf(node);
|
||||||
|
const cycle = [...stack.slice(start), node];
|
||||||
|
const key = [...new Set(cycle)].sort().join("|");
|
||||||
|
if (!seenCycles.has(key)) {
|
||||||
|
seenCycles.add(key);
|
||||||
|
cycles.push(cycle);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visited.has(node)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
visited.add(node);
|
||||||
|
active.add(node);
|
||||||
|
stack.push(node);
|
||||||
|
for (const target of graph.get(node) ?? []) {
|
||||||
|
if (graph.has(target)) {
|
||||||
|
visit(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stack.pop();
|
||||||
|
active.delete(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const node of graph.keys()) {
|
||||||
|
visit(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
return cycles;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportedSubpath(
|
||||||
|
manifest: PackageManifest,
|
||||||
|
specifier: string,
|
||||||
|
packageName: string,
|
||||||
|
): boolean {
|
||||||
|
const suffix = specifier.slice(packageName.length);
|
||||||
|
if (!suffix) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!manifest.exports || typeof manifest.exports !== "object") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requested = `.${suffix}`;
|
||||||
|
return Object.hasOwn(manifest.exports, requested);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkBoundaries(rootDirectory: string): Promise<BoundaryResult> {
|
||||||
|
const root = path.resolve(rootDirectory);
|
||||||
|
const allFiles = await walkFiles(root);
|
||||||
|
const manifestFiles = allFiles.filter((file) => path.basename(file) === "package.json");
|
||||||
|
const codeFiles = allFiles.filter((file) => CODE_EXTENSIONS.has(path.extname(file)));
|
||||||
|
const workspacePackages = new Map<string, WorkspacePackage>();
|
||||||
|
const packageByDirectory = new Map<string, WorkspacePackage>();
|
||||||
|
const violations: BoundaryViolation[] = [];
|
||||||
|
|
||||||
|
for (const manifestFile of manifestFiles) {
|
||||||
|
const manifest = JSON.parse(await readFile(manifestFile, "utf8")) as PackageManifest;
|
||||||
|
if (!manifest.name || manifestFile === path.join(root, "package.json")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const directory = path.dirname(manifestFile);
|
||||||
|
const relativeDirectory = normalizePath(path.relative(root, directory));
|
||||||
|
const location = describeRepositoryPath(relativeDirectory);
|
||||||
|
if (manifest.ekklesia?.layer) {
|
||||||
|
location.role = manifest.ekklesia.layer;
|
||||||
|
}
|
||||||
|
const workspacePackage = { directory, location, manifest, name: manifest.name };
|
||||||
|
workspacePackages.set(manifest.name, workspacePackage);
|
||||||
|
packageByDirectory.set(directory, workspacePackage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const packageGraph = new Map<string, Set<string>>();
|
||||||
|
for (const workspacePackage of workspacePackages.values()) {
|
||||||
|
packageGraph.set(workspacePackage.name, new Set());
|
||||||
|
for (const dependencyField of DEPENDENCY_FIELDS) {
|
||||||
|
for (const dependencyName of Object.keys(workspacePackage.manifest[dependencyField] ?? {})) {
|
||||||
|
if (workspacePackage.location.layer === "module" && isModuleUiPackage(dependencyName)) {
|
||||||
|
violations.push({
|
||||||
|
from: workspacePackage.location.relativePath,
|
||||||
|
message: `Module package depends on UI framework ${dependencyName}.`,
|
||||||
|
rule: "modules-do-not-own-ui",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetPackage = workspacePackages.get(dependencyName);
|
||||||
|
if (!targetPackage) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
packageGraph.get(workspacePackage.name)?.add(targetPackage.name);
|
||||||
|
const rule = boundaryRule(workspacePackage.location, targetPackage.location);
|
||||||
|
if (rule) {
|
||||||
|
violations.push({
|
||||||
|
from: workspacePackage.location.relativePath,
|
||||||
|
message: `${workspacePackage.name} may not depend on ${targetPackage.name}.`,
|
||||||
|
rule,
|
||||||
|
to: targetPackage.location.relativePath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const cycle of findCycles(packageGraph)) {
|
||||||
|
violations.push({
|
||||||
|
from: cycle[0] ?? "unknown-package",
|
||||||
|
message: `Workspace package cycle: ${cycle.join(" -> ")}`,
|
||||||
|
rule: "no-circular-package-dependencies",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const repositoryFile of allFiles) {
|
||||||
|
const relativeFile = normalizePath(path.relative(root, repositoryFile));
|
||||||
|
const location = describeRepositoryPath(relativeFile);
|
||||||
|
if (location.layer === "module" && MODULE_UI_EXTENSIONS.has(path.extname(repositoryFile))) {
|
||||||
|
violations.push({
|
||||||
|
from: relativeFile,
|
||||||
|
message: "UI source files belong in a surface, not a module.",
|
||||||
|
rule: "modules-do-not-own-ui",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceGraph = new Map<string, Set<string>>();
|
||||||
|
for (const codeFile of codeFiles) {
|
||||||
|
const relativeFile = normalizePath(path.relative(root, codeFile));
|
||||||
|
const from = describeRepositoryPath(relativeFile);
|
||||||
|
sourceGraph.set(relativeFile, new Set());
|
||||||
|
|
||||||
|
const sourceText = await readFile(codeFile, "utf8");
|
||||||
|
for (const specifier of collectImportSpecifiers(sourceText)) {
|
||||||
|
const dependencyName = packageNameFromSpecifier(specifier);
|
||||||
|
if (from.layer === "module" && isModuleUiPackage(dependencyName)) {
|
||||||
|
violations.push({
|
||||||
|
from: relativeFile,
|
||||||
|
message: `Module source imports UI framework ${dependencyName}.`,
|
||||||
|
rule: "modules-do-not-own-ui",
|
||||||
|
to: specifier,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetFile: string | undefined;
|
||||||
|
let to: Location | undefined;
|
||||||
|
if (specifier.startsWith(".")) {
|
||||||
|
targetFile = await resolveSourceImport(codeFile, specifier);
|
||||||
|
if (targetFile) {
|
||||||
|
to = describeRepositoryPath(normalizePath(path.relative(root, targetFile)));
|
||||||
|
if (await isFile(targetFile)) {
|
||||||
|
sourceGraph.get(relativeFile)?.add(normalizePath(path.relative(root, targetFile)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const targetPackage = workspacePackages.get(dependencyName);
|
||||||
|
if (targetPackage) {
|
||||||
|
to = targetPackage.location;
|
||||||
|
if (!exportedSubpath(targetPackage.manifest, specifier, dependencyName)) {
|
||||||
|
violations.push({
|
||||||
|
from: relativeFile,
|
||||||
|
message: `${specifier} is not a public package export.`,
|
||||||
|
rule: "workspace-imports-use-public-exports",
|
||||||
|
to: targetPackage.location.relativePath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to) {
|
||||||
|
const rule = boundaryRule(from, to);
|
||||||
|
if (rule) {
|
||||||
|
violations.push({
|
||||||
|
from: relativeFile,
|
||||||
|
message: `${from.layer} code may not import ${to.layer} code directly.`,
|
||||||
|
rule,
|
||||||
|
to: to.relativePath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const cycle of findCycles(sourceGraph)) {
|
||||||
|
violations.push({
|
||||||
|
from: cycle[0] ?? "unknown-source",
|
||||||
|
message: `Source dependency cycle: ${cycle.join(" -> ")}`,
|
||||||
|
rule: "no-circular-source-dependencies",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
filesChecked: codeFiles.length,
|
||||||
|
packagesChecked: packageByDirectory.size,
|
||||||
|
violations,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const root = process.argv[2] ? path.resolve(process.argv[2]) : process.cwd();
|
||||||
|
const result = await checkBoundaries(root);
|
||||||
|
if (result.violations.length > 0) {
|
||||||
|
console.error(`Boundary validation failed with ${result.violations.length} violation(s):`);
|
||||||
|
for (const violation of result.violations) {
|
||||||
|
const target = violation.to ? ` -> ${violation.to}` : "";
|
||||||
|
console.error(`- [${violation.rule}] ${violation.from}${target}: ${violation.message}`);
|
||||||
|
}
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Boundary validation passed (${result.packagesChecked} packages, ${result.filesChecked} source files).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryPoint = process.argv[1];
|
||||||
|
if (entryPoint && import.meta.url === pathToFileURL(path.resolve(entryPoint)).href) {
|
||||||
|
await main();
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { checkBoundaries } from "../scripts/check-boundaries.js";
|
||||||
|
|
||||||
|
const testDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const fixtures = path.join(testDirectory, "fixtures", "boundaries");
|
||||||
|
|
||||||
|
describe("repository dependency boundaries", () => {
|
||||||
|
it("accepts public contracts below Core", async () => {
|
||||||
|
const result = await checkBoundaries(path.join(fixtures, "legal"));
|
||||||
|
expect(result.violations).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["core-imports-module", "core-is-feature-agnostic"],
|
||||||
|
["module-imports-surface", "modules-use-public-platform-contracts"],
|
||||||
|
["cross-module-import", "modules-do-not-import-other-modules"],
|
||||||
|
["module-has-ui", "modules-do-not-own-ui"],
|
||||||
|
["cycle", "no-circular-source-dependencies"],
|
||||||
|
])("rejects %s", async (fixture, expectedRule) => {
|
||||||
|
const result = await checkBoundaries(path.join(fixtures, fixture));
|
||||||
|
expect(result.violations.map((violation) => violation.rule)).toContain(expectedRule);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects both component and stylesheet files inside a module", async () => {
|
||||||
|
const result = await checkBoundaries(path.join(fixtures, "module-has-ui"));
|
||||||
|
expect(
|
||||||
|
result.violations.filter((violation) => violation.rule === "modules-do-not-own-ui"),
|
||||||
|
).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { calendar } from "../../modules/calendar/src/index.js";
|
||||||
|
|
||||||
|
export const invalidCoreFeature = calendar;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const calendar = "calendar";
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { media } from "../../../modules/media/src/index.js";
|
||||||
|
|
||||||
|
export const invalidModuleDependency = media;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const media = "media";
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { b } from "./b.js";
|
||||||
|
|
||||||
|
export const a = b;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { a } from "./a.js";
|
||||||
|
|
||||||
|
export const b = a;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import type { HealthContract } from "@ekklesia/contracts";
|
||||||
|
|
||||||
|
export const health: HealthContract = { status: "ok" };
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/contracts",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"ekklesia": {
|
||||||
|
"layer": "contracts"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export interface HealthContract {
|
||||||
|
readonly status: "ok";
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.calendar {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function Calendar(): string {
|
||||||
|
return "calendar";
|
||||||
|
}
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
import { CalendarView } from "../../../surfaces/dashboard/src/calendar-view.js";
|
||||||
|
|
||||||
|
export const invalidModuleUi = CalendarView;
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export const CalendarView = "calendar-view";
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { parse } from "yaml";
|
||||||
|
|
||||||
|
interface ComposeService {
|
||||||
|
healthcheck?: unknown;
|
||||||
|
image?: string;
|
||||||
|
ports?: string[];
|
||||||
|
volumes?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComposeDocument {
|
||||||
|
services?: Record<string, ComposeService>;
|
||||||
|
volumes?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
|
||||||
|
describe("development infrastructure", () => {
|
||||||
|
it("has valid, local-only, version-pinned Compose services", async () => {
|
||||||
|
const source = await readFile(
|
||||||
|
path.join(repositoryRoot, "infrastructure", "compose.yaml"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const compose = parse(source) as ComposeDocument;
|
||||||
|
const postgres = compose.services?.postgres;
|
||||||
|
const objectStorage = compose.services?.["object-storage"];
|
||||||
|
|
||||||
|
expect(postgres?.image).toBe("postgres:18.4-alpine3.24");
|
||||||
|
expect(objectStorage?.image).toBe("chrislusf/seaweedfs:4.28");
|
||||||
|
expect(postgres?.healthcheck).toBeDefined();
|
||||||
|
expect(objectStorage?.healthcheck).toBeDefined();
|
||||||
|
|
||||||
|
for (const service of [postgres, objectStorage]) {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
expect(service?.image).not.toMatch(/:latest$/u);
|
||||||
|
expect(service?.ports?.every((port) => port.startsWith("127.0.0.1:"))).toBe(true);
|
||||||
|
expect(service?.volumes?.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(compose.volumes).toHaveProperty("postgres-data");
|
||||||
|
expect(compose.volumes).toHaveProperty("object-storage-data");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has a parseable CI workflow with a single repository gate", async () => {
|
||||||
|
const source = await readFile(
|
||||||
|
path.join(repositoryRoot, ".github", "workflows", "ci.yml"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const workflow = parse(source) as {
|
||||||
|
jobs?: { verify?: { steps?: Array<{ run?: string }> } };
|
||||||
|
};
|
||||||
|
const commands = workflow.jobs?.verify?.steps?.flatMap((step) => step.run ?? []) ?? [];
|
||||||
|
|
||||||
|
expect(workflow.jobs?.verify).toBeDefined();
|
||||||
|
expect(commands).toContain("pnpm install --frozen-lockfile");
|
||||||
|
expect(commands).toContain("pnpm ci");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "./packages/typescript-config/node.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["scripts/**/*.ts", "tests/**/*.ts", "vitest.config.ts"],
|
||||||
|
"exclude": ["node_modules", "tests/fixtures"]
|
||||||
|
}
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://turbo.build/schema.json",
|
||||||
|
"ui": "stream",
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"dependsOn": ["^build"],
|
||||||
|
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
|
||||||
|
},
|
||||||
|
"lint": {
|
||||||
|
"dependsOn": ["^lint"],
|
||||||
|
"outputs": []
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"dependsOn": ["build"],
|
||||||
|
"outputs": ["coverage/**"]
|
||||||
|
},
|
||||||
|
"typecheck": {
|
||||||
|
"dependsOn": ["^typecheck"],
|
||||||
|
"outputs": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
coverage: {
|
||||||
|
provider: "v8",
|
||||||
|
reporter: ["text", "json", "html"],
|
||||||
|
},
|
||||||
|
include: ["tests/**/*.test.ts"],
|
||||||
|
passWithNoTests: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user