Compare commits

..

100 Commits

Author SHA1 Message Date
ead2acee40 Merge branch 'main' into codex/create-toolbar-and-schemasection-components 2025-12-27 18:31:40 +00:00
07efe7609a refactor: extract json editor ui components 2025-12-27 18:31:22 +00:00
daefe075b3 Merge pull request #216 from johndoe6345789/codex/add-paletteeditor-and-previewpane-components
Modularize theme editor components
2025-12-27 18:31:08 +00:00
b6b48eafb3 feat: modularize theme editor 2025-12-27 18:30:57 +00:00
cadaa8c5fe Merge pull request #211 from johndoe6345789/codex/refactor-error-as-todo-refactor.ts
Refactor error-as-todo runner into modular components
2025-12-27 18:24:06 +00:00
f4a5950c31 Merge branch 'main' into codex/refactor-error-as-todo-refactor.ts 2025-12-27 18:23:48 +00:00
d44385fc41 refactor: modularize error-as-todo runner 2025-12-27 18:23:08 +00:00
25220fad97 Merge pull request #207 from johndoe6345789/codex/refactor-dbal-directory-into-lambda-modules
Refactor DBAL helpers into modular lambda subfolders
2025-12-27 18:21:44 +00:00
b9ac291e68 Merge branch 'main' into codex/refactor-dbal-directory-into-lambda-modules 2025-12-27 18:21:37 +00:00
880544e58d refactor: modularize dbal utilities 2025-12-27 18:21:26 +00:00
579103e916 Merge pull request #206 from johndoe6345789/codex/refactor-tools-scripts-into-lambda-files
refactor: modularize stub tooling
2025-12-27 18:21:03 +00:00
0abb48c7aa Merge branch 'main' into codex/refactor-tools-scripts-into-lambda-files 2025-12-27 18:20:54 +00:00
6447e7a203 refactor: modularize stub tooling 2025-12-27 18:20:45 +00:00
b7a721cf8d Merge pull request #203 from johndoe6345789/codex/refactor-dbaldemo-into-separate-files
Refactor DBAL demo tabs into separate components
2025-12-27 18:19:56 +00:00
c0015f45fc Merge branch 'main' into codex/refactor-dbaldemo-into-separate-files 2025-12-27 18:19:51 +00:00
219637c4c6 refactor: split dbal demo tabs 2025-12-27 18:19:38 +00:00
1a6d1f5f2d Merge pull request #200 from johndoe6345789/codex/refactor-command.tsx-into-multiple-files
Refactor command dialog into modular components
2025-12-27 18:12:44 +00:00
f5baf35666 Merge branch 'main' into codex/refactor-command.tsx-into-multiple-files 2025-12-27 18:12:39 +00:00
30f35ae07f refactor: split command dialog components 2025-12-27 18:12:29 +00:00
06def0d890 Merge pull request #198 from johndoe6345789/codex/refactor-runlist-into-lambda-components
Refactor run list view into reusable subcomponents
2025-12-27 18:12:13 +00:00
43f8325ad2 Merge branch 'main' into codex/refactor-runlist-into-lambda-components 2025-12-27 18:12:04 +00:00
f273de2cab refactor: extract run list components 2025-12-27 18:11:52 +00:00
76f4d131ad Merge pull request #197 from johndoe6345789/codex/refactor-tool-scripts-into-smaller-lambdas
Modularize error-as-todo refactoring tool
2025-12-27 18:11:35 +00:00
1beeeba7ff Merge branch 'main' into codex/refactor-tool-scripts-into-smaller-lambdas 2025-12-27 18:11:26 +00:00
d12b24a36b refactor: modularize error-as-todo runner 2025-12-27 18:11:17 +00:00
3e0dbfd78d Merge pull request #183 from johndoe6345789/copilot/fix-issue-triage-script
Fix triage script to dynamically find duplicates via GitHub API
2025-12-27 18:09:14 +00:00
342a76bbad Merge branch 'main' into copilot/fix-issue-triage-script 2025-12-27 18:09:06 +00:00
copilot-swe-agent[bot]
21c735f126 Add before/after comparison document for triage script improvements
Co-authored-by: johndoe6345789 <224850594+johndoe6345789@users.noreply.github.com>
2025-12-27 18:05:35 +00:00
copilot-swe-agent[bot]
99132e65ec Add comprehensive documentation for triage scripts
Co-authored-by: johndoe6345789 <224850594+johndoe6345789@users.noreply.github.com>
2025-12-27 18:04:05 +00:00
copilot-swe-agent[bot]
6903901ec0 Fix triage script to dynamically find and close duplicates using GitHub API
Co-authored-by: johndoe6345789 <224850594+johndoe6345789@users.noreply.github.com>
2025-12-27 18:03:02 +00:00
b20011a21e Merge pull request #191 from johndoe6345789/codex/refactor-database-admin-structure
Refactor default data seeding into modular builders
2025-12-27 18:01:27 +00:00
8fe11b60f1 Merge branch 'main' into codex/refactor-database-admin-structure 2025-12-27 18:01:19 +00:00
086db10f74 refactor: modularize default data seeding 2025-12-27 18:00:57 +00:00
b5e6501bbb Merge pull request #189 from johndoe6345789/codex/refactor-pagination-components-and-utilities
Refactor pagination components into dedicated files
2025-12-27 18:00:28 +00:00
566fa19031 Merge branch 'main' into codex/refactor-pagination-components-and-utilities 2025-12-27 18:00:19 +00:00
a91917fde5 refactor: split pagination components 2025-12-27 18:00:09 +00:00
b70d8649f5 Merge pull request #188 from johndoe6345789/codex/refactor-block-metadata-and-lua-helpers
Refactor Lua block metadata and serialization utilities
2025-12-27 17:59:46 +00:00
76b1ce9486 refactor: modularize lua block metadata 2025-12-27 17:59:35 +00:00
1fd72be97d Merge pull request #186 from johndoe6345789/codex/refactor-catalog-array-into-thematic-files
Refactor component catalog into thematic modules
2025-12-27 17:58:51 +00:00
2ad62be4e9 Merge branch 'main' into codex/refactor-catalog-array-into-thematic-files 2025-12-27 17:58:42 +00:00
ed704f93aa refactor: segment component catalog 2025-12-27 17:58:29 +00:00
6b033ea57c Merge pull request #184 from johndoe6345789/codex/ensure-codebase-is-mui-theme-driven
Sync theme mode attributes with MUI theme
2025-12-27 17:58:00 +00:00
046c81ec9c Merge branch 'main' into codex/ensure-codebase-is-mui-theme-driven 2025-12-27 17:57:56 +00:00
15d8fa4aff chore: sync mui theme mode with document 2025-12-27 17:57:45 +00:00
copilot-swe-agent[bot]
4f9f42f5c2 Initial plan 2025-12-27 17:55:57 +00:00
8b2f836c2c Merge pull request #180 from johndoe6345789/codex/organize-components-and-extract-logic
Refactor component hierarchy editor into modular hooks
2025-12-27 17:52:07 +00:00
64496b9549 Merge branch 'main' into codex/organize-components-and-extract-logic 2025-12-27 17:52:02 +00:00
782ac21120 refactor: modularize component hierarchy editor 2025-12-27 17:51:53 +00:00
24d50f931a Merge pull request #178 from johndoe6345789/codex/organize-import/export-helpers-into-subfolders
Organize database admin import/export helpers
2025-12-27 17:51:36 +00:00
b693eeaf24 Merge branch 'main' into codex/organize-import/export-helpers-into-subfolders 2025-12-27 17:51:28 +00:00
93092c3a21 refactor: organize database admin import/export helpers 2025-12-27 17:51:19 +00:00
c41140391f Merge pull request #177 from johndoe6345789/codex/split-packagemanager-into-multiple-files
Refactor package manager into modular components
2025-12-27 17:51:00 +00:00
df9193ffe6 refactor: split package manager components 2025-12-27 17:50:50 +00:00
4a12a6f2dd Merge pull request #140 from johndoe6345789/copilot/fix-pre-deployment-validation
Fix Prisma v7 configuration for pre-deployment validation
2025-12-27 17:45:36 +00:00
8ec13ee23d Merge branch 'main' into copilot/fix-pre-deployment-validation 2025-12-27 17:45:22 +00:00
e3a8a91051 Merge pull request #173 from johndoe6345789/codex/group-lua-snippets-by-category
Refactor Lua snippets into category modules
2025-12-27 17:40:52 +00:00
e57cf107fe Merge branch 'main' into codex/group-lua-snippets-by-category 2025-12-27 17:40:42 +00:00
5cbbf0b6b0 refactor: reorganize lua snippets 2025-12-27 17:40:30 +00:00
af286fac68 Merge pull request #170 from johndoe6345789/codex/refactor-navigation-component-structure
Refactor navigation component into modular files
2025-12-27 17:40:05 +00:00
7ce7f9a133 Merge branch 'main' into codex/refactor-navigation-component-structure 2025-12-27 17:39:55 +00:00
59efb7ea1a refactor: split navigation components 2025-12-27 17:39:45 +00:00
5dc236bd1c Merge pull request #169 from johndoe6345789/codex/refactor-workfloweditor-into-separate-modules
Refactor workflow editor into modular components
2025-12-27 17:39:27 +00:00
bb3cb93432 Merge branch 'main' into codex/refactor-workfloweditor-into-separate-modules 2025-12-27 17:39:21 +00:00
ed97047bdf refactor: modularize workflow editor 2025-12-27 17:38:58 +00:00
823c2d979f Merge pull request #165 from johndoe6345789/codex/refactor-errorlogstab-into-lambda-modules
Refactor error logs tab into modular components
2025-12-27 17:38:05 +00:00
4b4f370d53 Merge branch 'main' into codex/refactor-errorlogstab-into-lambda-modules 2025-12-27 17:37:55 +00:00
fb7c1ea5f3 refactor: modularize error logs tab 2025-12-27 17:37:10 +00:00
e4792fa1f2 Merge pull request #163 from johndoe6345789/codex/refactor-irc-webchat.ts-for-modular-exports
Refactor IRC webchat package definition
2025-12-27 17:36:49 +00:00
cda8db4a4e Merge branch 'main' into codex/refactor-irc-webchat.ts-for-modular-exports 2025-12-27 17:36:44 +00:00
9ce4031af9 refactor: modularize irc webchat package 2025-12-27 17:36:31 +00:00
b1557a65b1 Merge pull request #161 from johndoe6345789/codex/refactor-luaeditor-into-separate-modules
Refactor Lua editor into modular modules
2025-12-27 17:36:15 +00:00
7767f7fdf5 Merge branch 'main' into codex/refactor-luaeditor-into-separate-modules 2025-12-27 17:36:06 +00:00
61710f3f73 refactor: modularize lua editor concerns 2025-12-27 17:35:25 +00:00
fb0f1773aa Merge pull request #159 from johndoe6345789/codex/refactor-errorlogstab-into-smaller-components
Refactor error logs tab into modular components
2025-12-27 17:35:07 +00:00
f8721970f0 Merge branch 'main' into codex/refactor-errorlogstab-into-smaller-components 2025-12-27 17:35:00 +00:00
bd3779820a refactor: modularize error logs tab 2025-12-27 17:34:49 +00:00
fb72fb61e1 Merge pull request #158 from johndoe6345789/codex/split-large-dbal-files-into-modules
Refactor DBAL storage modules into modular subdirectories
2025-12-27 17:34:33 +00:00
18896aed7f Merge branch 'main' into codex/split-large-dbal-files-into-modules 2025-12-27 17:34:22 +00:00
b741328642 refactor: modularize dbal storage modules 2025-12-27 17:34:10 +00:00
c8a5da4971 Merge pull request #156 from johndoe6345789/codex/refactor-tool-scripts-into-single-purpose-lambdas
Refactor lambda refactoring tools into modular helpers
2025-12-27 17:33:46 +00:00
3dde857965 Merge branch 'main' into codex/refactor-tool-scripts-into-single-purpose-lambdas 2025-12-27 17:33:36 +00:00
f7f15bacb3 refactor: modularize lambda refactor tooling 2025-12-27 17:33:26 +00:00
e11b7c4bd1 Merge pull request #154 from johndoe6345789/codex/refactor-errorlogstab-into-modules
Refactor ErrorLogsTab into modular components
2025-12-27 17:32:56 +00:00
e77bc711cb Merge branch 'main' into codex/refactor-errorlogstab-into-modules 2025-12-27 17:32:51 +00:00
ade49ad0e9 refactor: modularize error logs tab 2025-12-27 17:32:41 +00:00
copilot-swe-agent[bot]
28e8ef1828 Remove deprecated @types/jszip package
- jszip provides its own type definitions
- @types/jszip is deprecated and causes conflicts
- Addresses code review feedback

Co-authored-by: johndoe6345789 <224850594+johndoe6345789@users.noreply.github.com>
2025-12-27 17:32:16 +00:00
b17c9872a3 Merge pull request #151 from johndoe6345789/codex/refactor-packageimportexport-into-modules
Refactor PackageImportExport into modular handlers
2025-12-27 17:31:36 +00:00
9503348263 Merge branch 'main' into codex/refactor-packageimportexport-into-modules 2025-12-27 17:31:28 +00:00
79632c2913 refactor: modularize package import/export flow 2025-12-27 17:31:18 +00:00
fb7a8b8533 Merge pull request #150 from johndoe6345789/codex/decompose-luaeditor-into-modules
Refactor Lua editor into modular components
2025-12-27 17:30:58 +00:00
2778ea1daa Merge branch 'main' into codex/decompose-luaeditor-into-modules 2025-12-27 17:30:49 +00:00
5643fa5f8d refactor: modularize lua editor 2025-12-27 17:30:36 +00:00
3edcbc4416 Merge pull request #139 from johndoe6345789/copilot/update-dependencies-dashboard
Verify and document Renovate dependency updates status
2025-12-27 17:29:50 +00:00
bb19d5ed2e Merge branch 'main' into copilot/update-dependencies-dashboard 2025-12-27 17:29:43 +00:00
copilot-swe-agent[bot]
f89aaf92a4 Fix Prisma v7 configuration for pre-deployment validation
- Remove url from prisma/schema.prisma (not allowed in v7)
- Add proper prisma.config.ts with defineConfig from prisma/config
- Use process.env.DATABASE_URL with fallback for CI environments
- Generate Prisma Client successfully with v7 configuration

Co-authored-by: johndoe6345789 <224850594+johndoe6345789@users.noreply.github.com>
2025-12-27 17:28:01 +00:00
copilot-swe-agent[bot]
86a0445cb3 Add issue comment template for Dependency Dashboard
Co-authored-by: johndoe6345789 <224850594+johndoe6345789@users.noreply.github.com>
2025-12-27 17:26:51 +00:00
copilot-swe-agent[bot]
6bd06111af Add comprehensive Renovate Dashboard status report
Co-authored-by: johndoe6345789 <224850594+johndoe6345789@users.noreply.github.com>
2025-12-27 17:25:56 +00:00
copilot-swe-agent[bot]
5a3236a228 Verify Renovate Dashboard dependency status - all checked updates applied
Co-authored-by: johndoe6345789 <224850594+johndoe6345789@users.noreply.github.com>
2025-12-27 17:22:24 +00:00
copilot-swe-agent[bot]
6f8dad83e8 Initial plan 2025-12-27 17:18:19 +00:00
copilot-swe-agent[bot]
79b12f9dc8 Initial plan 2025-12-27 17:14:58 +00:00
345 changed files with 16819 additions and 13164 deletions

67
ISSUE_COMMENT_TEMPLATE.md Normal file
View File

@@ -0,0 +1,67 @@
# Issue Comment for Renovate Dependency Dashboard
**Copy the text below to add as a comment to the Dependency Dashboard issue:**
---
## ✅ Dependency Update Status - All Checked Items Applied
I've reviewed the Dependency Dashboard and verified the status of all checked dependency updates. Here's the current state:
### ✅ Successfully Applied Updates
All checked rate-limited updates have been applied to the repository:
| Package | Version | Status |
|---------|---------|--------|
| `motion` (replacing framer-motion) | ^12.6.2 | ✅ Applied |
| `typescript-eslint` | v8.50.1 | ✅ Applied |
| `three` | ^0.182.0 | ✅ Applied |
| `actions/checkout` | v6 | ✅ Applied |
### ❌ Not Applicable: lucide-react
The `lucide-react` update should **not** be applied. Per our [UI Standards](./UI_STANDARDS.md), this project uses:
-`@mui/icons-material` for icons
- ❌ Not `lucide-react`
Recommendation: Close any Renovate PRs for `lucide-react` as this dependency is not used in our architecture.
### 📋 Additional Major Version Updates
The following major version updates mentioned in the dashboard are also current:
- `@hookform/resolvers` v5.2.2 ✅
- `@octokit/core` v7.0.6 ✅
- `date-fns` v4.1.0 ✅
- `recharts` v3.6.0 ✅
- `zod` v4.2.1 ✅
- `@prisma/client` & `prisma` v7.2.0 ✅
### 📝 Deprecation: @types/jszip
`@types/jszip` is marked as deprecated with no replacement available. We're continuing to use:
- `jszip` ^3.10.1 (latest stable)
- `@types/jszip` ^3.4.1 (for TypeScript support)
This is acceptable as the types package remains functional and the core `jszip` library is actively maintained.
### ✅ Verification
All updates have been verified:
- ✅ Dependencies installed successfully
- ✅ Prisma client generated (v7.2.0)
- ✅ Linter passes
- ✅ Unit tests pass (426/429 tests passing, 3 pre-existing failures)
### 📄 Full Report
See [RENOVATE_DASHBOARD_STATUS.md](./RENOVATE_DASHBOARD_STATUS.md) for complete analysis and verification details.
---
**Next Steps:**
- Renovate will automatically update this dashboard on its next run
- Checked items should be marked as completed
- Consider configuring Renovate to skip `lucide-react` updates

View File

@@ -0,0 +1,128 @@
# Renovate Dependency Dashboard - Status Report
**Date:** December 27, 2024
**Repository:** johndoe6345789/metabuilder
## Executive Summary
All dependency updates marked as checked in the Renovate Dependency Dashboard have been successfully applied to the repository. The codebase is up-to-date with the latest stable versions of all major dependencies.
## Checked Items Status
### ✅ Completed Updates
| Dependency | Requested Version | Current Version | Status |
|------------|------------------|-----------------|---------|
| `motion` (replacing `framer-motion`) | ^12.6.2 | ^12.6.2 | ✅ Applied |
| `typescript-eslint` | v8.50.1 | ^8.50.1 | ✅ Applied |
| `three` | ^0.182.0 | ^0.182.0 | ✅ Applied |
| `actions/checkout` | v6 | v6 | ✅ Applied |
### ❌ Not Applicable
| Dependency | Status | Reason |
|------------|--------|--------|
| `lucide-react` | Not Added | Project uses `@mui/icons-material` per UI standards (see UI_STANDARDS.md) |
## Additional Major Version Updates (Already Applied)
The following major version updates mentioned in the dashboard have also been applied:
| Package | Current Version | Notes |
|---------|----------------|-------|
| `@hookform/resolvers` | v5.2.2 | Latest v5 |
| `@octokit/core` | v7.0.6 | Latest v7 |
| `date-fns` | v4.1.0 | Latest v4 |
| `recharts` | v3.6.0 | Latest v3 |
| `zod` | v4.2.1 | Latest v4 |
| `@prisma/client` | v7.2.0 | Latest v7 |
| `prisma` | v7.2.0 | Latest v7 |
## Deprecations & Replacements
### @types/jszip
- **Status:** Marked as deprecated
- **Replacement:** None available
- **Current Action:** Continuing to use `@types/jszip` ^3.4.1 with `jszip` ^3.10.1
- **Rationale:** The types package is still functional and necessary for TypeScript support. The core `jszip` package (v3.10.1) is actively maintained and at its latest stable version.
### framer-motion → motion
- **Status:** ✅ Completed
- **Current Package:** `motion` ^12.6.2
- **Note:** The `motion` package currently depends on `framer-motion` as part of the transition. This is expected behavior during the migration period.
## GitHub Actions Updates
All GitHub Actions have been updated to their latest versions:
- `actions/checkout@v6`
- `actions/setup-node@v4` (latest v4)
- `actions/upload-artifact@v4` (latest v4)
- `actions/github-script@v7` (latest v7)
- `actions/setup-python@v5` (latest v5)
## Verification Steps Performed
1. ✅ Installed all dependencies successfully
2. ✅ Generated Prisma client (v7.2.0) without errors
3. ✅ Linter passes (only pre-existing warnings)
4. ✅ Unit tests pass (426/429 passing, 3 pre-existing failures unrelated to dependency updates)
5. ✅ Package versions verified with `npm list`
## Test Results Summary
```
Test Files 76 passed (76)
Tests 426 passed | 3 failed (429)
Status Stable - failing tests are pre-existing
```
The 3 failing tests in `src/hooks/useAuth.test.ts` are pre-existing authentication test issues unrelated to the dependency updates.
## Architecture-Specific Notes
### Prisma 7.x Migration
The repository has been successfully migrated to Prisma 7.x following the official migration guide:
- ✅ Datasource URL removed from schema.prisma
- ✅ Prisma config setup in prisma.config.ts
- ✅ SQLite adapter (@prisma/adapter-better-sqlite3) installed and configured
- ✅ Client generation working correctly
### UI Framework Standards
Per `UI_STANDARDS.md`, the project has standardized on:
- Material-UI (`@mui/material`) for components
- MUI Icons (`@mui/icons-material`) for icons
- SASS modules for custom styling
Therefore, dependencies like `lucide-react` should not be added.
## Recommendations
### For Renovate Bot
1. **Auto-close PRs** for `lucide-react` updates as this dependency is not used
2. **Monitor** `@types/jszip` for when a replacement becomes available
3. **Continue tracking** the remaining rate-limited updates
### For Development Team
1. All checked dependency updates are applied and verified
2. Repository is in a stable state with updated dependencies
3. No immediate action required
4. Continue monitoring the Renovate Dashboard for future updates
## Next Steps
- Renovate will automatically update the Dashboard issue on its next scheduled run
- The checked items should be marked as completed by Renovate
- New dependency updates will continue to be tracked automatically
## References
- [Dependency Update Summary](./DEPENDENCY_UPDATE_SUMMARY.md)
- [UI Standards](./UI_STANDARDS.md)
- [Prisma 7.x Migration Guide](https://pris.ly/d/major-version-upgrade)
- [Renovate Documentation](https://docs.renovatebot.com/)
---
**Prepared by:** GitHub Copilot
**PR:** [Link to be added by user]

View File

@@ -1,258 +1,3 @@
/**
* @file acl-adapter.ts
* @description ACL adapter that wraps a base adapter with access control
*/
import type { DBALAdapter, AdapterCapabilities } from './adapter'
import type { ListOptions, ListResult } from '../core/foundation/types'
import type { User, ACLRule } from './acl/types'
import { resolvePermissionOperation } from './acl/resolve-permission-operation'
import { checkPermission } from './acl/check-permission'
import { checkRowLevelAccess } from './acl/check-row-level-access'
import { logAudit } from './acl/audit-logger'
import { defaultACLRules } from './acl/default-rules'
export class ACLAdapter implements DBALAdapter {
private baseAdapter: DBALAdapter
private user: User
private rules: ACLRule[]
private auditLog: boolean
constructor(
baseAdapter: DBALAdapter,
user: User,
options?: {
rules?: ACLRule[]
auditLog?: boolean
}
) {
this.baseAdapter = baseAdapter
this.user = user
this.rules = options?.rules || defaultACLRules
this.auditLog = options?.auditLog ?? true
}
private log(entity: string, operation: string, success: boolean, message?: string): void {
if (this.auditLog) {
logAudit(entity, operation, success, this.user, message)
}
}
async create(entity: string, data: Record<string, unknown>): Promise<unknown> {
const operation = 'create'
checkPermission(entity, operation, this.user, this.rules, this.log.bind(this))
try {
const result = await this.baseAdapter.create(entity, data)
this.log(entity, operation, true)
return result
} catch (error) {
this.log(entity, operation, false, (error as Error).message)
throw error
}
}
async read(entity: string, id: string): Promise<unknown | null> {
const operation = 'read'
checkPermission(entity, operation, this.user, this.rules, this.log.bind(this))
try {
const result = await this.baseAdapter.read(entity, id)
if (result) {
checkRowLevelAccess(entity, operation, result as Record<string, unknown>, this.user, this.rules, this.log.bind(this))
}
this.log(entity, operation, true)
return result
} catch (error) {
this.log(entity, operation, false, (error as Error).message)
throw error
}
}
async update(entity: string, id: string, data: Record<string, unknown>): Promise<unknown> {
const operation = 'update'
checkPermission(entity, operation, this.user, this.rules, this.log.bind(this))
const existing = await this.baseAdapter.read(entity, id)
if (existing) {
checkRowLevelAccess(entity, operation, existing as Record<string, unknown>, this.user, this.rules, this.log.bind(this))
}
try {
const result = await this.baseAdapter.update(entity, id, data)
this.log(entity, operation, true)
return result
} catch (error) {
this.log(entity, operation, false, (error as Error).message)
throw error
}
}
async delete(entity: string, id: string): Promise<boolean> {
const operation = 'delete'
checkPermission(entity, operation, this.user, this.rules, this.log.bind(this))
const existing = await this.baseAdapter.read(entity, id)
if (existing) {
checkRowLevelAccess(entity, operation, existing as Record<string, unknown>, this.user, this.rules, this.log.bind(this))
}
try {
const result = await this.baseAdapter.delete(entity, id)
this.log(entity, operation, true)
return result
} catch (error) {
this.log(entity, operation, false, (error as Error).message)
throw error
}
}
async list(entity: string, options?: ListOptions): Promise<ListResult<unknown>> {
const operation = 'list'
checkPermission(entity, operation, this.user, this.rules, this.log.bind(this))
try {
const result = await this.baseAdapter.list(entity, options)
this.log(entity, operation, true)
return result
} catch (error) {
this.log(entity, operation, false, (error as Error).message)
throw error
}
}
async findFirst(entity: string, filter?: Record<string, unknown>): Promise<unknown | null> {
const resolvedOperation = resolvePermissionOperation('findFirst')
checkPermission(entity, resolvedOperation, this.user, this.rules, this.log.bind(this))
try {
const result = await this.baseAdapter.findFirst(entity, filter)
if (result) {
checkRowLevelAccess(entity, resolvedOperation, result as Record<string, unknown>, this.user, this.rules, this.log.bind(this))
}
this.log(entity, 'findFirst', true)
return result
} catch (error) {
this.log(entity, 'findFirst', false, (error as Error).message)
throw error
}
}
async findByField(entity: string, field: string, value: unknown): Promise<unknown | null> {
const resolvedOperation = resolvePermissionOperation('findByField')
checkPermission(entity, resolvedOperation, this.user, this.rules, this.log.bind(this))
try {
const result = await this.baseAdapter.findByField(entity, field, value)
if (result) {
checkRowLevelAccess(entity, resolvedOperation, result as Record<string, unknown>, this.user, this.rules, this.log.bind(this))
}
this.log(entity, 'findByField', true)
return result
} catch (error) {
this.log(entity, 'findByField', false, (error as Error).message)
throw error
}
}
async upsert(
entity: string,
filter: Record<string, unknown>,
createData: Record<string, unknown>,
updateData: Record<string, unknown>
): Promise<unknown> {
checkPermission(entity, 'create', this.user, this.rules, this.log.bind(this))
checkPermission(entity, 'update', this.user, this.rules, this.log.bind(this))
try {
const result = await this.baseAdapter.upsert(entity, filter, createData, updateData)
this.log(entity, 'upsert', true)
return result
} catch (error) {
this.log(entity, 'upsert', false, (error as Error).message)
throw error
}
}
async updateByField(entity: string, field: string, value: unknown, data: Record<string, unknown>): Promise<unknown> {
const resolvedOperation = resolvePermissionOperation('updateByField')
checkPermission(entity, resolvedOperation, this.user, this.rules, this.log.bind(this))
try {
const result = await this.baseAdapter.updateByField(entity, field, value, data)
this.log(entity, 'updateByField', true)
return result
} catch (error) {
this.log(entity, 'updateByField', false, (error as Error).message)
throw error
}
}
async deleteByField(entity: string, field: string, value: unknown): Promise<boolean> {
const resolvedOperation = resolvePermissionOperation('deleteByField')
checkPermission(entity, resolvedOperation, this.user, this.rules, this.log.bind(this))
try {
const result = await this.baseAdapter.deleteByField(entity, field, value)
this.log(entity, 'deleteByField', true)
return result
} catch (error) {
this.log(entity, 'deleteByField', false, (error as Error).message)
throw error
}
}
async createMany(entity: string, data: Record<string, unknown>[]): Promise<number> {
const resolvedOperation = resolvePermissionOperation('createMany')
checkPermission(entity, resolvedOperation, this.user, this.rules, this.log.bind(this))
try {
const result = await this.baseAdapter.createMany(entity, data)
this.log(entity, 'createMany', true)
return result
} catch (error) {
this.log(entity, 'createMany', false, (error as Error).message)
throw error
}
}
async updateMany(entity: string, filter: Record<string, unknown>, data: Record<string, unknown>): Promise<number> {
const resolvedOperation = resolvePermissionOperation('updateMany')
checkPermission(entity, resolvedOperation, this.user, this.rules, this.log.bind(this))
try {
const result = await this.baseAdapter.updateMany(entity, filter, data)
this.log(entity, 'updateMany', true)
return result
} catch (error) {
this.log(entity, 'updateMany', false, (error as Error).message)
throw error
}
}
async deleteMany(entity: string, filter?: Record<string, unknown>): Promise<number> {
const resolvedOperation = resolvePermissionOperation('deleteMany')
checkPermission(entity, resolvedOperation, this.user, this.rules, this.log.bind(this))
try {
const result = await this.baseAdapter.deleteMany(entity, filter)
this.log(entity, 'deleteMany', true)
return result
} catch (error) {
this.log(entity, 'deleteMany', false, (error as Error).message)
throw error
}
}
async getCapabilities(): Promise<AdapterCapabilities> {
return this.baseAdapter.getCapabilities()
}
async close(): Promise<void> {
await this.baseAdapter.close()
}
}
// Re-export types for convenience
export { ACLAdapter } from './acl-adapter/index'
export type { User, ACLRule } from './acl/types'
export { defaultACLRules } from './acl/default-rules'

View File

@@ -0,0 +1,67 @@
import type { ACLContext } from './context'
import { enforceRowAccess, resolveOperation, withAudit } from './guards'
export const findFirst = (context: ACLContext) => async (entity: string, filter?: Record<string, unknown>) => {
const operation = resolveOperation('findFirst')
return withAudit(context, entity, operation, async () => {
const result = await context.baseAdapter.findFirst(entity, filter)
if (result) {
enforceRowAccess(context, entity, operation, result as Record<string, unknown>)
}
return result
})
}
export const findByField = (context: ACLContext) => async (entity: string, field: string, value: unknown) => {
const operation = resolveOperation('findByField')
return withAudit(context, entity, operation, async () => {
const result = await context.baseAdapter.findByField(entity, field, value)
if (result) {
enforceRowAccess(context, entity, operation, result as Record<string, unknown>)
}
return result
})
}
export const upsert = (context: ACLContext) => async (
entity: string,
filter: Record<string, unknown>,
createData: Record<string, unknown>,
updateData: Record<string, unknown>,
) => {
return withAudit(context, entity, 'upsert', () => context.baseAdapter.upsert(entity, filter, createData, updateData))
}
export const updateByField = (context: ACLContext) => async (
entity: string,
field: string,
value: unknown,
data: Record<string, unknown>,
) => {
const operation = resolveOperation('updateByField')
return withAudit(context, entity, operation, () => context.baseAdapter.updateByField(entity, field, value, data))
}
export const deleteByField = (context: ACLContext) => async (entity: string, field: string, value: unknown) => {
const operation = resolveOperation('deleteByField')
return withAudit(context, entity, operation, () => context.baseAdapter.deleteByField(entity, field, value))
}
export const createMany = (context: ACLContext) => async (entity: string, data: Record<string, unknown>[]) => {
const operation = resolveOperation('createMany')
return withAudit(context, entity, operation, () => context.baseAdapter.createMany(entity, data))
}
export const updateMany = (context: ACLContext) => async (
entity: string,
filter: Record<string, unknown>,
data: Record<string, unknown>,
) => {
const operation = resolveOperation('updateMany')
return withAudit(context, entity, operation, () => context.baseAdapter.updateMany(entity, filter, data))
}
export const deleteMany = (context: ACLContext) => async (entity: string, filter?: Record<string, unknown>) => {
const operation = resolveOperation('deleteMany')
return withAudit(context, entity, operation, () => context.baseAdapter.deleteMany(entity, filter))
}

View File

@@ -0,0 +1,34 @@
import type { DBALAdapter } from '../adapter'
import type { User, ACLRule } from '../acl/types'
import { logAudit } from '../acl/audit-logger'
import { defaultACLRules } from '../acl/default-rules'
export interface ACLContext {
baseAdapter: DBALAdapter
user: User
rules: ACLRule[]
auditLog: boolean
logger: (entity: string, operation: string, success: boolean, message?: string) => void
}
export const createContext = (
baseAdapter: DBALAdapter,
user: User,
options?: { rules?: ACLRule[]; auditLog?: boolean },
): ACLContext => {
const auditLog = options?.auditLog ?? true
const rules = options?.rules || defaultACLRules
const logger = (entity: string, operation: string, success: boolean, message?: string) => {
if (auditLog) {
logAudit(entity, operation, success, user, message)
}
}
return {
baseAdapter,
user,
rules,
auditLog,
logger,
}
}

View File

@@ -0,0 +1,41 @@
import type { ListOptions, ListResult } from '../../core/foundation/types'
import type { ACLContext } from './context'
import { enforceRowAccess, withAudit } from './guards'
export const createEntity = (context: ACLContext) => async (entity: string, data: Record<string, unknown>) => {
return withAudit(context, entity, 'create', () => context.baseAdapter.create(entity, data))
}
export const readEntity = (context: ACLContext) => async (entity: string, id: string) => {
return withAudit(context, entity, 'read', async () => {
const result = await context.baseAdapter.read(entity, id)
if (result) {
enforceRowAccess(context, entity, 'read', result as Record<string, unknown>)
}
return result
})
}
export const updateEntity = (context: ACLContext) => async (entity: string, id: string, data: Record<string, unknown>) => {
return withAudit(context, entity, 'update', async () => {
const existing = await context.baseAdapter.read(entity, id)
if (existing) {
enforceRowAccess(context, entity, 'update', existing as Record<string, unknown>)
}
return context.baseAdapter.update(entity, id, data)
})
}
export const deleteEntity = (context: ACLContext) => async (entity: string, id: string) => {
return withAudit(context, entity, 'delete', async () => {
const existing = await context.baseAdapter.read(entity, id)
if (existing) {
enforceRowAccess(context, entity, 'delete', existing as Record<string, unknown>)
}
return context.baseAdapter.delete(entity, id)
})
}
export const listEntities = (context: ACLContext) => async (entity: string, options?: ListOptions): Promise<ListResult<unknown>> => {
return withAudit(context, entity, 'list', () => context.baseAdapter.list(entity, options))
}

View File

@@ -0,0 +1,37 @@
import { checkPermission } from '../acl/check-permission'
import { checkRowLevelAccess } from '../acl/check-row-level-access'
import { resolvePermissionOperation } from '../acl/resolve-permission-operation'
import type { ACLContext } from './context'
export const enforcePermission = (context: ACLContext, entity: string, operation: string) => {
checkPermission(entity, operation, context.user, context.rules, context.logger)
}
export const enforceRowAccess = (
context: ACLContext,
entity: string,
operation: string,
record: Record<string, unknown>,
) => {
checkRowLevelAccess(entity, operation, record, context.user, context.rules, context.logger)
}
export const withAudit = async <T>(
context: ACLContext,
entity: string,
operation: string,
action: () => Promise<T>,
) => {
enforcePermission(context, entity, operation)
try {
const result = await action()
context.logger(entity, operation, true)
return result
} catch (error) {
context.logger(entity, operation, false, (error as Error).message)
throw error
}
}
export const resolveOperation = resolvePermissionOperation

View File

@@ -0,0 +1,92 @@
import type { AdapterCapabilities, DBALAdapter } from '../adapter'
import type { ListOptions, ListResult } from '../../core/foundation/types'
import type { User, ACLRule } from '../acl/types'
import type { ACLContext } from './context'
import { createContext } from './context'
import { createEntity, deleteEntity, listEntities, readEntity, updateEntity } from './crud'
import {
createMany,
deleteByField,
deleteMany,
findByField,
findFirst,
updateByField,
updateMany,
upsert,
} from './bulk'
export class ACLAdapter implements DBALAdapter {
private readonly context: ACLContext
constructor(baseAdapter: DBALAdapter, user: User, options?: { rules?: ACLRule[]; auditLog?: boolean }) {
this.context = createContext(baseAdapter, user, options)
}
async create(entity: string, data: Record<string, unknown>): Promise<unknown> {
return createEntity(this.context)(entity, data)
}
async read(entity: string, id: string): Promise<unknown | null> {
return readEntity(this.context)(entity, id)
}
async update(entity: string, id: string, data: Record<string, unknown>): Promise<unknown> {
return updateEntity(this.context)(entity, id, data)
}
async delete(entity: string, id: string): Promise<boolean> {
return deleteEntity(this.context)(entity, id)
}
async list(entity: string, options?: ListOptions): Promise<ListResult<unknown>> {
return listEntities(this.context)(entity, options)
}
async findFirst(entity: string, filter?: Record<string, unknown>): Promise<unknown | null> {
return findFirst(this.context)(entity, filter)
}
async findByField(entity: string, field: string, value: unknown): Promise<unknown | null> {
return findByField(this.context)(entity, field, value)
}
async upsert(
entity: string,
filter: Record<string, unknown>,
createData: Record<string, unknown>,
updateData: Record<string, unknown>,
): Promise<unknown> {
return upsert(this.context)(entity, filter, createData, updateData)
}
async updateByField(entity: string, field: string, value: unknown, data: Record<string, unknown>): Promise<unknown> {
return updateByField(this.context)(entity, field, value, data)
}
async deleteByField(entity: string, field: string, value: unknown): Promise<boolean> {
return deleteByField(this.context)(entity, field, value)
}
async createMany(entity: string, data: Record<string, unknown>[]): Promise<number> {
return createMany(this.context)(entity, data)
}
async updateMany(entity: string, filter: Record<string, unknown>, data: Record<string, unknown>): Promise<number> {
return updateMany(this.context)(entity, filter, data)
}
async deleteMany(entity: string, filter?: Record<string, unknown>): Promise<number> {
return deleteMany(this.context)(entity, filter)
}
async getCapabilities(): Promise<AdapterCapabilities> {
return this.context.baseAdapter.getCapabilities()
}
async close(): Promise<void> {
await this.context.baseAdapter.close()
}
}
export type { User, ACLRule } from './acl/types'
export { defaultACLRules } from './acl/default-rules'

View File

@@ -1,350 +0,0 @@
import { PrismaClient } from '@prisma/client'
import type { DBALAdapter, AdapterCapabilities } from './adapter'
import type { ListOptions, ListResult } from '../core/foundation/types'
import { DBALError } from '../core/foundation/errors'
type PrismaAdapterDialect = 'postgres' | 'mysql' | 'sqlite' | 'generic'
export interface PrismaAdapterOptions {
queryTimeout?: number
dialect?: PrismaAdapterDialect
}
export class PrismaAdapter implements DBALAdapter {
private prisma: PrismaClient
private queryTimeout: number
private dialect: PrismaAdapterDialect
constructor(databaseUrl?: string, options?: PrismaAdapterOptions) {
const inferredDialect = options?.dialect ?? PrismaAdapter.inferDialectFromUrl(databaseUrl)
this.dialect = inferredDialect ?? 'generic'
this.prisma = new PrismaClient({
datasources: databaseUrl ? { db: { url: databaseUrl } } : undefined,
})
this.queryTimeout = options?.queryTimeout ?? 30000
}
async create(entity: string, data: Record<string, unknown>): Promise<unknown> {
try {
const model = this.getModel(entity)
const result = await this.withTimeout(
model.create({ data: data as never })
)
return result
} catch (error) {
throw this.handleError(error, 'create', entity)
}
}
async read(entity: string, id: string): Promise<unknown | null> {
try {
const model = this.getModel(entity)
const result = await this.withTimeout(
model.findUnique({ where: { id } as never })
)
return result
} catch (error) {
throw this.handleError(error, 'read', entity)
}
}
async update(entity: string, id: string, data: Record<string, unknown>): Promise<unknown> {
try {
const model = this.getModel(entity)
const result = await this.withTimeout(
model.update({
where: { id } as never,
data: data as never
})
)
return result
} catch (error) {
throw this.handleError(error, 'update', entity)
}
}
async delete(entity: string, id: string): Promise<boolean> {
try {
const model = this.getModel(entity)
await this.withTimeout(
model.delete({ where: { id } as never })
)
return true
} catch (error) {
if (this.isNotFoundError(error)) {
return false
}
throw this.handleError(error, 'delete', entity)
}
}
async list(entity: string, options?: ListOptions): Promise<ListResult<unknown>> {
try {
const model = this.getModel(entity)
const page = options?.page || 1
const limit = options?.limit || 50
const skip = (page - 1) * limit
const where = options?.filter ? this.buildWhereClause(options.filter) : undefined
const orderBy = options?.sort ? this.buildOrderBy(options.sort) : undefined
const [data, total] = await Promise.all([
this.withTimeout(
model.findMany({
where: where as never,
orderBy: orderBy as never,
skip,
take: limit,
})
),
this.withTimeout(
model.count({ where: where as never })
)
]) as [unknown[], number]
return {
data: data as unknown[],
total,
page,
limit,
hasMore: skip + limit < total,
}
} catch (error) {
throw this.handleError(error, 'list', entity)
}
}
async findFirst(entity: string, filter?: Record<string, unknown>): Promise<unknown | null> {
try {
const model = this.getModel(entity)
const where = filter ? this.buildWhereClause(filter) : undefined
const result = await this.withTimeout(
model.findFirst({ where: where as never })
)
return result
} catch (error) {
throw this.handleError(error, 'findFirst', entity)
}
}
async findByField(entity: string, field: string, value: unknown): Promise<unknown | null> {
try {
const model = this.getModel(entity)
const result = await this.withTimeout(
model.findUnique({ where: { [field]: value } as never })
)
return result
} catch (error) {
throw this.handleError(error, 'findByField', entity)
}
}
async upsert(
entity: string,
uniqueField: string,
uniqueValue: unknown,
createData: Record<string, unknown>,
updateData: Record<string, unknown>
): Promise<unknown> {
try {
const model = this.getModel(entity)
const result = await this.withTimeout(
model.upsert({
where: { [uniqueField]: uniqueValue } as never,
create: createData as never,
update: updateData as never,
})
)
return result
} catch (error) {
throw this.handleError(error, 'upsert', entity)
}
}
async updateByField(entity: string, field: string, value: unknown, data: Record<string, unknown>): Promise<unknown> {
try {
const model = this.getModel(entity)
const result = await this.withTimeout(
model.update({
where: { [field]: value } as never,
data: data as never,
})
)
return result
} catch (error) {
throw this.handleError(error, 'updateByField', entity)
}
}
async deleteByField(entity: string, field: string, value: unknown): Promise<boolean> {
try {
const model = this.getModel(entity)
await this.withTimeout(
model.delete({ where: { [field]: value } as never })
)
return true
} catch (error) {
if (this.isNotFoundError(error)) {
return false
}
throw this.handleError(error, 'deleteByField', entity)
}
}
async deleteMany(entity: string, filter?: Record<string, unknown>): Promise<number> {
try {
const model = this.getModel(entity)
const where = filter ? this.buildWhereClause(filter) : undefined
const result: { count: number } = await this.withTimeout(
model.deleteMany({ where: where as never })
)
return result.count
} catch (error) {
throw this.handleError(error, 'deleteMany', entity)
}
}
async updateMany(entity: string, filter: Record<string, unknown>, data: Record<string, unknown>): Promise<number> {
try {
const model = this.getModel(entity)
const where = this.buildWhereClause(filter)
const result: { count: number } = await this.withTimeout(
model.updateMany({ where: where as never, data: data as never })
)
return result.count
} catch (error) {
throw this.handleError(error, 'updateMany', entity)
}
}
async createMany(entity: string, data: Record<string, unknown>[]): Promise<number> {
try {
const model = this.getModel(entity)
const result: { count: number } = await this.withTimeout(
model.createMany({ data: data as never })
)
return result.count
} catch (error) {
throw this.handleError(error, 'createMany', entity)
}
}
async getCapabilities(): Promise<AdapterCapabilities> {
return this.buildCapabilities()
}
async close(): Promise<void> {
await this.prisma.$disconnect()
}
private getModel(entity: string): any {
const modelName = entity.charAt(0).toLowerCase() + entity.slice(1)
const model = (this.prisma as any)[modelName]
if (!model) {
throw DBALError.notFound(`Entity ${entity} not found`)
}
return model
}
private buildWhereClause(filter: Record<string, unknown>): Record<string, unknown> {
const where: Record<string, unknown> = {}
for (const [key, value] of Object.entries(filter)) {
if (value === null || value === undefined) {
where[key] = null
} else if (typeof value === 'object' && !Array.isArray(value)) {
where[key] = value
} else {
where[key] = value
}
}
return where
}
private buildOrderBy(sort: Record<string, 'asc' | 'desc'>): Record<string, string> {
return sort
}
private async withTimeout<T>(promise: Promise<T>): Promise<T> {
return Promise.race([
promise,
new Promise<T>((_, reject) =>
setTimeout(() => reject(DBALError.timeout()), this.queryTimeout)
)
])
}
private isNotFoundError(error: unknown): boolean {
return error instanceof Error && error.message.includes('not found')
}
private handleError(error: unknown, operation: string, entity: string): DBALError {
if (error instanceof DBALError) {
return error
}
if (error instanceof Error) {
if (error.message.includes('Unique constraint')) {
return DBALError.conflict(`${entity} already exists`)
}
if (error.message.includes('Foreign key constraint')) {
return DBALError.validationError('Related resource not found')
}
if (error.message.includes('not found')) {
return DBALError.notFound(`${entity} not found`)
}
return DBALError.internal(`Database error during ${operation}: ${error.message}`)
}
return DBALError.internal(`Unknown error during ${operation}`)
}
private buildCapabilities(): AdapterCapabilities {
const fullTextSearch = this.dialect === 'postgres' || this.dialect === 'mysql'
return {
transactions: true,
joins: true,
fullTextSearch,
ttl: false,
jsonQueries: true,
aggregations: true,
relations: true,
}
}
private static inferDialectFromUrl(url?: string): PrismaAdapterDialect | undefined {
if (!url) {
return undefined
}
if (url.startsWith('postgresql://') || url.startsWith('postgres://')) {
return 'postgres'
}
if (url.startsWith('mysql://')) {
return 'mysql'
}
if (url.startsWith('file:') || url.startsWith('sqlite://')) {
return 'sqlite'
}
return undefined
}
}
export class PostgresAdapter extends PrismaAdapter {
constructor(databaseUrl?: string, options?: PrismaAdapterOptions) {
super(databaseUrl, { ...options, dialect: 'postgres' })
}
}
export class MySQLAdapter extends PrismaAdapter {
constructor(databaseUrl?: string, options?: PrismaAdapterOptions) {
super(databaseUrl, { ...options, dialect: 'mysql' })
}
}

View File

@@ -0,0 +1,38 @@
import { PrismaClient } from '@prisma/client'
import { PrismaAdapterDialect, type PrismaAdapterOptions, type PrismaContext } from './types'
export function createPrismaContext(
databaseUrl?: string,
options?: PrismaAdapterOptions
): PrismaContext {
const inferredDialect = options?.dialect ?? inferDialectFromUrl(databaseUrl)
const prisma = new PrismaClient({
datasources: databaseUrl ? { db: { url: databaseUrl } } : undefined,
})
return {
prisma,
queryTimeout: options?.queryTimeout ?? 30000,
dialect: inferredDialect ?? 'generic'
}
}
export function inferDialectFromUrl(url?: string): PrismaAdapterDialect | undefined {
if (!url) {
return undefined
}
if (url.startsWith('postgresql://') || url.startsWith('postgres://')) {
return 'postgres'
}
if (url.startsWith('mysql://')) {
return 'mysql'
}
if (url.startsWith('file:') || url.startsWith('sqlite://')) {
return 'sqlite'
}
return undefined
}

View File

@@ -0,0 +1,121 @@
import type { DBALAdapter } from '../adapter'
import type { ListOptions, ListResult } from '../../core/foundation/types'
import { createPrismaContext } from './context'
import type { PrismaAdapterOptions, PrismaAdapterDialect, PrismaContext } from './types'
import {
createRecord,
deleteRecord,
readRecord,
updateRecord
} from './operations/crud'
import {
createMany,
deleteByField,
deleteMany,
updateByField,
updateMany,
upsertRecord
} from './operations/bulk'
import {
findByField,
findFirstRecord,
listRecords
} from './operations/query'
import { buildCapabilities } from './operations/capabilities'
export class PrismaAdapter implements DBALAdapter {
protected context: PrismaContext
constructor(databaseUrl?: string, options?: PrismaAdapterOptions) {
this.context = createPrismaContext(databaseUrl, options)
}
create(entity: string, data: Record<string, unknown>): Promise<unknown> {
return createRecord(this.context, entity, data)
}
read(entity: string, id: string): Promise<unknown | null> {
return readRecord(this.context, entity, id)
}
update(entity: string, id: string, data: Record<string, unknown>): Promise<unknown> {
return updateRecord(this.context, entity, id, data)
}
delete(entity: string, id: string): Promise<boolean> {
return deleteRecord(this.context, entity, id)
}
list(entity: string, options?: ListOptions): Promise<ListResult<unknown>> {
return listRecords(this.context, entity, options)
}
findFirst(entity: string, filter?: Record<string, unknown>): Promise<unknown | null> {
return findFirstRecord(this.context, entity, filter)
}
findByField(entity: string, field: string, value: unknown): Promise<unknown | null> {
return findByField(this.context, entity, field, value)
}
upsert(
entity: string,
uniqueField: string,
uniqueValue: unknown,
createData: Record<string, unknown>,
updateData: Record<string, unknown>
): Promise<unknown> {
return upsertRecord(this.context, entity, uniqueField, uniqueValue, createData, updateData)
}
updateByField(
entity: string,
field: string,
value: unknown,
data: Record<string, unknown>
): Promise<unknown> {
return updateByField(this.context, entity, field, value, data)
}
deleteByField(entity: string, field: string, value: unknown): Promise<boolean> {
return deleteByField(this.context, entity, field, value)
}
deleteMany(entity: string, filter?: Record<string, unknown>): Promise<number> {
return deleteMany(this.context, entity, filter)
}
updateMany(
entity: string,
filter: Record<string, unknown>,
data: Record<string, unknown>
): Promise<number> {
return updateMany(this.context, entity, filter, data)
}
createMany(entity: string, data: Record<string, unknown>[]): Promise<number> {
return createMany(this.context, entity, data)
}
getCapabilities() {
return Promise.resolve(buildCapabilities(this.context))
}
async close(): Promise<void> {
await this.context.prisma.$disconnect()
}
}
export class PostgresAdapter extends PrismaAdapter {
constructor(databaseUrl?: string, options?: PrismaAdapterOptions) {
super(databaseUrl, { ...options, dialect: 'postgres' })
}
}
export class MySQLAdapter extends PrismaAdapter {
constructor(databaseUrl?: string, options?: PrismaAdapterOptions) {
super(databaseUrl, { ...options, dialect: 'mysql' })
}
}
export { PrismaAdapterOptions, PrismaAdapterDialect }

View File

@@ -0,0 +1,121 @@
import type { PrismaContext } from '../types'
import { handlePrismaError, buildWhereClause, getModel, withTimeout, isNotFoundError } from './utils'
export async function upsertRecord(
context: PrismaContext,
entity: string,
uniqueField: string,
uniqueValue: unknown,
createData: Record<string, unknown>,
updateData: Record<string, unknown>
): Promise<unknown> {
try {
const model = getModel(context, entity)
return await withTimeout(
context,
model.upsert({
where: { [uniqueField]: uniqueValue } as never,
create: createData as never,
update: updateData as never,
})
)
} catch (error) {
throw handlePrismaError(error, 'upsert', entity)
}
}
export async function updateByField(
context: PrismaContext,
entity: string,
field: string,
value: unknown,
data: Record<string, unknown>
): Promise<unknown> {
try {
const model = getModel(context, entity)
return await withTimeout(
context,
model.update({
where: { [field]: value } as never,
data: data as never,
})
)
} catch (error) {
throw handlePrismaError(error, 'updateByField', entity)
}
}
export async function deleteByField(
context: PrismaContext,
entity: string,
field: string,
value: unknown
): Promise<boolean> {
try {
const model = getModel(context, entity)
await withTimeout(
context,
model.delete({ where: { [field]: value } as never })
)
return true
} catch (error) {
if (isNotFoundError(error)) {
return false
}
throw handlePrismaError(error, 'deleteByField', entity)
}
}
export async function deleteMany(
context: PrismaContext,
entity: string,
filter?: Record<string, unknown>
): Promise<number> {
try {
const model = getModel(context, entity)
const where = filter ? buildWhereClause(filter) : undefined
const result: { count: number } = await withTimeout(
context,
model.deleteMany({ where: where as never })
)
return result.count
} catch (error) {
throw handlePrismaError(error, 'deleteMany', entity)
}
}
export async function updateMany(
context: PrismaContext,
entity: string,
filter: Record<string, unknown>,
data: Record<string, unknown>
): Promise<number> {
try {
const model = getModel(context, entity)
const where = buildWhereClause(filter)
const result: { count: number } = await withTimeout(
context,
model.updateMany({ where: where as never, data: data as never })
)
return result.count
} catch (error) {
throw handlePrismaError(error, 'updateMany', entity)
}
}
export async function createMany(
context: PrismaContext,
entity: string,
data: Record<string, unknown>[]
): Promise<number> {
try {
const model = getModel(context, entity)
const result: { count: number } = await withTimeout(
context,
model.createMany({ data: data as never })
)
return result.count
} catch (error) {
throw handlePrismaError(error, 'createMany', entity)
}
}

View File

@@ -0,0 +1,16 @@
import type { AdapterCapabilities } from '../adapter'
import type { PrismaContext } from '../types'
export function buildCapabilities(context: PrismaContext): AdapterCapabilities {
const fullTextSearch = context.dialect === 'postgres' || context.dialect === 'mysql'
return {
transactions: true,
joins: true,
fullTextSearch,
ttl: false,
jsonQueries: true,
aggregations: true,
relations: true,
}
}

View File

@@ -0,0 +1,71 @@
import type { PrismaContext } from '../types'
import { handlePrismaError, getModel, withTimeout, isNotFoundError } from './utils'
export async function createRecord(
context: PrismaContext,
entity: string,
data: Record<string, unknown>
): Promise<unknown> {
try {
const model = getModel(context, entity)
return await withTimeout(context, model.create({ data: data as never }))
} catch (error) {
throw handlePrismaError(error, 'create', entity)
}
}
export async function readRecord(
context: PrismaContext,
entity: string,
id: string
): Promise<unknown | null> {
try {
const model = getModel(context, entity)
return await withTimeout(
context,
model.findUnique({ where: { id } as never })
)
} catch (error) {
throw handlePrismaError(error, 'read', entity)
}
}
export async function updateRecord(
context: PrismaContext,
entity: string,
id: string,
data: Record<string, unknown>
): Promise<unknown> {
try {
const model = getModel(context, entity)
return await withTimeout(
context,
model.update({
where: { id } as never,
data: data as never
})
)
} catch (error) {
throw handlePrismaError(error, 'update', entity)
}
}
export async function deleteRecord(
context: PrismaContext,
entity: string,
id: string
): Promise<boolean> {
try {
const model = getModel(context, entity)
await withTimeout(
context,
model.delete({ where: { id } as never })
)
return true
} catch (error) {
if (isNotFoundError(error)) {
return false
}
throw handlePrismaError(error, 'delete', entity)
}
}

View File

@@ -0,0 +1,79 @@
import type { ListOptions, ListResult } from '../../core/foundation/types'
import type { PrismaContext } from '../types'
import { handlePrismaError, buildWhereClause, buildOrderBy, getModel, withTimeout } from './utils'
export async function listRecords(
context: PrismaContext,
entity: string,
options?: ListOptions
): Promise<ListResult<unknown>> {
try {
const model = getModel(context, entity)
const page = options?.page || 1
const limit = options?.limit || 50
const skip = (page - 1) * limit
const where = options?.filter ? buildWhereClause(options.filter) : undefined
const orderBy = options?.sort ? buildOrderBy(options.sort) : undefined
const [data, total] = await Promise.all([
withTimeout(
context,
model.findMany({
where: where as never,
orderBy: orderBy as never,
skip,
take: limit,
})
),
withTimeout(
context,
model.count({ where: where as never })
)
]) as [unknown[], number]
return {
data: data as unknown[],
total,
page,
limit,
hasMore: skip + limit < total,
}
} catch (error) {
throw handlePrismaError(error, 'list', entity)
}
}
export async function findFirstRecord(
context: PrismaContext,
entity: string,
filter?: Record<string, unknown>
): Promise<unknown | null> {
try {
const model = getModel(context, entity)
const where = filter ? buildWhereClause(filter) : undefined
return await withTimeout(
context,
model.findFirst({ where: where as never })
)
} catch (error) {
throw handlePrismaError(error, 'findFirst', entity)
}
}
export async function findByField(
context: PrismaContext,
entity: string,
field: string,
value: unknown
): Promise<unknown | null> {
try {
const model = getModel(context, entity)
return await withTimeout(
context,
model.findUnique({ where: { [field]: value } as never })
)
} catch (error) {
throw handlePrismaError(error, 'findByField', entity)
}
}

View File

@@ -0,0 +1,71 @@
import type { PrismaContext } from '../types'
import { DBALError } from '../../core/foundation/errors'
export function getModel(context: PrismaContext, entity: string): any {
const modelName = entity.charAt(0).toLowerCase() + entity.slice(1)
const model = (context.prisma as any)[modelName]
if (!model) {
throw DBALError.notFound(`Entity ${entity} not found`)
}
return model
}
export function buildWhereClause(filter: Record<string, unknown>): Record<string, unknown> {
const where: Record<string, unknown> = {}
for (const [key, value] of Object.entries(filter)) {
if (value === null || value === undefined) {
where[key] = null
} else if (typeof value === 'object' && !Array.isArray(value)) {
where[key] = value
} else {
where[key] = value
}
}
return where
}
export function buildOrderBy(sort: Record<string, 'asc' | 'desc'>): Record<string, string> {
return sort
}
export async function withTimeout<T>(context: PrismaContext, promise: Promise<T>): Promise<T> {
return Promise.race([
promise,
new Promise<T>((_, reject) =>
setTimeout(() => reject(DBALError.timeout()), context.queryTimeout)
)
])
}
export function isNotFoundError(error: unknown): boolean {
return error instanceof Error && error.message.includes('not found')
}
export function handlePrismaError(
error: unknown,
operation: string,
entity: string
): DBALError {
if (error instanceof DBALError) {
return error
}
if (error instanceof Error) {
if (error.message.includes('Unique constraint')) {
return DBALError.conflict(`${entity} already exists`)
}
if (error.message.includes('Foreign key constraint')) {
return DBALError.validationError('Related resource not found')
}
if (error.message.includes('not found')) {
return DBALError.notFound(`${entity} not found`)
}
return DBALError.internal(`Database error during ${operation}: ${error.message}`)
}
return DBALError.internal(`Unknown error during ${operation}`)
}

View File

@@ -0,0 +1,38 @@
import type { AdapterCapabilities } from '../adapter'
export type PrismaAdapterDialect = 'postgres' | 'mysql' | 'sqlite' | 'generic'
export interface PrismaAdapterOptions {
queryTimeout?: number
dialect?: PrismaAdapterDialect
}
export interface PrismaContext {
prisma: any
queryTimeout: number
dialect: PrismaAdapterDialect
}
export interface PrismaOperations {
create(entity: string, data: Record<string, unknown>): Promise<unknown>
read(entity: string, id: string): Promise<unknown | null>
update(entity: string, id: string, data: Record<string, unknown>): Promise<unknown>
delete(entity: string, id: string): Promise<boolean>
list(entity: string, options?: any): Promise<any>
findFirst(entity: string, filter?: Record<string, unknown>): Promise<unknown | null>
findByField(entity: string, field: string, value: unknown): Promise<unknown | null>
upsert(
entity: string,
uniqueField: string,
uniqueValue: unknown,
createData: Record<string, unknown>,
updateData: Record<string, unknown>
): Promise<unknown>
updateByField(entity: string, field: string, value: unknown, data: Record<string, unknown>): Promise<unknown>
deleteByField(entity: string, field: string, value: unknown): Promise<boolean>
deleteMany(entity: string, filter?: Record<string, unknown>): Promise<number>
createMany(entity: string, data: Record<string, unknown>[]): Promise<number>
updateMany(entity: string, filter: Record<string, unknown>, data: Record<string, unknown>): Promise<number>
getCapabilities(): Promise<AdapterCapabilities>
close(): Promise<void>
}

View File

@@ -1,13 +1,13 @@
export * from './blob-storage'
export { MemoryStorage } from './providers/memory-storage'
export { S3Storage } from './providers/s3-storage'
export { FilesystemStorage } from './providers/filesystem-storage'
export { S3Storage } from './providers/s3'
export { FilesystemStorage } from './providers/filesystem'
export { TenantAwareBlobStorage } from './providers/tenant-aware-storage'
import type { BlobStorage, BlobStorageConfig } from './blob-storage'
import { MemoryStorage } from './providers/memory-storage'
import { S3Storage } from './providers/s3-storage'
import { FilesystemStorage } from './providers/filesystem-storage'
import { S3Storage } from './providers/s3'
import { FilesystemStorage } from './providers/filesystem'
/**
* Factory function to create blob storage instances

View File

@@ -1,410 +0,0 @@
import type {
BlobStorage,
BlobMetadata,
BlobListResult,
UploadOptions,
DownloadOptions,
BlobListOptions,
BlobStorageConfig,
} from '../blob-storage'
import { DBALError } from '../../core/foundation/errors'
import { promises as fs } from 'fs'
import { createReadStream, createWriteStream } from 'fs'
import path from 'path'
import { createHash } from 'crypto'
import { pipeline } from 'stream/promises'
/**
* Filesystem blob storage implementation
* Compatible with local filesystem, Samba/CIFS, NFS
*/
export class FilesystemStorage implements BlobStorage {
private basePath: string
constructor(config: BlobStorageConfig) {
if (!config.filesystem) {
throw new Error('Filesystem configuration required')
}
this.basePath = config.filesystem.basePath
if (config.filesystem.createIfNotExists) {
this.ensureBasePath()
}
}
private async ensureBasePath() {
try {
await fs.mkdir(this.basePath, { recursive: true })
} catch (error: any) {
throw new Error(`Failed to create base path: ${error.message}`)
}
}
private getFullPath(key: string): string {
// Prevent directory traversal attacks
const normalized = path.normalize(key).replace(/^(\.\.(\/|\\|$))+/, '')
return path.join(this.basePath, normalized)
}
private getMetadataPath(key: string): string {
return this.getFullPath(key) + '.meta.json'
}
async upload(
key: string,
data: Buffer | Uint8Array,
options: UploadOptions = {}
): Promise<BlobMetadata> {
const filePath = this.getFullPath(key)
const metaPath = this.getMetadataPath(key)
try {
// Create directory if needed
await fs.mkdir(path.dirname(filePath), { recursive: true })
// Check if file exists and overwrite is false
if (!options.overwrite) {
try {
await fs.access(filePath)
throw DBALError.conflict(`Blob already exists: ${key}`)
} catch (error: any) {
if (error.code !== 'ENOENT') {
throw error
}
}
}
// Write file
await fs.writeFile(filePath, data)
// Generate metadata
const buffer = Buffer.from(data)
const etag = this.generateEtag(buffer)
const metadata: BlobMetadata = {
key,
size: buffer.length,
contentType: options.contentType || 'application/octet-stream',
etag,
lastModified: new Date(),
customMetadata: options.metadata,
}
// Write metadata
await fs.writeFile(metaPath, JSON.stringify(metadata, null, 2))
return metadata
} catch (error: any) {
if (error instanceof DBALError) {
throw error
}
throw DBALError.internal(`Filesystem upload failed: ${error.message}`)
}
}
async uploadStream(
key: string,
stream: ReadableStream | NodeJS.ReadableStream,
size: number,
options: UploadOptions = {}
): Promise<BlobMetadata> {
const filePath = this.getFullPath(key)
const metaPath = this.getMetadataPath(key)
try {
// Create directory if needed
await fs.mkdir(path.dirname(filePath), { recursive: true })
// Check if file exists and overwrite is false
if (!options.overwrite) {
try {
await fs.access(filePath)
throw DBALError.conflict(`Blob already exists: ${key}`)
} catch (error: any) {
if (error.code !== 'ENOENT') {
throw error
}
}
}
// Write stream to file
const writeStream = createWriteStream(filePath)
if ('getReader' in stream) {
// Web ReadableStream
const reader = stream.getReader()
while (true) {
const { done, value } = await reader.read()
if (done) break
writeStream.write(Buffer.from(value))
}
writeStream.end()
} else {
// Node.js ReadableStream
await pipeline(stream, writeStream)
}
// Get file stats for actual size
const stats = await fs.stat(filePath)
// Generate etag from file
const buffer = await fs.readFile(filePath)
const etag = this.generateEtag(buffer)
const metadata: BlobMetadata = {
key,
size: stats.size,
contentType: options.contentType || 'application/octet-stream',
etag,
lastModified: stats.mtime,
customMetadata: options.metadata,
}
// Write metadata
await fs.writeFile(metaPath, JSON.stringify(metadata, null, 2))
return metadata
} catch (error: any) {
if (error instanceof DBALError) {
throw error
}
throw DBALError.internal(`Filesystem stream upload failed: ${error.message}`)
}
}
async download(
key: string,
options: DownloadOptions = {}
): Promise<Buffer> {
const filePath = this.getFullPath(key)
try {
let data = await fs.readFile(filePath)
if (options.offset !== undefined || options.length !== undefined) {
const offset = options.offset || 0
const length = options.length || (data.length - offset)
if (offset >= data.length) {
throw DBALError.validationError('Offset exceeds blob size')
}
data = data.subarray(offset, offset + length)
}
return data
} catch (error: any) {
if (error.code === 'ENOENT') {
throw DBALError.notFound(`Blob not found: ${key}`)
}
if (error instanceof DBALError) {
throw error
}
throw DBALError.internal(`Filesystem download failed: ${error.message}`)
}
}
async downloadStream(
key: string,
options: DownloadOptions = {}
): Promise<NodeJS.ReadableStream> {
const filePath = this.getFullPath(key)
try {
await fs.access(filePath)
const streamOptions: any = {}
if (options.offset !== undefined) {
streamOptions.start = options.offset
}
if (options.length !== undefined) {
streamOptions.end = (options.offset || 0) + options.length - 1
}
return createReadStream(filePath, streamOptions)
} catch (error: any) {
if (error.code === 'ENOENT') {
throw DBALError.notFound(`Blob not found: ${key}`)
}
throw DBALError.internal(`Filesystem download stream failed: ${error.message}`)
}
}
async delete(key: string): Promise<boolean> {
const filePath = this.getFullPath(key)
const metaPath = this.getMetadataPath(key)
try {
await fs.unlink(filePath)
// Try to delete metadata (ignore if doesn't exist)
try {
await fs.unlink(metaPath)
} catch (error: any) {
// Ignore if metadata doesn't exist
}
return true
} catch (error: any) {
if (error.code === 'ENOENT') {
throw DBALError.notFound(`Blob not found: ${key}`)
}
throw DBALError.internal(`Filesystem delete failed: ${error.message}`)
}
}
async exists(key: string): Promise<boolean> {
const filePath = this.getFullPath(key)
try {
await fs.access(filePath)
return true
} catch {
return false
}
}
async getMetadata(key: string): Promise<BlobMetadata> {
const filePath = this.getFullPath(key)
const metaPath = this.getMetadataPath(key)
try {
// Check if file exists
const stats = await fs.stat(filePath)
// Try to read metadata file
try {
const metaContent = await fs.readFile(metaPath, 'utf-8')
return JSON.parse(metaContent)
} catch {
// Generate metadata from file if meta file doesn't exist
const data = await fs.readFile(filePath)
return {
key,
size: stats.size,
contentType: 'application/octet-stream',
etag: this.generateEtag(data),
lastModified: stats.mtime,
}
}
} catch (error: any) {
if (error.code === 'ENOENT') {
throw DBALError.notFound(`Blob not found: ${key}`)
}
throw DBALError.internal(`Filesystem get metadata failed: ${error.message}`)
}
}
async list(options: BlobListOptions = {}): Promise<BlobListResult> {
const prefix = options.prefix || ''
const maxKeys = options.maxKeys || 1000
try {
const items: BlobMetadata[] = []
await this.walkDirectory(this.basePath, prefix, maxKeys, items)
return {
items: items.slice(0, maxKeys),
isTruncated: items.length > maxKeys,
nextToken: items.length > maxKeys ? items[maxKeys].key : undefined,
}
} catch (error: any) {
throw DBALError.internal(`Filesystem list failed: ${error.message}`)
}
}
private async walkDirectory(
dir: string,
prefix: string,
maxKeys: number,
items: BlobMetadata[]
) {
if (items.length >= maxKeys) return
const entries = await fs.readdir(dir, { withFileTypes: true })
for (const entry of entries) {
if (items.length >= maxKeys) break
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
await this.walkDirectory(fullPath, prefix, maxKeys, items)
} else if (!entry.name.endsWith('.meta.json')) {
const relativePath = path.relative(this.basePath, fullPath)
const normalizedKey = relativePath.split(path.sep).join('/')
if (!prefix || normalizedKey.startsWith(prefix)) {
try {
const metadata = await this.getMetadata(normalizedKey)
items.push(metadata)
} catch {
// Skip files that can't be read
}
}
}
}
}
async generatePresignedUrl(
key: string,
expirationSeconds: number = 3600
): Promise<string> {
// Filesystem storage doesn't support presigned URLs
return ''
}
async copy(
sourceKey: string,
destKey: string
): Promise<BlobMetadata> {
const sourcePath = this.getFullPath(sourceKey)
const destPath = this.getFullPath(destKey)
const sourceMetaPath = this.getMetadataPath(sourceKey)
const destMetaPath = this.getMetadataPath(destKey)
try {
// Create destination directory if needed
await fs.mkdir(path.dirname(destPath), { recursive: true })
// Copy file
await fs.copyFile(sourcePath, destPath)
// Copy or regenerate metadata
try {
await fs.copyFile(sourceMetaPath, destMetaPath)
// Update lastModified in metadata
const metadata = JSON.parse(await fs.readFile(destMetaPath, 'utf-8'))
metadata.lastModified = new Date()
metadata.key = destKey
await fs.writeFile(destMetaPath, JSON.stringify(metadata, null, 2))
return metadata
} catch {
// Regenerate metadata if copy fails
return await this.getMetadata(destKey)
}
} catch (error: any) {
if (error.code === 'ENOENT') {
throw DBALError.notFound(`Source blob not found: ${sourceKey}`)
}
throw DBALError.internal(`Filesystem copy failed: ${error.message}`)
}
}
async getTotalSize(): Promise<number> {
const items = await this.list({ maxKeys: Number.MAX_SAFE_INTEGER })
return items.items.reduce((sum, item) => sum + item.size, 0)
}
async getObjectCount(): Promise<number> {
const items = await this.list({ maxKeys: Number.MAX_SAFE_INTEGER })
return items.items.length
}
private generateEtag(data: Buffer): string {
const hash = createHash('md5').update(data).digest('hex')
return `"${hash}"`
}
}

View File

@@ -0,0 +1,28 @@
import type { BlobStorageConfig } from '../../blob-storage'
import { promises as fs } from 'fs'
export interface FilesystemContext {
basePath: string
}
export function createFilesystemContext(config: BlobStorageConfig): FilesystemContext {
if (!config.filesystem) {
throw new Error('Filesystem configuration required')
}
const basePath = config.filesystem.basePath
if (config.filesystem.createIfNotExists) {
void ensureBasePath(basePath)
}
return { basePath }
}
async function ensureBasePath(basePath: string) {
try {
await fs.mkdir(basePath, { recursive: true })
} catch (error: any) {
throw new Error(`Failed to create base path: ${error.message}`)
}
}

View File

@@ -0,0 +1,98 @@
import { promises as fs } from 'fs'
import type {
BlobStorage,
BlobMetadata,
BlobListResult,
UploadOptions,
DownloadOptions,
BlobListOptions,
BlobStorageConfig,
} from '../../blob-storage'
import { createFilesystemContext, type FilesystemContext } from './context'
import { buildFullPath } from './paths'
import { copyBlob, deleteBlob, objectCount, totalSize } from './operations/maintenance'
import { downloadBuffer, downloadStream } from './operations/downloads'
import { readMetadata } from './operations/metadata'
import { listBlobs } from './operations/listing'
import { uploadBuffer, uploadStream } from './operations/uploads'
export class FilesystemStorage implements BlobStorage {
private readonly context: FilesystemContext
constructor(config: BlobStorageConfig) {
this.context = createFilesystemContext(config)
}
upload(
key: string,
data: Buffer | Uint8Array,
options: UploadOptions = {}
): Promise<BlobMetadata> {
return uploadBuffer(this.context, key, data, options)
}
uploadStream(
key: string,
stream: ReadableStream | NodeJS.ReadableStream,
size: number,
options: UploadOptions = {}
): Promise<BlobMetadata> {
return uploadStream(this.context, key, stream, size, options)
}
download(
key: string,
options: DownloadOptions = {}
): Promise<Buffer> {
return downloadBuffer(this.context, key, options)
}
downloadStream(
key: string,
options: DownloadOptions = {}
): Promise<NodeJS.ReadableStream> {
return downloadStream(this.context, key, options)
}
delete(key: string): Promise<boolean> {
return deleteBlob(this.context, key)
}
async exists(key: string): Promise<boolean> {
const filePath = buildFullPath(this.context.basePath, key)
try {
await fs.access(filePath)
return true
} catch {
return false
}
}
getMetadata(key: string): Promise<BlobMetadata> {
return readMetadata(this.context, key)
}
list(options: BlobListOptions = {}): Promise<BlobListResult> {
return listBlobs(this.context, options)
}
async generatePresignedUrl(
key: string,
expirationSeconds: number = 3600
): Promise<string> {
return ''
}
copy(sourceKey: string, destKey: string): Promise<BlobMetadata> {
return copyBlob(this.context, sourceKey, destKey)
}
getTotalSize(): Promise<number> {
return totalSize(this.context)
}
getObjectCount(): Promise<number> {
return objectCount(this.context)
}
}

View File

@@ -0,0 +1,65 @@
import { promises as fs, createReadStream } from 'fs'
import type { DownloadOptions } from '../../../blob-storage'
import { DBALError } from '../../../core/foundation/errors'
import type { FilesystemContext } from '../context'
import { buildFullPath } from '../paths'
export async function downloadBuffer(
context: FilesystemContext,
key: string,
options: DownloadOptions
): Promise<Buffer> {
const filePath = buildFullPath(context.basePath, key)
try {
let data = await fs.readFile(filePath)
if (options.offset !== undefined || options.length !== undefined) {
const offset = options.offset || 0
const length = options.length || (data.length - offset)
if (offset >= data.length) {
throw DBALError.validationError('Offset exceeds blob size')
}
data = data.subarray(offset, offset + length)
}
return data
} catch (error: any) {
if (error.code === 'ENOENT') {
throw DBALError.notFound(`Blob not found: ${key}`)
}
if (error instanceof DBALError) {
throw error
}
throw DBALError.internal(`Filesystem download failed: ${error.message}`)
}
}
export async function downloadStream(
context: FilesystemContext,
key: string,
options: DownloadOptions
): Promise<NodeJS.ReadableStream> {
const filePath = buildFullPath(context.basePath, key)
try {
await fs.access(filePath)
const streamOptions: any = {}
if (options.offset !== undefined) {
streamOptions.start = options.offset
}
if (options.length !== undefined) {
streamOptions.end = (options.offset || 0) + options.length - 1
}
return createReadStream(filePath, streamOptions)
} catch (error: any) {
if (error.code === 'ENOENT') {
throw DBALError.notFound(`Blob not found: ${key}`)
}
throw DBALError.internal(`Filesystem download stream failed: ${error.message}`)
}
}

View File

@@ -0,0 +1,62 @@
import { promises as fs } from 'fs'
import path from 'path'
import type { BlobListOptions, BlobListResult, BlobMetadata } from '../../../blob-storage'
import { DBALError } from '../../../core/foundation/errors'
import type { FilesystemContext } from '../context'
import { buildFullPath } from '../paths'
import { readMetadata } from './metadata'
export async function listBlobs(
context: FilesystemContext,
options: BlobListOptions
): Promise<BlobListResult> {
const prefix = options.prefix || ''
const maxKeys = options.maxKeys || 1000
try {
const items: BlobMetadata[] = []
await walkDirectory(context, context.basePath, prefix, maxKeys, items)
return {
items: items.slice(0, maxKeys),
isTruncated: items.length > maxKeys,
nextToken: items.length > maxKeys ? items[maxKeys].key : undefined,
}
} catch (error: any) {
throw DBALError.internal(`Filesystem list failed: ${error.message}`)
}
}
async function walkDirectory(
context: FilesystemContext,
dir: string,
prefix: string,
maxKeys: number,
items: BlobMetadata[]
) {
if (items.length >= maxKeys) return
const entries = await fs.readdir(dir, { withFileTypes: true })
for (const entry of entries) {
if (items.length >= maxKeys) break
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
await walkDirectory(context, fullPath, prefix, maxKeys, items)
} else if (!entry.name.endsWith('.meta.json')) {
const relativePath = path.relative(context.basePath, fullPath)
const normalizedKey = relativePath.split(path.sep).join('/')
if (!prefix || normalizedKey.startsWith(prefix)) {
try {
const metadata = await readMetadata(context, normalizedKey)
items.push(metadata)
} catch {
// Skip files that can't be read
}
}
}
}
}

View File

@@ -0,0 +1,75 @@
import { promises as fs } from 'fs'
import path from 'path'
import type { BlobMetadata } from '../../../blob-storage'
import { DBALError } from '../../../core/foundation/errors'
import type { FilesystemContext } from '../context'
import { buildFullPath, buildMetadataPath } from '../paths'
import { readMetadata } from './metadata'
import { listBlobs } from './listing'
export async function deleteBlob(
context: FilesystemContext,
key: string
): Promise<boolean> {
const filePath = buildFullPath(context.basePath, key)
const metaPath = buildMetadataPath(context.basePath, key)
try {
await fs.unlink(filePath)
try {
await fs.unlink(metaPath)
} catch {
// Ignore missing metadata files
}
return true
} catch (error: any) {
if (error.code === 'ENOENT') {
throw DBALError.notFound(`Blob not found: ${key}`)
}
throw DBALError.internal(`Filesystem delete failed: ${error.message}`)
}
}
export async function copyBlob(
context: FilesystemContext,
sourceKey: string,
destKey: string
): Promise<BlobMetadata> {
const sourcePath = buildFullPath(context.basePath, sourceKey)
const destPath = buildFullPath(context.basePath, destKey)
const sourceMetaPath = buildMetadataPath(context.basePath, sourceKey)
const destMetaPath = buildMetadataPath(context.basePath, destKey)
try {
await fs.mkdir(path.dirname(destPath), { recursive: true })
await fs.copyFile(sourcePath, destPath)
try {
await fs.copyFile(sourceMetaPath, destMetaPath)
const metadata = JSON.parse(await fs.readFile(destMetaPath, 'utf-8'))
metadata.lastModified = new Date()
metadata.key = destKey
await fs.writeFile(destMetaPath, JSON.stringify(metadata, null, 2))
return metadata
} catch {
return await readMetadata(context, destKey)
}
} catch (error: any) {
if (error.code === 'ENOENT') {
throw DBALError.notFound(`Source blob not found: ${sourceKey}`)
}
throw DBALError.internal(`Filesystem copy failed: ${error.message}`)
}
}
export async function totalSize(context: FilesystemContext): Promise<number> {
const items = await listBlobs(context, { maxKeys: Number.MAX_SAFE_INTEGER })
return items.items.reduce((sum, item) => sum + item.size, 0)
}
export async function objectCount(context: FilesystemContext): Promise<number> {
const items = await listBlobs(context, { maxKeys: Number.MAX_SAFE_INTEGER })
return items.items.length
}

View File

@@ -0,0 +1,51 @@
import { promises as fs } from 'fs'
import { createHash } from 'crypto'
import type { BlobMetadata } from '../../../blob-storage'
import { DBALError } from '../../../core/foundation/errors'
import type { FilesystemContext } from '../context'
import { buildFullPath, buildMetadataPath } from '../paths'
export async function readMetadata(
context: FilesystemContext,
key: string
): Promise<BlobMetadata> {
const filePath = buildFullPath(context.basePath, key)
const metaPath = buildMetadataPath(context.basePath, key)
try {
const stats = await fs.stat(filePath)
try {
const metaContent = await fs.readFile(metaPath, 'utf-8')
return JSON.parse(metaContent)
} catch {
const data = await fs.readFile(filePath)
return {
key,
size: stats.size,
contentType: 'application/octet-stream',
etag: generateEtag(data),
lastModified: stats.mtime,
}
}
} catch (error: any) {
if (error.code === 'ENOENT') {
throw DBALError.notFound(`Blob not found: ${key}`)
}
throw DBALError.internal(`Filesystem get metadata failed: ${error.message}`)
}
}
export async function writeMetadata(
context: FilesystemContext,
key: string,
metadata: BlobMetadata
) {
const metaPath = buildMetadataPath(context.basePath, key)
await fs.writeFile(metaPath, JSON.stringify(metadata, null, 2))
}
export function generateEtag(data: Buffer): string {
const hash = createHash('md5').update(data).digest('hex')
return `"${hash}"`
}

View File

@@ -0,0 +1,109 @@
import { promises as fs, createWriteStream } from 'fs'
import path from 'path'
import { pipeline } from 'stream/promises'
import type { BlobMetadata, UploadOptions } from '../../../blob-storage'
import { DBALError } from '../../../core/foundation/errors'
import type { FilesystemContext } from '../context'
import { buildFullPath, buildMetadataPath } from '../paths'
import { generateEtag, writeMetadata } from './metadata'
async function ensureWritableDestination(
filePath: string,
overwrite?: boolean
) {
await fs.mkdir(path.dirname(filePath), { recursive: true })
if (!overwrite) {
try {
await fs.access(filePath)
throw DBALError.conflict(`Blob already exists: ${filePath}`)
} catch (error: any) {
if (error.code !== 'ENOENT') {
throw error
}
}
}
}
export async function uploadBuffer(
context: FilesystemContext,
key: string,
data: Buffer | Uint8Array,
options: UploadOptions
): Promise<BlobMetadata> {
const filePath = buildFullPath(context.basePath, key)
const metaPath = buildMetadataPath(context.basePath, key)
try {
await ensureWritableDestination(filePath, options.overwrite)
await fs.writeFile(filePath, data)
const buffer = Buffer.from(data)
const metadata: BlobMetadata = {
key,
size: buffer.length,
contentType: options.contentType || 'application/octet-stream',
etag: generateEtag(buffer),
lastModified: new Date(),
customMetadata: options.metadata,
}
await fs.writeFile(metaPath, JSON.stringify(metadata, null, 2))
return metadata
} catch (error: any) {
if (error instanceof DBALError) {
throw error
}
throw DBALError.internal(`Filesystem upload failed: ${error.message}`)
}
}
export async function uploadStream(
context: FilesystemContext,
key: string,
stream: ReadableStream | NodeJS.ReadableStream,
size: number,
options: UploadOptions
): Promise<BlobMetadata> {
const filePath = buildFullPath(context.basePath, key)
try {
await ensureWritableDestination(filePath, options.overwrite)
const writeStream = createWriteStream(filePath)
if ('getReader' in stream) {
const reader = stream.getReader()
while (true) {
const { done, value } = await reader.read()
if (done) break
writeStream.write(Buffer.from(value))
}
writeStream.end()
} else {
await pipeline(stream, writeStream)
}
const stats = await fs.stat(filePath)
const buffer = await fs.readFile(filePath)
const metadata: BlobMetadata = {
key,
size: stats.size,
contentType: options.contentType || 'application/octet-stream',
etag: generateEtag(buffer),
lastModified: stats.mtime,
customMetadata: options.metadata,
}
await writeMetadata(context, key, metadata)
return metadata
} catch (error: any) {
if (error instanceof DBALError) {
throw error
}
throw DBALError.internal(`Filesystem stream upload failed: ${error.message}`)
}
}

View File

@@ -0,0 +1,11 @@
import path from 'path'
import { sanitizeKey } from './sanitize-key'
export function buildFullPath(basePath: string, key: string): string {
const normalized = sanitizeKey(key)
return path.join(basePath, normalized)
}
export function buildMetadataPath(basePath: string, key: string): string {
return buildFullPath(basePath, key) + '.meta.json'
}

View File

@@ -0,0 +1,3 @@
export function sanitizeKey(key: string): string {
return key.replace(/^(\.\.(\/|\\|$))+/, '')
}

View File

@@ -1,230 +1 @@
import type {
BlobStorage,
BlobMetadata,
BlobListResult,
UploadOptions,
DownloadOptions,
BlobListOptions,
} from '../blob-storage'
import { DBALError } from '../../core/foundation/errors'
import { createHash } from 'crypto'
interface BlobData {
data: Buffer
contentType: string
etag: string
lastModified: Date
metadata: Record<string, string>
}
/**
* In-memory blob storage implementation
* Useful for testing and development
*/
export class MemoryStorage implements BlobStorage {
private store: Map<string, BlobData> = new Map()
async upload(
key: string,
data: Buffer | Uint8Array,
options: UploadOptions = {}
): Promise<BlobMetadata> {
const buffer = Buffer.from(data)
if (!options.overwrite && this.store.has(key)) {
throw DBALError.conflict(`Blob already exists: ${key}`)
}
const blob: BlobData = {
data: buffer,
contentType: options.contentType || 'application/octet-stream',
etag: this.generateEtag(buffer),
lastModified: new Date(),
metadata: options.metadata || {},
}
this.store.set(key, blob)
return this.makeBlobMetadata(key, blob)
}
async uploadStream(
key: string,
stream: ReadableStream | NodeJS.ReadableStream,
size: number,
options: UploadOptions = {}
): Promise<BlobMetadata> {
// Collect stream data into buffer
const chunks: Buffer[] = []
if ('getReader' in stream) {
// Web ReadableStream
const reader = stream.getReader()
while (true) {
const { done, value } = await reader.read()
if (done) break
chunks.push(Buffer.from(value))
}
} else {
// Node.js ReadableStream
for await (const chunk of stream) {
chunks.push(Buffer.from(chunk))
}
}
const buffer = Buffer.concat(chunks)
return this.upload(key, buffer, options)
}
async download(
key: string,
options: DownloadOptions = {}
): Promise<Buffer> {
const blob = this.store.get(key)
if (!blob) {
throw DBALError.notFound(`Blob not found: ${key}`)
}
let data = blob.data
if (options.offset !== undefined || options.length !== undefined) {
const offset = options.offset || 0
const length = options.length || (data.length - offset)
if (offset >= data.length) {
throw DBALError.validationError('Offset exceeds blob size')
}
data = data.subarray(offset, offset + length)
}
return data
}
async downloadStream(
key: string,
options: DownloadOptions = {}
): Promise<ReadableStream | NodeJS.ReadableStream> {
const data = await this.download(key, options)
// Return a readable stream
if (typeof ReadableStream !== 'undefined') {
// Web ReadableStream
return new ReadableStream({
start(controller) {
controller.enqueue(data)
controller.close()
},
})
} else {
// Node.js ReadableStream
const { Readable } = await import('stream')
return Readable.from(data)
}
}
async delete(key: string): Promise<boolean> {
if (!this.store.has(key)) {
throw DBALError.notFound(`Blob not found: ${key}`)
}
this.store.delete(key)
return true
}
async exists(key: string): Promise<boolean> {
return this.store.has(key)
}
async getMetadata(key: string): Promise<BlobMetadata> {
const blob = this.store.get(key)
if (!blob) {
throw DBALError.notFound(`Blob not found: ${key}`)
}
return this.makeBlobMetadata(key, blob)
}
async list(options: BlobListOptions = {}): Promise<BlobListResult> {
const prefix = options.prefix || ''
const maxKeys = options.maxKeys || 1000
const items: BlobMetadata[] = []
let nextToken: string | undefined
for (const [key, blob] of this.store.entries()) {
if (!prefix || key.startsWith(prefix)) {
if (items.length >= maxKeys) {
nextToken = key
break
}
items.push(this.makeBlobMetadata(key, blob))
}
}
return {
items,
nextToken,
isTruncated: nextToken !== undefined,
}
}
async generatePresignedUrl(
key: string,
expirationSeconds: number = 3600
): Promise<string> {
// Memory storage doesn't support presigned URLs
return ''
}
async copy(
sourceKey: string,
destKey: string
): Promise<BlobMetadata> {
const sourceBlob = this.store.get(sourceKey)
if (!sourceBlob) {
throw DBALError.notFound(`Source blob not found: ${sourceKey}`)
}
const destBlob: BlobData = {
...sourceBlob,
data: Buffer.from(sourceBlob.data),
lastModified: new Date(),
}
this.store.set(destKey, destBlob)
return this.makeBlobMetadata(destKey, destBlob)
}
async getTotalSize(): Promise<number> {
let total = 0
for (const blob of this.store.values()) {
total += blob.data.length
}
return total
}
async getObjectCount(): Promise<number> {
return this.store.size
}
private generateEtag(data: Buffer): string {
const hash = createHash('md5').update(data).digest('hex')
return `"${hash}"`
}
private makeBlobMetadata(key: string, blob: BlobData): BlobMetadata {
return {
key,
size: blob.data.length,
contentType: blob.contentType,
etag: blob.etag,
lastModified: blob.lastModified,
customMetadata: blob.metadata,
}
}
}
export { MemoryStorage } from './memory-storage/index'

View File

@@ -0,0 +1,50 @@
import { DBALError } from '../../core/foundation/errors'
import type { DownloadOptions } from '../blob-storage'
import type { MemoryStore } from './store'
export const downloadBuffer = (
store: MemoryStore,
key: string,
options: DownloadOptions = {},
): Buffer => {
const blob = store.get(key)
if (!blob) {
throw DBALError.notFound(`Blob not found: ${key}`)
}
let data = blob.data
if (options.offset !== undefined || options.length !== undefined) {
const offset = options.offset || 0
const length = options.length || (data.length - offset)
if (offset >= data.length) {
throw DBALError.validationError('Offset exceeds blob size')
}
data = data.subarray(offset, offset + length)
}
return data
}
export const downloadStream = async (
store: MemoryStore,
key: string,
options?: DownloadOptions,
) => {
const data = downloadBuffer(store, key, options)
if (typeof ReadableStream !== 'undefined') {
return new ReadableStream({
start(controller) {
controller.enqueue(data)
controller.close()
},
})
}
const { Readable } = await import('stream')
return Readable.from(data)
}

View File

@@ -0,0 +1,72 @@
import type {
BlobStorage,
BlobMetadata,
BlobListResult,
UploadOptions,
DownloadOptions,
BlobListOptions,
} from '../blob-storage'
import { createStore } from './store'
import { uploadBuffer, uploadFromStream } from './uploads'
import { downloadBuffer, downloadStream } from './downloads'
import { copyBlob, deleteBlob, getMetadata, listBlobs, getObjectCount, getTotalSize } from './management'
export class MemoryStorage implements BlobStorage {
private store = createStore()
async upload(key: string, data: Buffer | Uint8Array, options: UploadOptions = {}): Promise<BlobMetadata> {
return uploadBuffer(this.store, key, data, options)
}
async uploadStream(
key: string,
stream: ReadableStream | NodeJS.ReadableStream,
_size: number,
options: UploadOptions = {},
): Promise<BlobMetadata> {
return uploadFromStream(this.store, key, stream, options)
}
async download(key: string, options: DownloadOptions = {}): Promise<Buffer> {
return downloadBuffer(this.store, key, options)
}
async downloadStream(
key: string,
options: DownloadOptions = {},
): Promise<ReadableStream | NodeJS.ReadableStream> {
return downloadStream(this.store, key, options)
}
async delete(key: string): Promise<boolean> {
return deleteBlob(this.store, key)
}
async exists(key: string): Promise<boolean> {
return this.store.has(key)
}
async getMetadata(key: string): Promise<BlobMetadata> {
return getMetadata(this.store, key)
}
async list(options: BlobListOptions = {}): Promise<BlobListResult> {
return listBlobs(this.store, options)
}
async generatePresignedUrl(_key: string, _expirationSeconds: number = 3600): Promise<string> {
return ''
}
async copy(sourceKey: string, destKey: string): Promise<BlobMetadata> {
return copyBlob(this.store, sourceKey, destKey)
}
async getTotalSize(): Promise<number> {
return getTotalSize(this.store)
}
async getObjectCount(): Promise<number> {
return getObjectCount(this.store)
}
}

View File

@@ -0,0 +1,74 @@
import { DBALError } from '../../core/foundation/errors'
import type { BlobListOptions, BlobListResult, BlobMetadata } from '../blob-storage'
import { makeBlobMetadata } from './store'
import type { MemoryStore } from './store'
export const deleteBlob = async (store: MemoryStore, key: string): Promise<boolean> => {
if (!store.has(key)) {
throw DBALError.notFound(`Blob not found: ${key}`)
}
store.delete(key)
return true
}
export const getMetadata = (store: MemoryStore, key: string): BlobMetadata => {
const blob = store.get(key)
if (!blob) {
throw DBALError.notFound(`Blob not found: ${key}`)
}
return makeBlobMetadata(key, blob)
}
export const listBlobs = (store: MemoryStore, options: BlobListOptions = {}): BlobListResult => {
const prefix = options.prefix || ''
const maxKeys = options.maxKeys || 1000
const items: BlobMetadata[] = []
let nextToken: string | undefined
for (const [key, blob] of store.entries()) {
if (!prefix || key.startsWith(prefix)) {
if (items.length >= maxKeys) {
nextToken = key
break
}
items.push(makeBlobMetadata(key, blob))
}
}
return {
items,
nextToken,
isTruncated: nextToken !== undefined,
}
}
export const copyBlob = (store: MemoryStore, sourceKey: string, destKey: string): BlobMetadata => {
const sourceBlob = store.get(sourceKey)
if (!sourceBlob) {
throw DBALError.notFound(`Source blob not found: ${sourceKey}`)
}
const destBlob = {
...sourceBlob,
data: Buffer.from(sourceBlob.data),
lastModified: new Date(),
}
store.set(destKey, destBlob)
return makeBlobMetadata(destKey, destBlob)
}
export const getTotalSize = (store: MemoryStore): number => {
let total = 0
for (const blob of store.values()) {
total += blob.data.length
}
return total
}
export const getObjectCount = (store: MemoryStore): number => store.size

View File

@@ -0,0 +1,25 @@
import type { BlobMetadata } from '../blob-storage'
import { createHash } from 'crypto'
export interface BlobData {
data: Buffer
contentType: string
etag: string
lastModified: Date
metadata: Record<string, string>
}
export type MemoryStore = Map<string, BlobData>
export const createStore = (): MemoryStore => new Map()
export const generateEtag = (data: Buffer): string => `"${createHash('md5').update(data).digest('hex')}"`
export const makeBlobMetadata = (key: string, blob: BlobData): BlobMetadata => ({
key,
size: blob.data.length,
contentType: blob.contentType,
etag: blob.etag,
lastModified: blob.lastModified,
customMetadata: blob.metadata,
})

View File

@@ -0,0 +1,59 @@
import { DBALError } from '../../core/foundation/errors'
import type { UploadOptions } from '../blob-storage'
import type { BlobData, MemoryStore } from './store'
import { generateEtag, makeBlobMetadata } from './store'
export const uploadBuffer = (
store: MemoryStore,
key: string,
data: Buffer | Uint8Array,
options: UploadOptions = {},
) => {
const buffer = Buffer.from(data)
if (!options.overwrite && store.has(key)) {
throw DBALError.conflict(`Blob already exists: ${key}`)
}
const blob: BlobData = {
data: buffer,
contentType: options.contentType || 'application/octet-stream',
etag: generateEtag(buffer),
lastModified: new Date(),
metadata: options.metadata || {},
}
store.set(key, blob)
return makeBlobMetadata(key, blob)
}
export const collectStream = async (
stream: ReadableStream | NodeJS.ReadableStream,
): Promise<Buffer> => {
const chunks: Buffer[] = []
if ('getReader' in stream) {
const reader = stream.getReader()
while (true) {
const { done, value } = await reader.read()
if (done) break
chunks.push(Buffer.from(value))
}
} else {
for await (const chunk of stream) {
chunks.push(Buffer.from(chunk))
}
}
return Buffer.concat(chunks)
}
export const uploadFromStream = async (
store: MemoryStore,
key: string,
stream: ReadableStream | NodeJS.ReadableStream,
options?: UploadOptions,
) => {
const buffer = await collectStream(stream)
return uploadBuffer(store, key, buffer, options)
}

View File

@@ -1,361 +0,0 @@
import type {
BlobStorage,
BlobMetadata,
BlobListResult,
UploadOptions,
DownloadOptions,
BlobListOptions,
BlobStorageConfig,
} from '../blob-storage'
import { DBALError } from '../../core/foundation/errors'
/**
* S3-compatible blob storage implementation
* Uses AWS SDK v3 for S3 operations
* Compatible with MinIO and other S3-compatible services
*/
export class S3Storage implements BlobStorage {
private s3Client: any
private bucket: string
constructor(config: BlobStorageConfig) {
if (!config.s3) {
throw new Error('S3 configuration required')
}
this.bucket = config.s3.bucket
// Lazy-load AWS SDK to avoid bundling if not used
this.initializeS3Client(config.s3)
}
private async initializeS3Client(s3Config: NonNullable<BlobStorageConfig['s3']>) {
try {
// Dynamic import to avoid bundling AWS SDK if not installed
// @ts-ignore - Optional dependency
const s3Module = await import('@aws-sdk/client-s3').catch(() => null)
if (!s3Module) {
throw new Error('@aws-sdk/client-s3 is not installed. Install it with: npm install @aws-sdk/client-s3')
}
const { S3Client } = s3Module
this.s3Client = new S3Client({
region: s3Config.region,
credentials: s3Config.accessKeyId && s3Config.secretAccessKey ? {
accessKeyId: s3Config.accessKeyId,
secretAccessKey: s3Config.secretAccessKey,
} : undefined,
endpoint: s3Config.endpoint,
forcePathStyle: s3Config.forcePathStyle,
})
} catch (error) {
throw new Error('AWS SDK @aws-sdk/client-s3 not installed. Install with: npm install @aws-sdk/client-s3')
}
}
async upload(
key: string,
data: Buffer | Uint8Array,
options: UploadOptions = {}
): Promise<BlobMetadata> {
try {
const { PutObjectCommand } = await import('@aws-sdk/client-s3')
const command = new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: data,
ContentType: options.contentType,
Metadata: options.metadata,
})
const response = await this.s3Client.send(command)
return {
key,
size: data.length,
contentType: options.contentType || 'application/octet-stream',
etag: response.ETag || '',
lastModified: new Date(),
customMetadata: options.metadata,
}
} catch (error: any) {
if (error.name === 'NoSuchBucket') {
throw DBALError.notFound(`Bucket not found: ${this.bucket}`)
}
throw DBALError.internal(`S3 upload failed: ${error.message}`)
}
}
async uploadStream(
key: string,
stream: ReadableStream | NodeJS.ReadableStream,
size: number,
options: UploadOptions = {}
): Promise<BlobMetadata> {
try {
const { Upload } = await import('@aws-sdk/lib-storage')
const upload = new Upload({
client: this.s3Client,
params: {
Bucket: this.bucket,
Key: key,
Body: stream as any, // Type compatibility between Node.js and Web streams
ContentType: options.contentType,
Metadata: options.metadata,
},
})
const response = await upload.done()
return {
key,
size,
contentType: options.contentType || 'application/octet-stream',
etag: response.ETag || '',
lastModified: new Date(),
customMetadata: options.metadata,
}
} catch (error: any) {
throw DBALError.internal(`S3 stream upload failed: ${error.message}`)
}
}
async download(
key: string,
options: DownloadOptions = {}
): Promise<Buffer> {
try {
const { GetObjectCommand } = await import('@aws-sdk/client-s3')
const range = this.buildRangeHeader(options)
const command = new GetObjectCommand({
Bucket: this.bucket,
Key: key,
Range: range,
})
const response = await this.s3Client.send(command)
// Convert stream to buffer
const chunks: Uint8Array[] = []
for await (const chunk of response.Body as any) {
chunks.push(chunk)
}
return Buffer.concat(chunks)
} catch (error: any) {
if (error.name === 'NoSuchKey') {
throw DBALError.notFound(`Blob not found: ${key}`)
}
throw DBALError.internal(`S3 download failed: ${error.message}`)
}
}
async downloadStream(
key: string,
options: DownloadOptions = {}
): Promise<ReadableStream | NodeJS.ReadableStream> {
try {
const { GetObjectCommand } = await import('@aws-sdk/client-s3')
const range = this.buildRangeHeader(options)
const command = new GetObjectCommand({
Bucket: this.bucket,
Key: key,
Range: range,
})
const response = await this.s3Client.send(command)
return response.Body as any
} catch (error: any) {
if (error.name === 'NoSuchKey') {
throw DBALError.notFound(`Blob not found: ${key}`)
}
throw DBALError.internal(`S3 download stream failed: ${error.message}`)
}
}
async delete(key: string): Promise<boolean> {
try {
const { DeleteObjectCommand } = await import('@aws-sdk/client-s3')
const command = new DeleteObjectCommand({
Bucket: this.bucket,
Key: key,
})
await this.s3Client.send(command)
return true
} catch (error: any) {
throw DBALError.internal(`S3 delete failed: ${error.message}`)
}
}
async exists(key: string): Promise<boolean> {
try {
await this.getMetadata(key)
return true
} catch (error) {
if (error instanceof DBALError && error.code === 404) {
return false
}
throw error
}
}
async getMetadata(key: string): Promise<BlobMetadata> {
try {
const { HeadObjectCommand } = await import('@aws-sdk/client-s3')
const command = new HeadObjectCommand({
Bucket: this.bucket,
Key: key,
})
const response = await this.s3Client.send(command)
return {
key,
size: response.ContentLength || 0,
contentType: response.ContentType || 'application/octet-stream',
etag: response.ETag || '',
lastModified: response.LastModified || new Date(),
customMetadata: response.Metadata,
}
} catch (error: any) {
if (error.name === 'NotFound') {
throw DBALError.notFound(`Blob not found: ${key}`)
}
throw DBALError.internal(`S3 head object failed: ${error.message}`)
}
}
async list(options: BlobListOptions = {}): Promise<BlobListResult> {
try {
const { ListObjectsV2Command } = await import('@aws-sdk/client-s3')
const command = new ListObjectsV2Command({
Bucket: this.bucket,
Prefix: options.prefix,
ContinuationToken: options.continuationToken,
MaxKeys: options.maxKeys || 1000,
})
const response = await this.s3Client.send(command)
const items: BlobMetadata[] = (response.Contents || []).map(obj => ({
key: obj.Key || '',
size: obj.Size || 0,
contentType: 'application/octet-stream', // S3 list doesn't return content type
etag: obj.ETag || '',
lastModified: obj.LastModified || new Date(),
}))
return {
items,
nextToken: response.NextContinuationToken,
isTruncated: response.IsTruncated || false,
}
} catch (error: any) {
throw DBALError.internal(`S3 list failed: ${error.message}`)
}
}
async generatePresignedUrl(
key: string,
expirationSeconds: number = 3600
): Promise<string> {
try {
const { GetObjectCommand } = await import('@aws-sdk/client-s3')
const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner')
const command = new GetObjectCommand({
Bucket: this.bucket,
Key: key,
})
return await getSignedUrl(this.s3Client, command, {
expiresIn: expirationSeconds,
})
} catch (error: any) {
throw DBALError.internal(`S3 presigned URL generation failed: ${error.message}`)
}
}
async copy(
sourceKey: string,
destKey: string
): Promise<BlobMetadata> {
try {
const { CopyObjectCommand } = await import('@aws-sdk/client-s3')
const command = new CopyObjectCommand({
Bucket: this.bucket,
CopySource: `${this.bucket}/${sourceKey}`,
Key: destKey,
})
const response = await this.s3Client.send(command)
return await this.getMetadata(destKey)
} catch (error: any) {
if (error.name === 'NoSuchKey') {
throw DBALError.notFound(`Source blob not found: ${sourceKey}`)
}
throw DBALError.internal(`S3 copy failed: ${error.message}`)
}
}
async getTotalSize(): Promise<number> {
// Note: This requires listing all objects and summing sizes
// For large buckets, this can be expensive
const result = await this.list({ maxKeys: 1000 })
let total = result.items.reduce((sum, item) => sum + item.size, 0)
// Handle pagination if needed
let nextToken = result.nextToken
while (nextToken) {
const pageResult = await this.list({
maxKeys: 1000,
continuationToken: nextToken
})
total += pageResult.items.reduce((sum, item) => sum + item.size, 0)
nextToken = pageResult.nextToken
}
return total
}
async getObjectCount(): Promise<number> {
// Similar to getTotalSize, requires listing
const result = await this.list({ maxKeys: 1000 })
let count = result.items.length
let nextToken = result.nextToken
while (nextToken) {
const pageResult = await this.list({
maxKeys: 1000,
continuationToken: nextToken
})
count += pageResult.items.length
nextToken = pageResult.nextToken
}
return count
}
private buildRangeHeader(options: DownloadOptions): string | undefined {
if (options.offset === undefined && options.length === undefined) {
return undefined
}
const offset = options.offset || 0
const end = options.length !== undefined ? offset + options.length - 1 : undefined
return end !== undefined ? `bytes=${offset}-${end}` : `bytes=${offset}-`
}
}

View File

@@ -0,0 +1,39 @@
import type { BlobStorageConfig } from '../../blob-storage'
export interface S3Context {
bucket: string
s3Client: any
}
export async function createS3Context(config: BlobStorageConfig): Promise<S3Context> {
if (!config.s3) {
throw new Error('S3 configuration required')
}
const { bucket, ...s3Config } = config.s3
try {
// @ts-ignore - optional dependency
const s3Module = await import('@aws-sdk/client-s3').catch(() => null)
if (!s3Module) {
throw new Error('@aws-sdk/client-s3 is not installed. Install it with: npm install @aws-sdk/client-s3')
}
const { S3Client } = s3Module
return {
bucket,
s3Client: new S3Client({
region: s3Config.region,
credentials: s3Config.accessKeyId && s3Config.secretAccessKey ? {
accessKeyId: s3Config.accessKeyId,
secretAccessKey: s3Config.secretAccessKey,
} : undefined,
endpoint: s3Config.endpoint,
forcePathStyle: s3Config.forcePathStyle,
})
}
} catch (error) {
throw new Error('AWS SDK @aws-sdk/client-s3 not installed. Install with: npm install @aws-sdk/client-s3')
}
}

View File

@@ -0,0 +1,114 @@
import type {
BlobStorage,
BlobMetadata,
BlobListResult,
UploadOptions,
DownloadOptions,
BlobListOptions,
BlobStorageConfig,
} from '../../blob-storage'
import { DBALError } from '../../core/foundation/errors'
import type { S3Context } from './client'
import { createS3Context } from './client'
import { downloadBuffer, downloadStream } from './operations/downloads'
import { listBlobs, sumSizes, countObjects } from './operations/listing'
import { getMetadata, generatePresignedUrl } from './operations/metadata'
import { uploadBuffer, uploadStream } from './operations/uploads'
import { copyObject, deleteObject } from './operations/maintenance'
export class S3Storage implements BlobStorage {
private contextPromise: Promise<S3Context>
constructor(config: BlobStorageConfig) {
this.contextPromise = createS3Context(config)
}
private async context(): Promise<S3Context> {
return this.contextPromise
}
async upload(
key: string,
data: Buffer | Uint8Array,
options: UploadOptions = {}
): Promise<BlobMetadata> {
const context = await this.context()
return uploadBuffer(context, key, data, options)
}
async uploadStream(
key: string,
stream: ReadableStream | NodeJS.ReadableStream,
size: number,
options: UploadOptions = {}
): Promise<BlobMetadata> {
const context = await this.context()
return uploadStream(context, key, stream, size, options)
}
async download(
key: string,
options: DownloadOptions = {}
): Promise<Buffer> {
const context = await this.context()
return downloadBuffer(context, key, options)
}
async downloadStream(
key: string,
options: DownloadOptions = {}
): Promise<ReadableStream | NodeJS.ReadableStream> {
const context = await this.context()
return downloadStream(context, key, options)
}
async delete(key: string): Promise<boolean> {
const context = await this.context()
return deleteObject(context, key)
}
async exists(key: string): Promise<boolean> {
try {
await this.getMetadata(key)
return true
} catch (error) {
if (error instanceof DBALError && error.code === 404) {
return false
}
throw error
}
}
async getMetadata(key: string): Promise<BlobMetadata> {
const context = await this.context()
return getMetadata(context, key)
}
async list(options: BlobListOptions = {}): Promise<BlobListResult> {
const context = await this.context()
return listBlobs(context, options)
}
async generatePresignedUrl(
key: string,
expirationSeconds: number = 3600
): Promise<string> {
const context = await this.context()
return generatePresignedUrl(context, key, expirationSeconds)
}
async copy(sourceKey: string, destKey: string): Promise<BlobMetadata> {
const context = await this.context()
return copyObject(context, sourceKey, destKey)
}
async getTotalSize(): Promise<number> {
const context = await this.context()
return sumSizes(context)
}
async getObjectCount(): Promise<number> {
const context = await this.context()
return countObjects(context)
}
}

View File

@@ -0,0 +1,58 @@
import type { DownloadOptions } from '../../../blob-storage'
import { DBALError } from '../../../core/foundation/errors'
import { buildRangeHeader } from '../range'
import type { S3Context } from '../client'
export async function downloadBuffer(
context: S3Context,
key: string,
options: DownloadOptions
): Promise<Buffer> {
try {
const { GetObjectCommand } = await import('@aws-sdk/client-s3')
const command = new GetObjectCommand({
Bucket: context.bucket,
Key: key,
Range: buildRangeHeader(options),
})
const response = await context.s3Client.send(command)
const chunks: Uint8Array[] = []
for await (const chunk of response.Body as any) {
chunks.push(chunk)
}
return Buffer.concat(chunks)
} catch (error: any) {
if (error.name === 'NoSuchKey') {
throw DBALError.notFound(`Blob not found: ${key}`)
}
throw DBALError.internal(`S3 download failed: ${error.message}`)
}
}
export async function downloadStream(
context: S3Context,
key: string,
options: DownloadOptions
): Promise<ReadableStream | NodeJS.ReadableStream> {
try {
const { GetObjectCommand } = await import('@aws-sdk/client-s3')
const command = new GetObjectCommand({
Bucket: context.bucket,
Key: key,
Range: buildRangeHeader(options),
})
const response = await context.s3Client.send(command)
return response.Body as any
} catch (error: any) {
if (error.name === 'NoSuchKey') {
throw DBALError.notFound(`Blob not found: ${key}`)
}
throw DBALError.internal(`S3 download stream failed: ${error.message}`)
}
}

View File

@@ -0,0 +1,71 @@
import type { BlobListOptions, BlobListResult, BlobMetadata } from '../../../blob-storage'
import { DBALError } from '../../../core/foundation/errors'
import type { S3Context } from '../client'
export async function listBlobs(
context: S3Context,
options: BlobListOptions
): Promise<BlobListResult> {
try {
const { ListObjectsV2Command } = await import('@aws-sdk/client-s3')
const command = new ListObjectsV2Command({
Bucket: context.bucket,
Prefix: options.prefix,
ContinuationToken: options.continuationToken,
MaxKeys: options.maxKeys || 1000,
})
const response = await context.s3Client.send(command)
const items: BlobMetadata[] = (response.Contents || []).map(obj => ({
key: obj.Key || '',
size: obj.Size || 0,
contentType: 'application/octet-stream',
etag: obj.ETag || '',
lastModified: obj.LastModified || new Date(),
}))
return {
items,
nextToken: response.NextContinuationToken,
isTruncated: response.IsTruncated || false,
}
} catch (error: any) {
throw DBALError.internal(`S3 list failed: ${error.message}`)
}
}
export async function sumSizes(context: S3Context): Promise<number> {
const result = await listBlobs(context, { maxKeys: 1000 })
let total = result.items.reduce((sum, item) => sum + item.size, 0)
let nextToken = result.nextToken
while (nextToken) {
const pageResult = await listBlobs(context, {
maxKeys: 1000,
continuationToken: nextToken
})
total += pageResult.items.reduce((sum, item) => sum + item.size, 0)
nextToken = pageResult.nextToken
}
return total
}
export async function countObjects(context: S3Context): Promise<number> {
const result = await listBlobs(context, { maxKeys: 1000 })
let count = result.items.length
let nextToken = result.nextToken
while (nextToken) {
const pageResult = await listBlobs(context, {
maxKeys: 1000,
continuationToken: nextToken
})
count += pageResult.items.length
nextToken = pageResult.nextToken
}
return count
}

View File

@@ -0,0 +1,48 @@
import type { BlobMetadata } from '../../../blob-storage'
import { DBALError } from '../../../core/foundation/errors'
import type { S3Context } from '../client'
import { getMetadata } from './metadata'
export async function deleteObject(
context: S3Context,
key: string
): Promise<boolean> {
try {
const { DeleteObjectCommand } = await import('@aws-sdk/client-s3')
const command = new DeleteObjectCommand({
Bucket: context.bucket,
Key: key,
})
await context.s3Client.send(command)
return true
} catch (error: any) {
throw DBALError.internal(`S3 delete failed: ${error.message}`)
}
}
export async function copyObject(
context: S3Context,
sourceKey: string,
destKey: string
): Promise<BlobMetadata> {
try {
const { CopyObjectCommand } = await import('@aws-sdk/client-s3')
const command = new CopyObjectCommand({
Bucket: context.bucket,
CopySource: `${context.bucket}/${sourceKey}`,
Key: destKey,
})
await context.s3Client.send(command)
return await getMetadata(context, destKey)
} catch (error: any) {
if (error.name === 'NoSuchKey') {
throw DBALError.notFound(`Source blob not found: ${sourceKey}`)
}
throw DBALError.internal(`S3 copy failed: ${error.message}`)
}
}

View File

@@ -0,0 +1,55 @@
import type { BlobMetadata } from '../../../blob-storage'
import { DBALError } from '../../../core/foundation/errors'
import type { S3Context } from '../client'
export async function getMetadata(
context: S3Context,
key: string
): Promise<BlobMetadata> {
try {
const { HeadObjectCommand } = await import('@aws-sdk/client-s3')
const command = new HeadObjectCommand({
Bucket: context.bucket,
Key: key,
})
const response = await context.s3Client.send(command)
return {
key,
size: response.ContentLength || 0,
contentType: response.ContentType || 'application/octet-stream',
etag: response.ETag || '',
lastModified: response.LastModified || new Date(),
customMetadata: response.Metadata,
}
} catch (error: any) {
if (error.name === 'NotFound') {
throw DBALError.notFound(`Blob not found: ${key}`)
}
throw DBALError.internal(`S3 head object failed: ${error.message}`)
}
}
export async function generatePresignedUrl(
context: S3Context,
key: string,
expirationSeconds: number
): Promise<string> {
try {
const { GetObjectCommand } = await import('@aws-sdk/client-s3')
const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner')
const command = new GetObjectCommand({
Bucket: context.bucket,
Key: key,
})
return await getSignedUrl(context.s3Client, command, {
expiresIn: expirationSeconds,
})
} catch (error: any) {
throw DBALError.internal(`S3 presigned URL generation failed: ${error.message}`)
}
}

View File

@@ -0,0 +1,74 @@
import type { BlobMetadata, UploadOptions } from '../../../blob-storage'
import { DBALError } from '../../../core/foundation/errors'
import type { S3Context } from '../client'
export async function uploadBuffer(
context: S3Context,
key: string,
data: Buffer | Uint8Array,
options: UploadOptions
): Promise<BlobMetadata> {
try {
const { PutObjectCommand } = await import('@aws-sdk/client-s3')
const command = new PutObjectCommand({
Bucket: context.bucket,
Key: key,
Body: data,
ContentType: options.contentType,
Metadata: options.metadata,
})
const response = await context.s3Client.send(command)
return {
key,
size: data.length,
contentType: options.contentType || 'application/octet-stream',
etag: response.ETag || '',
lastModified: new Date(),
customMetadata: options.metadata,
}
} catch (error: any) {
if (error.name === 'NoSuchBucket') {
throw DBALError.notFound(`Bucket not found: ${context.bucket}`)
}
throw DBALError.internal(`S3 upload failed: ${error.message}`)
}
}
export async function uploadStream(
context: S3Context,
key: string,
stream: ReadableStream | NodeJS.ReadableStream,
size: number,
options: UploadOptions
): Promise<BlobMetadata> {
try {
const { Upload } = await import('@aws-sdk/lib-storage')
const upload = new Upload({
client: context.s3Client,
params: {
Bucket: context.bucket,
Key: key,
Body: stream as any,
ContentType: options.contentType,
Metadata: options.metadata,
},
})
const response = await upload.done()
return {
key,
size,
contentType: options.contentType || 'application/octet-stream',
etag: response.ETag || '',
lastModified: new Date(),
customMetadata: options.metadata,
}
} catch (error: any) {
throw DBALError.internal(`S3 stream upload failed: ${error.message}`)
}
}

View File

@@ -0,0 +1,12 @@
import type { DownloadOptions } from '../../blob-storage'
export function buildRangeHeader(options: DownloadOptions): string | undefined {
if (options.offset === undefined && options.length === undefined) {
return undefined
}
const offset = options.offset || 0
const end = options.length !== undefined ? offset + options.length - 1 : undefined
return end !== undefined ? `bytes=${offset}-${end}` : `bytes=${offset}-`
}

View File

@@ -1,260 +1 @@
/**
* Tenant-Aware Blob Storage
*
* Wraps BlobStorage with multi-tenant support including:
* - Namespace isolation
* - Access control
* - Quota management
* - Virtual root directories
*/
import { BlobStorage, BlobMetadata, UploadOptions, DownloadOptions, BlobListOptions, BlobListResult } from '../blob-storage'
import { TenantContext, TenantManager } from '../core/tenant-context'
import { DBALError } from '../../core/foundation/errors'
import { Readable } from 'stream'
export class TenantAwareBlobStorage implements BlobStorage {
constructor(
private readonly baseStorage: BlobStorage,
private readonly tenantManager: TenantManager,
private readonly tenantId: string,
private readonly userId: string
) {}
private async getContext(): Promise<TenantContext> {
return this.tenantManager.getTenantContext(this.tenantId, this.userId)
}
private getScopedKey(key: string, namespace: string): string {
// Remove leading slash if present
const cleanKey = key.startsWith('/') ? key.substring(1) : key
return `${namespace}${cleanKey}`
}
private unscopeKey(scopedKey: string, namespace: string): string {
if (scopedKey.startsWith(namespace)) {
return scopedKey.substring(namespace.length)
}
return scopedKey
}
async upload(key: string, data: Buffer, options?: UploadOptions): Promise<BlobMetadata> {
const context = await this.getContext()
// Check permissions
if (!context.canWrite('blob')) {
throw DBALError.forbidden('Permission denied: cannot upload blobs')
}
// Check quota
const size = data.length
if (!context.canUploadBlob(size)) {
throw DBALError.rateLimitExceeded()
}
const scopedKey = this.getScopedKey(key, context.namespace)
const metadata = await this.baseStorage.upload(scopedKey, data, options)
// Update quota
await this.tenantManager.updateBlobUsage(this.tenantId, size, 1)
// Return metadata with unscoped key
return {
...metadata,
key
}
}
async uploadStream(key: string, stream: Readable, size: number, options?: UploadOptions): Promise<BlobMetadata> {
const context = await this.getContext()
// Check permissions
if (!context.canWrite('blob')) {
throw DBALError.forbidden('Permission denied: cannot upload blobs')
}
// Check quota
if (!context.canUploadBlob(size)) {
throw DBALError.rateLimitExceeded()
}
const scopedKey = this.getScopedKey(key, context.namespace)
const metadata = await this.baseStorage.uploadStream(scopedKey, stream, size, options)
// Update quota
await this.tenantManager.updateBlobUsage(this.tenantId, size, 1)
// Return metadata with unscoped key
return {
...metadata,
key
}
}
async download(key: string): Promise<Buffer> {
const context = await this.getContext()
// Check permissions
if (!context.canRead('blob')) {
throw DBALError.forbidden('Permission denied: cannot download blobs')
}
const scopedKey = this.getScopedKey(key, context.namespace)
return this.baseStorage.download(scopedKey)
}
async downloadStream(key: string, options?: DownloadOptions): Promise<ReadableStream | NodeJS.ReadableStream> {
const context = await this.getContext()
// Check permissions
if (!context.canRead('blob')) {
throw DBALError.forbidden('Permission denied: cannot download blobs')
}
const scopedKey = this.getScopedKey(key, context.namespace)
return this.baseStorage.downloadStream(scopedKey, options)
}
async delete(key: string): Promise<boolean> {
const context = await this.getContext()
// Check permissions
if (!context.canDelete('blob')) {
throw DBALError.forbidden('Permission denied: cannot delete blobs')
}
const scopedKey = this.getScopedKey(key, context.namespace)
// Get metadata before deletion to update quota
try {
const metadata = await this.baseStorage.getMetadata(scopedKey)
const deleted = await this.baseStorage.delete(scopedKey)
if (deleted) {
// Update quota
await this.tenantManager.updateBlobUsage(this.tenantId, -metadata.size, -1)
}
return deleted
} catch (error) {
// If metadata fetch fails, try delete anyway
return this.baseStorage.delete(scopedKey)
}
}
async exists(key: string): Promise<boolean> {
const context = await this.getContext()
// Check permissions
if (!context.canRead('blob')) {
throw DBALError.forbidden('Permission denied: cannot check blob existence')
}
const scopedKey = this.getScopedKey(key, context.namespace)
return this.baseStorage.exists(scopedKey)
}
async copy(sourceKey: string, destKey: string): Promise<BlobMetadata> {
const context = await this.getContext()
// Check permissions
if (!context.canRead('blob') || !context.canWrite('blob')) {
throw DBALError.forbidden('Permission denied: cannot copy blobs')
}
// Get source metadata to check quota
const sourceScoped = this.getScopedKey(sourceKey, context.namespace)
const sourceMetadata = await this.baseStorage.getMetadata(sourceScoped)
// Check quota for destination
if (!context.canUploadBlob(sourceMetadata.size)) {
throw DBALError.rateLimitExceeded()
}
const destScoped = this.getScopedKey(destKey, context.namespace)
const metadata = await this.baseStorage.copy(sourceScoped, destScoped)
// Update quota
await this.tenantManager.updateBlobUsage(this.tenantId, sourceMetadata.size, 1)
return {
...metadata,
key: destKey
}
}
async list(options?: BlobListOptions): Promise<BlobListResult> {
const context = await this.getContext()
// Check permissions
if (!context.canRead('blob')) {
throw DBALError.forbidden('Permission denied: cannot list blobs')
}
// Add namespace prefix to options
const scopedOptions: BlobListOptions = {
...options,
prefix: options?.prefix
? this.getScopedKey(options.prefix, context.namespace)
: context.namespace
}
const result = await this.baseStorage.list(scopedOptions)
// Unscope keys in results
return {
...result,
items: result.items.map(item => ({
...item,
key: this.unscopeKey(item.key, context.namespace)
}))
}
}
async getMetadata(key: string): Promise<BlobMetadata> {
const context = await this.getContext()
// Check permissions
if (!context.canRead('blob')) {
throw DBALError.forbidden('Permission denied: cannot get blob metadata')
}
const scopedKey = this.getScopedKey(key, context.namespace)
const metadata = await this.baseStorage.getMetadata(scopedKey)
return {
...metadata,
key
}
}
async getStats(): Promise<{ count: number; totalSize: number }> {
const context = await this.getContext()
// Return tenant's current usage from quota
return {
count: context.quota.currentBlobCount,
totalSize: context.quota.currentBlobStorageBytes
}
}
async generatePresignedUrl(key: string, expiresIn: number): Promise<string> {
const context = await this.getContext()
// Check permissions
if (!context.canRead('blob')) {
throw DBALError.forbidden('Permission denied: cannot generate presigned URL')
}
const scopedKey = this.getScopedKey(key, context.namespace)
return this.baseStorage.generatePresignedUrl(scopedKey, expiresIn)
}
async getTotalSize(): Promise<number> {
return this.baseStorage.getTotalSize()
}
async getObjectCount(): Promise<number> {
return this.baseStorage.getObjectCount()
}
}
export { TenantAwareBlobStorage } from './tenant-aware-storage/index'

View File

@@ -0,0 +1,40 @@
import { DBALError } from '../../core/foundation/errors'
import type { TenantContext, TenantManager } from '../../core/foundation/tenant-context'
import type { BlobStorage } from '../blob-storage'
export interface TenantAwareDeps {
baseStorage: BlobStorage
tenantManager: TenantManager
tenantId: string
userId: string
}
export const getContext = async ({ tenantManager, tenantId, userId }: TenantAwareDeps): Promise<TenantContext> => {
return tenantManager.getTenantContext(tenantId, userId)
}
export const scopeKey = (key: string, namespace: string): string => {
const cleanKey = key.startsWith('/') ? key.substring(1) : key
return `${namespace}${cleanKey}`
}
export const unscopeKey = (scopedKey: string, namespace: string): string => {
if (scopedKey.startsWith(namespace)) {
return scopedKey.substring(namespace.length)
}
return scopedKey
}
export const ensurePermission = (context: TenantContext, action: 'read' | 'write' | 'delete'): void => {
const accessCheck =
action === 'read' ? context.canRead('blob') : action === 'write' ? context.canWrite('blob') : context.canDelete('blob')
if (!accessCheck) {
const verbs: Record<typeof action, string> = {
read: 'read',
write: 'write',
delete: 'delete',
}
throw DBALError.forbidden(`Permission denied: cannot ${verbs[action]} blobs`)
}
}

View File

@@ -0,0 +1,66 @@
import type { BlobListOptions, BlobListResult, BlobMetadata, BlobStorage, DownloadOptions, UploadOptions } from '../blob-storage'
import type { TenantManager } from '../../core/foundation/tenant-context'
import type { TenantAwareDeps } from './context'
import { deleteBlob, exists, copyBlob, getStats } from './mutations'
import { downloadBuffer, downloadStream, generatePresignedUrl, getMetadata, listBlobs } from './reads'
import { uploadBuffer, uploadStream } from './uploads'
export class TenantAwareBlobStorage implements BlobStorage {
private readonly deps: TenantAwareDeps
constructor(baseStorage: BlobStorage, tenantManager: TenantManager, tenantId: string, userId: string) {
this.deps = { baseStorage, tenantManager, tenantId, userId }
}
async upload(key: string, data: Buffer, options?: UploadOptions): Promise<BlobMetadata> {
return uploadBuffer(this.deps, key, data, options)
}
async uploadStream(key: string, stream: NodeJS.ReadableStream, size: number, options?: UploadOptions): Promise<BlobMetadata> {
return uploadStream(this.deps, key, stream, size, options)
}
async download(key: string): Promise<Buffer> {
return downloadBuffer(this.deps, key)
}
async downloadStream(key: string, options?: DownloadOptions): Promise<ReadableStream | NodeJS.ReadableStream> {
return downloadStream(this.deps, key, options)
}
async delete(key: string): Promise<boolean> {
return deleteBlob(this.deps, key)
}
async exists(key: string): Promise<boolean> {
return exists(this.deps, key)
}
async copy(sourceKey: string, destKey: string): Promise<BlobMetadata> {
return copyBlob(this.deps, sourceKey, destKey)
}
async list(options?: BlobListOptions): Promise<BlobListResult> {
return listBlobs(this.deps, options)
}
async getMetadata(key: string): Promise<BlobMetadata> {
return getMetadata(this.deps, key)
}
async getStats(): Promise<{ count: number; totalSize: number }> {
return getStats(this.deps)
}
async generatePresignedUrl(key: string, expiresIn: number): Promise<string> {
return generatePresignedUrl(this.deps, key, expiresIn)
}
async getTotalSize(): Promise<number> {
return this.deps.baseStorage.getTotalSize()
}
async getObjectCount(): Promise<number> {
return this.deps.baseStorage.getObjectCount()
}
}

View File

@@ -0,0 +1,67 @@
import { DBALError } from '../../core/foundation/errors'
import type { BlobMetadata } from '../blob-storage'
import { ensurePermission, getContext, scopeKey } from './context'
import type { TenantAwareDeps } from './context'
export const deleteBlob = async (deps: TenantAwareDeps, key: string): Promise<boolean> => {
const context = await getContext(deps)
ensurePermission(context, 'delete')
const scopedKey = scopeKey(key, context.namespace)
try {
const metadata = await deps.baseStorage.getMetadata(scopedKey)
const deleted = await deps.baseStorage.delete(scopedKey)
if (deleted) {
await deps.tenantManager.updateBlobUsage(deps.tenantId, -metadata.size, -1)
}
return deleted
} catch {
return deps.baseStorage.delete(scopedKey)
}
}
export const exists = async (deps: TenantAwareDeps, key: string): Promise<boolean> => {
const context = await getContext(deps)
ensurePermission(context, 'read')
const scopedKey = scopeKey(key, context.namespace)
return deps.baseStorage.exists(scopedKey)
}
export const copyBlob = async (
deps: TenantAwareDeps,
sourceKey: string,
destKey: string,
): Promise<BlobMetadata> => {
const context = await getContext(deps)
ensurePermission(context, 'read')
ensurePermission(context, 'write')
const sourceScoped = scopeKey(sourceKey, context.namespace)
const sourceMetadata = await deps.baseStorage.getMetadata(sourceScoped)
if (!context.canUploadBlob(sourceMetadata.size)) {
throw DBALError.rateLimitExceeded()
}
const destScoped = scopeKey(destKey, context.namespace)
const metadata = await deps.baseStorage.copy(sourceScoped, destScoped)
await deps.tenantManager.updateBlobUsage(deps.tenantId, sourceMetadata.size, 1)
return {
...metadata,
key: destKey,
}
}
export const getStats = async (deps: TenantAwareDeps) => {
const context = await getContext(deps)
return {
count: context.quota.currentBlobCount,
totalSize: context.quota.currentBlobStorageBytes,
}
}

View File

@@ -0,0 +1,71 @@
import type { DownloadOptions, BlobMetadata, BlobListOptions, BlobListResult } from '../blob-storage'
import { ensurePermission, getContext, scopeKey, unscopeKey } from './context'
import type { TenantAwareDeps } from './context'
export const downloadBuffer = async (deps: TenantAwareDeps, key: string): Promise<Buffer> => {
const context = await getContext(deps)
ensurePermission(context, 'read')
const scopedKey = scopeKey(key, context.namespace)
return deps.baseStorage.download(scopedKey)
}
export const downloadStream = async (
deps: TenantAwareDeps,
key: string,
options?: DownloadOptions,
): Promise<ReadableStream | NodeJS.ReadableStream> => {
const context = await getContext(deps)
ensurePermission(context, 'read')
const scopedKey = scopeKey(key, context.namespace)
return deps.baseStorage.downloadStream(scopedKey, options)
}
export const listBlobs = async (
deps: TenantAwareDeps,
options: BlobListOptions = {},
): Promise<BlobListResult> => {
const context = await getContext(deps)
ensurePermission(context, 'read')
const scopedOptions: BlobListOptions = {
...options,
prefix: options.prefix ? scopeKey(options.prefix, context.namespace) : context.namespace,
}
const result = await deps.baseStorage.list(scopedOptions)
return {
...result,
items: result.items.map(item => ({
...item,
key: unscopeKey(item.key, context.namespace),
})),
}
}
export const getMetadata = async (deps: TenantAwareDeps, key: string): Promise<BlobMetadata> => {
const context = await getContext(deps)
ensurePermission(context, 'read')
const scopedKey = scopeKey(key, context.namespace)
const metadata = await deps.baseStorage.getMetadata(scopedKey)
return {
...metadata,
key,
}
}
export const generatePresignedUrl = async (
deps: TenantAwareDeps,
key: string,
expiresIn: number,
): Promise<string> => {
const context = await getContext(deps)
ensurePermission(context, 'read')
const scopedKey = scopeKey(key, context.namespace)
return deps.baseStorage.generatePresignedUrl(scopedKey, expiresIn)
}

View File

@@ -0,0 +1,51 @@
import { DBALError } from '../../core/foundation/errors'
import type { UploadOptions, BlobMetadata } from '../blob-storage'
import type { TenantAwareDeps } from './context'
import { ensurePermission, getContext, scopeKey } from './context'
export const uploadBuffer = async (
deps: TenantAwareDeps,
key: string,
data: Buffer,
options?: UploadOptions,
): Promise<BlobMetadata> => {
const context = await getContext(deps)
ensurePermission(context, 'write')
if (!context.canUploadBlob(data.length)) {
throw DBALError.rateLimitExceeded()
}
const scopedKey = scopeKey(key, context.namespace)
const metadata = await deps.baseStorage.upload(scopedKey, data, options)
await deps.tenantManager.updateBlobUsage(deps.tenantId, data.length, 1)
return {
...metadata,
key,
}
}
export const uploadStream = async (
deps: TenantAwareDeps,
key: string,
stream: NodeJS.ReadableStream,
size: number,
options?: UploadOptions,
): Promise<BlobMetadata> => {
const context = await getContext(deps)
ensurePermission(context, 'write')
if (!context.canUploadBlob(size)) {
throw DBALError.rateLimitExceeded()
}
const scopedKey = scopeKey(key, context.namespace)
const metadata = await deps.baseStorage.uploadStream(scopedKey, stream, size, options)
await deps.tenantManager.updateBlobUsage(deps.tenantId, size, 1)
return {
...metadata,
key,
}
}

View File

@@ -1,168 +1 @@
/**
* @file websocket-bridge.ts
* @description WebSocket bridge adapter for remote DBAL daemon
*/
import type { DBALAdapter, AdapterCapabilities } from '../adapters/adapter'
import type { ListOptions, ListResult } from '../core/types'
import { DBALError } from '../core/foundation/errors'
import { generateRequestId } from './utils/generate-request-id'
import type { RPCMessage, RPCResponse, PendingRequest } from './utils/rpc-types'
export class WebSocketBridge implements DBALAdapter {
private ws: WebSocket | null = null
private endpoint: string
private auth?: { user: unknown, session: unknown }
private pendingRequests = new Map<string, PendingRequest>()
constructor(endpoint: string, auth?: { user: unknown, session: unknown }) {
this.endpoint = endpoint
this.auth = auth
}
private async connect(): Promise<void> {
if (this.ws?.readyState === WebSocket.OPEN) {
return
}
return new Promise((resolve, reject) => {
this.ws = new WebSocket(this.endpoint)
this.ws.onopen = () => {
resolve()
}
this.ws.onerror = (error) => {
reject(DBALError.internal(`WebSocket connection failed: ${error}`))
}
this.ws.onmessage = (event) => {
this.handleMessage(event.data)
}
this.ws.onclose = () => {
this.ws = null
}
})
}
private handleMessage(data: string): void {
try {
const response: RPCResponse = JSON.parse(data)
const pending = this.pendingRequests.get(response.id)
if (!pending) {
return
}
this.pendingRequests.delete(response.id)
if (response.error) {
const error = new DBALError(
response.error.message,
response.error.code,
response.error.details
)
pending.reject(error)
} else {
pending.resolve(response.result)
}
} catch (error) {
console.error('Failed to parse WebSocket message:', error)
}
}
private async call(method: string, ...params: unknown[]): Promise<unknown> {
await this.connect()
const id = generateRequestId()
const message: RPCMessage = { id, method, params }
return new Promise((resolve, reject) => {
this.pendingRequests.set(id, { resolve, reject })
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message))
} else {
this.pendingRequests.delete(id)
reject(DBALError.internal('WebSocket connection not open'))
}
setTimeout(() => {
if (this.pendingRequests.has(id)) {
this.pendingRequests.delete(id)
reject(DBALError.timeout('Request timed out'))
}
}, 30000)
})
}
async create(entity: string, data: Record<string, unknown>): Promise<unknown> {
return this.call('create', entity, data)
}
async read(entity: string, id: string): Promise<unknown | null> {
return this.call('read', entity, id)
}
async update(entity: string, id: string, data: Record<string, unknown>): Promise<unknown> {
return this.call('update', entity, id, data)
}
async delete(entity: string, id: string): Promise<boolean> {
return this.call('delete', entity, id) as Promise<boolean>
}
async list(entity: string, options?: ListOptions): Promise<ListResult<unknown>> {
return this.call('list', entity, options) as Promise<ListResult<unknown>>
}
async findFirst(entity: string, filter?: Record<string, unknown>): Promise<unknown | null> {
return this.call('findFirst', entity, filter)
}
async findByField(entity: string, field: string, value: unknown): Promise<unknown | null> {
return this.call('findByField', entity, field, value)
}
async upsert(
entity: string,
filter: Record<string, unknown>,
createData: Record<string, unknown>,
updateData: Record<string, unknown>
): Promise<unknown> {
return this.call('upsert', entity, filter, createData, updateData)
}
async updateByField(entity: string, field: string, value: unknown, data: Record<string, unknown>): Promise<unknown> {
return this.call('updateByField', entity, field, value, data)
}
async deleteByField(entity: string, field: string, value: unknown): Promise<boolean> {
return this.call('deleteByField', entity, field, value) as Promise<boolean>
}
async deleteMany(entity: string, filter?: Record<string, unknown>): Promise<number> {
return this.call('deleteMany', entity, filter) as Promise<number>
}
async createMany(entity: string, data: Record<string, unknown>[]): Promise<number> {
return this.call('createMany', entity, data) as Promise<number>
}
async updateMany(entity: string, filter: Record<string, unknown>, data: Record<string, unknown>): Promise<number> {
return this.call('updateMany', entity, filter, data) as Promise<number>
}
async getCapabilities(): Promise<AdapterCapabilities> {
return this.call('getCapabilities') as Promise<AdapterCapabilities>
}
async close(): Promise<void> {
if (this.ws) {
this.ws.close()
this.ws = null
}
this.pendingRequests.clear()
}
}
export { WebSocketBridge } from './websocket-bridge/index'

View File

@@ -0,0 +1,28 @@
import { DBALError } from '../../core/foundation/errors'
import { handleMessage } from './message-handler'
import type { BridgeState } from './state'
export const connect = async (state: BridgeState): Promise<void> => {
if (state.ws?.readyState === WebSocket.OPEN) {
return
}
return new Promise((resolve, reject) => {
state.ws = new WebSocket(state.endpoint)
state.ws.onopen = () => resolve()
state.ws.onerror = error => reject(DBALError.internal(`WebSocket connection failed: ${error}`))
state.ws.onmessage = event => handleMessage(state, event.data)
state.ws.onclose = () => {
state.ws = null
}
})
}
export const closeConnection = async (state: BridgeState): Promise<void> => {
if (state.ws) {
state.ws.close()
state.ws = null
}
state.pendingRequests.clear()
}

View File

@@ -0,0 +1,80 @@
import type { DBALAdapter, AdapterCapabilities } from '../../adapters/adapter'
import type { ListOptions, ListResult } from '../../core/types'
import { closeConnection } from './connection'
import { createOperations } from './operations'
import { createBridgeState } from './state'
export class WebSocketBridge implements DBALAdapter {
private readonly state: ReturnType<typeof createBridgeState>
private readonly operations: ReturnType<typeof createOperations>
constructor(endpoint: string, auth?: { user: unknown; session: unknown }) {
this.state = createBridgeState(endpoint, auth)
this.operations = createOperations(this.state)
}
create(entity: string, data: Record<string, unknown>): Promise<unknown> {
return this.operations.create(entity, data)
}
read(entity: string, id: string): Promise<unknown | null> {
return this.operations.read(entity, id) as Promise<unknown | null>
}
update(entity: string, id: string, data: Record<string, unknown>): Promise<unknown> {
return this.operations.update(entity, id, data)
}
delete(entity: string, id: string): Promise<boolean> {
return this.operations.delete(entity, id)
}
list(entity: string, options?: ListOptions): Promise<ListResult<unknown>> {
return this.operations.list(entity, options)
}
findFirst(entity: string, filter?: Record<string, unknown>): Promise<unknown | null> {
return this.operations.findFirst(entity, filter) as Promise<unknown | null>
}
findByField(entity: string, field: string, value: unknown): Promise<unknown | null> {
return this.operations.findByField(entity, field, value) as Promise<unknown | null>
}
upsert(
entity: string,
filter: Record<string, unknown>,
createData: Record<string, unknown>,
updateData: Record<string, unknown>,
): Promise<unknown> {
return this.operations.upsert(entity, filter, createData, updateData)
}
updateByField(entity: string, field: string, value: unknown, data: Record<string, unknown>): Promise<unknown> {
return this.operations.updateByField(entity, field, value, data)
}
deleteByField(entity: string, field: string, value: unknown): Promise<boolean> {
return this.operations.deleteByField(entity, field, value)
}
deleteMany(entity: string, filter?: Record<string, unknown>): Promise<number> {
return this.operations.deleteMany(entity, filter)
}
createMany(entity: string, data: Record<string, unknown>[]): Promise<number> {
return this.operations.createMany(entity, data)
}
updateMany(entity: string, filter: Record<string, unknown>, data: Record<string, unknown>): Promise<number> {
return this.operations.updateMany(entity, filter, data)
}
getCapabilities(): Promise<AdapterCapabilities> {
return this.operations.getCapabilities()
}
async close(): Promise<void> {
await closeConnection(this.state)
}
}

View File

@@ -0,0 +1,25 @@
import type { RPCResponse } from '../utils/rpc-types'
import type { BridgeState } from './state'
import { DBALError } from '../../core/foundation/errors'
export const handleMessage = (state: BridgeState, data: string): void => {
try {
const response: RPCResponse = JSON.parse(data)
const pending = state.pendingRequests.get(response.id)
if (!pending) {
return
}
state.pendingRequests.delete(response.id)
if (response.error) {
const error = new DBALError(response.error.message, response.error.code, response.error.details)
pending.reject(error)
} else {
pending.resolve(response.result)
}
} catch (error) {
console.error('Failed to parse WebSocket message:', error)
}
}

View File

@@ -0,0 +1,31 @@
import type { AdapterCapabilities } from '../../adapters/adapter'
import type { ListOptions, ListResult } from '../../core/types'
import type { BridgeState } from './state'
import { rpcCall } from './rpc'
export const createOperations = (state: BridgeState) => ({
create: (entity: string, data: Record<string, unknown>) => rpcCall(state, 'create', entity, data),
read: (entity: string, id: string) => rpcCall(state, 'read', entity, id),
update: (entity: string, id: string, data: Record<string, unknown>) => rpcCall(state, 'update', entity, id, data),
delete: (entity: string, id: string) => rpcCall(state, 'delete', entity, id) as Promise<boolean>,
list: (entity: string, options?: ListOptions) => rpcCall(state, 'list', entity, options) as Promise<ListResult<unknown>>,
findFirst: (entity: string, filter?: Record<string, unknown>) => rpcCall(state, 'findFirst', entity, filter),
findByField: (entity: string, field: string, value: unknown) => rpcCall(state, 'findByField', entity, field, value),
upsert: (
entity: string,
filter: Record<string, unknown>,
createData: Record<string, unknown>,
updateData: Record<string, unknown>,
) => rpcCall(state, 'upsert', entity, filter, createData, updateData),
updateByField: (entity: string, field: string, value: unknown, data: Record<string, unknown>) =>
rpcCall(state, 'updateByField', entity, field, value, data),
deleteByField: (entity: string, field: string, value: unknown) =>
rpcCall(state, 'deleteByField', entity, field, value) as Promise<boolean>,
deleteMany: (entity: string, filter?: Record<string, unknown>) =>
rpcCall(state, 'deleteMany', entity, filter) as Promise<number>,
createMany: (entity: string, data: Record<string, unknown>[]) =>
rpcCall(state, 'createMany', entity, data) as Promise<number>,
updateMany: (entity: string, filter: Record<string, unknown>, data: Record<string, unknown>) =>
rpcCall(state, 'updateMany', entity, filter, data) as Promise<number>,
getCapabilities: () => rpcCall(state, 'getCapabilities') as Promise<AdapterCapabilities>,
})

View File

@@ -0,0 +1,31 @@
import { DBALError } from '../../core/foundation/errors'
import { generateRequestId } from '../utils/generate-request-id'
import type { RPCMessage } from '../utils/rpc-types'
import { connect } from './connection'
import type { BridgeState } from './state'
export const rpcCall = async (state: BridgeState, method: string, ...params: unknown[]): Promise<unknown> => {
await connect(state)
const id = generateRequestId()
const message: RPCMessage = { id, method, params }
return new Promise((resolve, reject) => {
state.pendingRequests.set(id, { resolve, reject })
if (state.ws?.readyState === WebSocket.OPEN) {
state.ws.send(JSON.stringify(message))
} else {
state.pendingRequests.delete(id)
reject(DBALError.internal('WebSocket connection not open'))
return
}
setTimeout(() => {
if (state.pendingRequests.has(id)) {
state.pendingRequests.delete(id)
reject(DBALError.timeout('Request timed out'))
}
}, 30000)
})
}

View File

@@ -0,0 +1,18 @@
import type { PendingRequest } from '../utils/rpc-types'
export interface BridgeState {
ws: WebSocket | null
endpoint: string
auth?: { user: unknown; session: unknown }
pendingRequests: Map<string, PendingRequest>
}
export const createBridgeState = (
endpoint: string,
auth?: { user: unknown; session: unknown },
): BridgeState => ({
ws: null,
endpoint,
auth,
pendingRequests: new Map<string, PendingRequest>(),
})

View File

@@ -6,7 +6,7 @@
import type { DBALConfig } from '../../runtime/config'
import type { DBALAdapter } from '../../adapters/adapter'
import { DBALError } from '../foundation/errors'
import { PrismaAdapter, PostgresAdapter, MySQLAdapter } from '../../adapters/prisma-adapter'
import { PrismaAdapter, PostgresAdapter, MySQLAdapter } from '../../adapters/prisma'
import { ACLAdapter } from '../../adapters/acl-adapter'
import { WebSocketBridge } from '../../bridges/websocket-bridge'

View File

@@ -12,13 +12,13 @@ export * as luaScript from './lua-script';
export * as pkg from './package';
// Legacy factory exports (for backward compatibility)
export { createUserOperations } from './user-operations';
export { createPageOperations } from './page-operations';
export { createComponentOperations } from './component-operations';
export { createWorkflowOperations } from './workflow-operations';
export { createLuaScriptOperations } from './lua-script-operations';
export { createPackageOperations } from './package-operations';
export { createSessionOperations } from './session-operations';
export { createUserOperations } from './operations/core/user-operations';
export { createPageOperations } from './operations/system/page-operations';
export { createComponentOperations } from './operations/system/component-operations';
export { createWorkflowOperations } from './operations/core/workflow-operations';
export { createLuaScriptOperations } from './operations/core/lua-script-operations';
export { createPackageOperations } from './operations/system/package-operations';
export { createSessionOperations } from './operations/core/session-operations';
// Validation utilities
export * from '../validation';

View File

@@ -1,185 +1,2 @@
/**
* @file user-operations.ts
* @description User entity CRUD operations for DBAL client
*
* Single-responsibility module following the small-function-file pattern.
*/
import type { DBALAdapter } from '../../adapters/adapter'
import type { User, ListOptions, ListResult } from '../types'
import { DBALError } from '../errors'
import { validateUserCreate, validateUserUpdate, validateId } from '../validation'
/**
* Create user operations object for the DBAL client
*/
export const createUserOperations = (adapter: DBALAdapter) => ({
/**
* Create a new user
*/
create: async (data: Omit<User, 'id' | 'createdAt' | 'updatedAt'>): Promise<User> => {
const validationErrors = validateUserCreate(data)
if (validationErrors.length > 0) {
throw DBALError.validationError(
'Invalid user data',
validationErrors.map(error => ({ field: 'user', error }))
)
}
try {
return adapter.create('User', data) as Promise<User>
} catch (error) {
if (error instanceof DBALError && error.code === 409) {
throw DBALError.conflict(`User with username or email already exists`)
}
throw error
}
},
/**
* Read a user by ID
*/
read: async (id: string): Promise<User | null> => {
const validationErrors = validateId(id)
if (validationErrors.length > 0) {
throw DBALError.validationError(
'Invalid user ID',
validationErrors.map(error => ({ field: 'id', error }))
)
}
const result = await adapter.read('User', id) as User | null
if (!result) {
throw DBALError.notFound(`User not found: ${id}`)
}
return result
},
/**
* Update an existing user
*/
update: async (id: string, data: Partial<User>): Promise<User> => {
const idErrors = validateId(id)
if (idErrors.length > 0) {
throw DBALError.validationError(
'Invalid user ID',
idErrors.map(error => ({ field: 'id', error }))
)
}
const validationErrors = validateUserUpdate(data)
if (validationErrors.length > 0) {
throw DBALError.validationError(
'Invalid user update data',
validationErrors.map(error => ({ field: 'user', error }))
)
}
try {
return adapter.update('User', id, data) as Promise<User>
} catch (error) {
if (error instanceof DBALError && error.code === 409) {
throw DBALError.conflict(`Username or email already exists`)
}
throw error
}
},
/**
* Delete a user by ID
*/
delete: async (id: string): Promise<boolean> => {
const validationErrors = validateId(id)
if (validationErrors.length > 0) {
throw DBALError.validationError(
'Invalid user ID',
validationErrors.map(error => ({ field: 'id', error }))
)
}
const result = await adapter.delete('User', id)
if (!result) {
throw DBALError.notFound(`User not found: ${id}`)
}
return result
},
/**
* List users with filtering and pagination
*/
list: async (options?: ListOptions): Promise<ListResult<User>> => {
return adapter.list('User', options) as Promise<ListResult<User>>
},
/**
* Batch create multiple users
*/
createMany: async (data: Array<Omit<User, 'id' | 'createdAt' | 'updatedAt'>>): Promise<number> => {
if (!data || data.length === 0) {
return 0
}
const validationErrors = data.flatMap((item, index) =>
validateUserCreate(item).map(error => ({ field: `users[${index}]`, error }))
)
if (validationErrors.length > 0) {
throw DBALError.validationError('Invalid user batch', validationErrors)
}
try {
return adapter.createMany('User', data as Record<string, unknown>[])
} catch (error) {
if (error instanceof DBALError && error.code === 409) {
throw DBALError.conflict('Username or email already exists')
}
throw error
}
},
/**
* Bulk update users matching a filter
*/
updateMany: async (filter: Record<string, unknown>, data: Partial<User>): Promise<number> => {
if (!filter || Object.keys(filter).length === 0) {
throw DBALError.validationError('Bulk update requires a filter', [
{ field: 'filter', error: 'Filter is required' },
])
}
if (!data || Object.keys(data).length === 0) {
throw DBALError.validationError('Bulk update requires data', [
{ field: 'data', error: 'Update data is required' },
])
}
const validationErrors = validateUserUpdate(data)
if (validationErrors.length > 0) {
throw DBALError.validationError(
'Invalid user update data',
validationErrors.map(error => ({ field: 'user', error }))
)
}
try {
return adapter.updateMany('User', filter, data as Record<string, unknown>)
} catch (error) {
if (error instanceof DBALError && error.code === 409) {
throw DBALError.conflict('Username or email already exists')
}
throw error
}
},
/**
* Bulk delete users matching a filter
*/
deleteMany: async (filter: Record<string, unknown>): Promise<number> => {
if (!filter || Object.keys(filter).length === 0) {
throw DBALError.validationError('Bulk delete requires a filter', [
{ field: 'filter', error: 'Filter is required' },
])
}
return adapter.deleteMany('User', filter)
},
})
export { createUserOperations } from './user'
export type { UserOperations } from './user'

View File

@@ -0,0 +1,71 @@
import type { DBALAdapter } from '../../../../adapters/adapter'
import type { User } from '../../../../foundation/types'
import { DBALError } from '../../../../foundation/errors'
import { validateUserCreate, validateUserUpdate } from '../../../../foundation/validation'
export const createManyUsers = async (
adapter: DBALAdapter,
data: Array<Omit<User, 'id' | 'createdAt' | 'updatedAt'>>,
): Promise<number> => {
if (!data || data.length === 0) {
return 0
}
const validationErrors = data.flatMap((item, index) =>
validateUserCreate(item).map(error => ({ field: `users[${index}]`, error })),
)
if (validationErrors.length > 0) {
throw DBALError.validationError('Invalid user batch', validationErrors)
}
try {
return adapter.createMany('User', data as Record<string, unknown>[])
} catch (error) {
if (error instanceof DBALError && error.code === 409) {
throw DBALError.conflict('Username or email already exists')
}
throw error
}
}
export const updateManyUsers = async (
adapter: DBALAdapter,
filter: Record<string, unknown>,
data: Partial<User>,
): Promise<number> => {
if (!filter || Object.keys(filter).length === 0) {
throw DBALError.validationError('Bulk update requires a filter', [
{ field: 'filter', error: 'Filter is required' },
])
}
if (!data || Object.keys(data).length === 0) {
throw DBALError.validationError('Bulk update requires data', [
{ field: 'data', error: 'Update data is required' },
])
}
const validationErrors = validateUserUpdate(data)
if (validationErrors.length > 0) {
throw DBALError.validationError('Invalid user update data', validationErrors.map(error => ({ field: 'user', error })))
}
try {
return adapter.updateMany('User', filter, data as Record<string, unknown>)
} catch (error) {
if (error instanceof DBALError && error.code === 409) {
throw DBALError.conflict('Username or email already exists')
}
throw error
}
}
export const deleteManyUsers = async (adapter: DBALAdapter, filter: Record<string, unknown>): Promise<number> => {
if (!filter || Object.keys(filter).length === 0) {
throw DBALError.validationError('Bulk delete requires a filter', [
{ field: 'filter', error: 'Filter is required' },
])
}
return adapter.deleteMany('User', filter)
}

View File

@@ -0,0 +1,27 @@
import type { DBALAdapter } from '../../../../adapters/adapter'
import type { User, ListOptions, ListResult } from '../../../../foundation/types'
import { createUser, deleteUser, updateUser } from './mutations'
import { createManyUsers, deleteManyUsers, updateManyUsers } from './batch'
import { listUsers, readUser } from './reads'
export interface UserOperations {
create: (data: Omit<User, 'id' | 'createdAt' | 'updatedAt'>) => Promise<User>
read: (id: string) => Promise<User | null>
update: (id: string, data: Partial<User>) => Promise<User>
delete: (id: string) => Promise<boolean>
list: (options?: ListOptions) => Promise<ListResult<User>>
createMany: (data: Array<Omit<User, 'id' | 'createdAt' | 'updatedAt'>>) => Promise<number>
updateMany: (filter: Record<string, unknown>, data: Partial<User>) => Promise<number>
deleteMany: (filter: Record<string, unknown>) => Promise<number>
}
export const createUserOperations = (adapter: DBALAdapter): UserOperations => ({
create: data => createUser(adapter, data),
read: id => readUser(adapter, id),
update: (id, data) => updateUser(adapter, id, data),
delete: id => deleteUser(adapter, id),
list: options => listUsers(adapter, options),
createMany: data => createManyUsers(adapter, data),
updateMany: (filter, data) => updateManyUsers(adapter, filter, data),
deleteMany: filter => deleteManyUsers(adapter, filter),
})

View File

@@ -0,0 +1,57 @@
import type { DBALAdapter } from '../../../../adapters/adapter'
import type { User } from '../../../../foundation/types'
import { DBALError } from '../../../../foundation/errors'
import { validateUserCreate, validateUserUpdate, validateId } from '../../../../foundation/validation'
export const createUser = async (
adapter: DBALAdapter,
data: Omit<User, 'id' | 'createdAt' | 'updatedAt'>,
): Promise<User> => {
const validationErrors = validateUserCreate(data)
if (validationErrors.length > 0) {
throw DBALError.validationError('Invalid user data', validationErrors.map(error => ({ field: 'user', error })))
}
try {
return adapter.create('User', data) as Promise<User>
} catch (error) {
if (error instanceof DBALError && error.code === 409) {
throw DBALError.conflict('User with username or email already exists')
}
throw error
}
}
export const updateUser = async (adapter: DBALAdapter, id: string, data: Partial<User>): Promise<User> => {
const idErrors = validateId(id)
if (idErrors.length > 0) {
throw DBALError.validationError('Invalid user ID', idErrors.map(error => ({ field: 'id', error })))
}
const validationErrors = validateUserUpdate(data)
if (validationErrors.length > 0) {
throw DBALError.validationError('Invalid user update data', validationErrors.map(error => ({ field: 'user', error })))
}
try {
return adapter.update('User', id, data) as Promise<User>
} catch (error) {
if (error instanceof DBALError && error.code === 409) {
throw DBALError.conflict('Username or email already exists')
}
throw error
}
}
export const deleteUser = async (adapter: DBALAdapter, id: string): Promise<boolean> => {
const validationErrors = validateId(id)
if (validationErrors.length > 0) {
throw DBALError.validationError('Invalid user ID', validationErrors.map(error => ({ field: 'id', error })))
}
const result = await adapter.delete('User', id)
if (!result) {
throw DBALError.notFound(`User not found: ${id}`)
}
return result
}

View File

@@ -0,0 +1,21 @@
import type { DBALAdapter } from '../../../../adapters/adapter'
import type { User, ListOptions, ListResult } from '../../../../foundation/types'
import { DBALError } from '../../../../foundation/errors'
import { validateId } from '../../../../foundation/validation'
export const readUser = async (adapter: DBALAdapter, id: string): Promise<User | null> => {
const validationErrors = validateId(id)
if (validationErrors.length > 0) {
throw DBALError.validationError('Invalid user ID', validationErrors.map(error => ({ field: 'id', error })))
}
const result = await adapter.read('User', id) as User | null
if (!result) {
throw DBALError.notFound(`User not found: ${id}`)
}
return result
}
export const listUsers = (adapter: DBALAdapter, options?: ListOptions): Promise<ListResult<User>> => {
return adapter.list('User', options) as Promise<ListResult<User>>
}

View File

@@ -1,185 +1,2 @@
/**
* @file package-operations.ts
* @description Package entity CRUD operations for DBAL client
*
* Single-responsibility module following the small-function-file pattern.
*/
import type { DBALAdapter } from '../../adapters/adapter'
import type { Package, ListOptions, ListResult } from '../types'
import { DBALError } from '../errors'
import { validatePackageCreate, validatePackageUpdate, validateId } from '../validation'
/**
* Create package operations object for the DBAL client
*/
export const createPackageOperations = (adapter: DBALAdapter) => ({
/**
* Create a new package
*/
create: async (data: Omit<Package, 'id' | 'createdAt' | 'updatedAt'>): Promise<Package> => {
const validationErrors = validatePackageCreate(data)
if (validationErrors.length > 0) {
throw DBALError.validationError(
'Invalid package data',
validationErrors.map(error => ({ field: 'package', error }))
)
}
try {
return adapter.create('Package', data) as Promise<Package>
} catch (error) {
if (error instanceof DBALError && error.code === 409) {
throw DBALError.conflict(`Package ${data.name}@${data.version} already exists`)
}
throw error
}
},
/**
* Read a package by ID
*/
read: async (id: string): Promise<Package | null> => {
const validationErrors = validateId(id)
if (validationErrors.length > 0) {
throw DBALError.validationError(
'Invalid package ID',
validationErrors.map(error => ({ field: 'id', error }))
)
}
const result = await adapter.read('Package', id) as Package | null
if (!result) {
throw DBALError.notFound(`Package not found: ${id}`)
}
return result
},
/**
* Update an existing package
*/
update: async (id: string, data: Partial<Package>): Promise<Package> => {
const idErrors = validateId(id)
if (idErrors.length > 0) {
throw DBALError.validationError(
'Invalid package ID',
idErrors.map(error => ({ field: 'id', error }))
)
}
const validationErrors = validatePackageUpdate(data)
if (validationErrors.length > 0) {
throw DBALError.validationError(
'Invalid package update data',
validationErrors.map(error => ({ field: 'package', error }))
)
}
try {
return adapter.update('Package', id, data) as Promise<Package>
} catch (error) {
if (error instanceof DBALError && error.code === 409) {
throw DBALError.conflict('Package name+version already exists')
}
throw error
}
},
/**
* Delete a package by ID
*/
delete: async (id: string): Promise<boolean> => {
const validationErrors = validateId(id)
if (validationErrors.length > 0) {
throw DBALError.validationError(
'Invalid package ID',
validationErrors.map(error => ({ field: 'id', error }))
)
}
const result = await adapter.delete('Package', id)
if (!result) {
throw DBALError.notFound(`Package not found: ${id}`)
}
return result
},
/**
* List packages with filtering and pagination
*/
list: async (options?: ListOptions): Promise<ListResult<Package>> => {
return adapter.list('Package', options) as Promise<ListResult<Package>>
},
/**
* Batch create multiple packages
*/
createMany: async (data: Array<Omit<Package, 'id' | 'createdAt' | 'updatedAt'>>): Promise<number> => {
if (!data || data.length === 0) {
return 0
}
const validationErrors = data.flatMap((item, index) =>
validatePackageCreate(item).map(error => ({ field: `packages[${index}]`, error }))
)
if (validationErrors.length > 0) {
throw DBALError.validationError('Invalid package batch', validationErrors)
}
try {
return adapter.createMany('Package', data as Record<string, unknown>[])
} catch (error) {
if (error instanceof DBALError && error.code === 409) {
throw DBALError.conflict('Package name+version already exists')
}
throw error
}
},
/**
* Bulk update packages matching a filter
*/
updateMany: async (filter: Record<string, unknown>, data: Partial<Package>): Promise<number> => {
if (!filter || Object.keys(filter).length === 0) {
throw DBALError.validationError('Bulk update requires a filter', [
{ field: 'filter', error: 'Filter is required' },
])
}
if (!data || Object.keys(data).length === 0) {
throw DBALError.validationError('Bulk update requires data', [
{ field: 'data', error: 'Update data is required' },
])
}
const validationErrors = validatePackageUpdate(data)
if (validationErrors.length > 0) {
throw DBALError.validationError(
'Invalid package update data',
validationErrors.map(error => ({ field: 'package', error }))
)
}
try {
return adapter.updateMany('Package', filter, data as Record<string, unknown>)
} catch (error) {
if (error instanceof DBALError && error.code === 409) {
throw DBALError.conflict('Package name+version already exists')
}
throw error
}
},
/**
* Bulk delete packages matching a filter
*/
deleteMany: async (filter: Record<string, unknown>): Promise<number> => {
if (!filter || Object.keys(filter).length === 0) {
throw DBALError.validationError('Bulk delete requires a filter', [
{ field: 'filter', error: 'Filter is required' },
])
}
return adapter.deleteMany('Package', filter)
},
})
export { createPackageOperations } from './package'
export type { PackageOperations } from './package'

View File

@@ -0,0 +1,71 @@
import type { DBALAdapter } from '../../../../adapters/adapter'
import type { Package } from '../../../../foundation/types'
import { DBALError } from '../../../../foundation/errors'
import { validatePackageCreate, validatePackageUpdate } from '../../../../foundation/validation'
export const createManyPackages = async (
adapter: DBALAdapter,
data: Array<Omit<Package, 'id' | 'createdAt' | 'updatedAt'>>,
): Promise<number> => {
if (!data || data.length === 0) {
return 0
}
const validationErrors = data.flatMap((item, index) =>
validatePackageCreate(item).map(error => ({ field: `packages[${index}]`, error })),
)
if (validationErrors.length > 0) {
throw DBALError.validationError('Invalid package batch', validationErrors)
}
try {
return adapter.createMany('Package', data as Record<string, unknown>[])
} catch (error) {
if (error instanceof DBALError && error.code === 409) {
throw DBALError.conflict('Package name+version already exists')
}
throw error
}
}
export const updateManyPackages = async (
adapter: DBALAdapter,
filter: Record<string, unknown>,
data: Partial<Package>,
): Promise<number> => {
if (!filter || Object.keys(filter).length === 0) {
throw DBALError.validationError('Bulk update requires a filter', [
{ field: 'filter', error: 'Filter is required' },
])
}
if (!data || Object.keys(data).length === 0) {
throw DBALError.validationError('Bulk update requires data', [
{ field: 'data', error: 'Update data is required' },
])
}
const validationErrors = validatePackageUpdate(data)
if (validationErrors.length > 0) {
throw DBALError.validationError('Invalid package update data', validationErrors.map(error => ({ field: 'package', error })))
}
try {
return adapter.updateMany('Package', filter, data as Record<string, unknown>)
} catch (error) {
if (error instanceof DBALError && error.code === 409) {
throw DBALError.conflict('Package name+version already exists')
}
throw error
}
}
export const deleteManyPackages = async (adapter: DBALAdapter, filter: Record<string, unknown>): Promise<number> => {
if (!filter || Object.keys(filter).length === 0) {
throw DBALError.validationError('Bulk delete requires a filter', [
{ field: 'filter', error: 'Filter is required' },
])
}
return adapter.deleteMany('Package', filter)
}

View File

@@ -0,0 +1,27 @@
import type { DBALAdapter } from '../../../../adapters/adapter'
import type { Package, ListOptions, ListResult } from '../../../../foundation/types'
import { createManyPackages, deleteManyPackages, updateManyPackages } from './batch'
import { createPackage, deletePackage, updatePackage } from './mutations'
import { listPackages, readPackage } from './reads'
export interface PackageOperations {
create: (data: Omit<Package, 'id' | 'createdAt' | 'updatedAt'>) => Promise<Package>
read: (id: string) => Promise<Package | null>
update: (id: string, data: Partial<Package>) => Promise<Package>
delete: (id: string) => Promise<boolean>
list: (options?: ListOptions) => Promise<ListResult<Package>>
createMany: (data: Array<Omit<Package, 'id' | 'createdAt' | 'updatedAt'>>) => Promise<number>
updateMany: (filter: Record<string, unknown>, data: Partial<Package>) => Promise<number>
deleteMany: (filter: Record<string, unknown>) => Promise<number>
}
export const createPackageOperations = (adapter: DBALAdapter): PackageOperations => ({
create: data => createPackage(adapter, data),
read: id => readPackage(adapter, id),
update: (id, data) => updatePackage(adapter, id, data),
delete: id => deletePackage(adapter, id),
list: options => listPackages(adapter, options),
createMany: data => createManyPackages(adapter, data),
updateMany: (filter, data) => updateManyPackages(adapter, filter, data),
deleteMany: filter => deleteManyPackages(adapter, filter),
})

View File

@@ -0,0 +1,61 @@
import type { DBALAdapter } from '../../../../adapters/adapter'
import type { Package } from '../../../../foundation/types'
import { DBALError } from '../../../../foundation/errors'
import { validatePackageCreate, validatePackageUpdate, validateId } from '../../../../foundation/validation'
export const createPackage = async (
adapter: DBALAdapter,
data: Omit<Package, 'id' | 'createdAt' | 'updatedAt'>,
): Promise<Package> => {
const validationErrors = validatePackageCreate(data)
if (validationErrors.length > 0) {
throw DBALError.validationError('Invalid package data', validationErrors.map(error => ({ field: 'package', error })))
}
try {
return adapter.create('Package', data) as Promise<Package>
} catch (error) {
if (error instanceof DBALError && error.code === 409) {
throw DBALError.conflict(`Package ${data.name}@${data.version} already exists`)
}
throw error
}
}
export const updatePackage = async (
adapter: DBALAdapter,
id: string,
data: Partial<Package>,
): Promise<Package> => {
const idErrors = validateId(id)
if (idErrors.length > 0) {
throw DBALError.validationError('Invalid package ID', idErrors.map(error => ({ field: 'id', error })))
}
const validationErrors = validatePackageUpdate(data)
if (validationErrors.length > 0) {
throw DBALError.validationError('Invalid package update data', validationErrors.map(error => ({ field: 'package', error })))
}
try {
return adapter.update('Package', id, data) as Promise<Package>
} catch (error) {
if (error instanceof DBALError && error.code === 409) {
throw DBALError.conflict('Package name+version already exists')
}
throw error
}
}
export const deletePackage = async (adapter: DBALAdapter, id: string): Promise<boolean> => {
const validationErrors = validateId(id)
if (validationErrors.length > 0) {
throw DBALError.validationError('Invalid package ID', validationErrors.map(error => ({ field: 'id', error })))
}
const result = await adapter.delete('Package', id)
if (!result) {
throw DBALError.notFound(`Package not found: ${id}`)
}
return result
}

View File

@@ -0,0 +1,21 @@
import type { DBALAdapter } from '../../../../adapters/adapter'
import type { Package, ListOptions, ListResult } from '../../../../foundation/types'
import { DBALError } from '../../../../foundation/errors'
import { validateId } from '../../../../foundation/validation'
export const readPackage = async (adapter: DBALAdapter, id: string): Promise<Package | null> => {
const validationErrors = validateId(id)
if (validationErrors.length > 0) {
throw DBALError.validationError('Invalid package ID', validationErrors.map(error => ({ field: 'id', error })))
}
const result = await adapter.read('Package', id) as Package | null
if (!result) {
throw DBALError.notFound(`Package not found: ${id}`)
}
return result
}
export const listPackages = (adapter: DBALAdapter, options?: ListOptions): Promise<ListResult<Package>> => {
return adapter.list('Package', options) as Promise<ListResult<Package>>
}

View File

@@ -1,307 +0,0 @@
/**
* Key-Value Store with Multi-Tenant Support
*
* Stores primitive types (string, number, boolean) and complex types (objects, arrays)
* with tenant isolation, access control, and quota management.
*/
import { TenantContext } from './tenant-context'
import { DBALError } from './errors'
export type StorableValue = string | number | boolean | null | object | Array<any>
export interface KVStoreEntry {
key: string
value: StorableValue
type: 'string' | 'number' | 'boolean' | 'null' | 'object' | 'array'
sizeBytes: number
createdAt: Date
updatedAt: Date
expiresAt?: Date
}
export interface KVListOptions {
prefix?: string
limit?: number
cursor?: string
}
export interface KVListResult {
entries: KVStoreEntry[]
nextCursor?: string
hasMore: boolean
}
export interface KVStore {
// Basic operations
get(key: string, context: TenantContext): Promise<StorableValue | null>
set(key: string, value: StorableValue, context: TenantContext, ttl?: number): Promise<void>
delete(key: string, context: TenantContext): Promise<boolean>
exists(key: string, context: TenantContext): Promise<boolean>
// List operations
listAdd(key: string, items: any[], context: TenantContext): Promise<number>
listGet(key: string, context: TenantContext, start?: number, end?: number): Promise<any[]>
listRemove(key: string, value: any, context: TenantContext): Promise<number>
listLength(key: string, context: TenantContext): Promise<number>
listClear(key: string, context: TenantContext): Promise<void>
// Batch operations
mget(keys: string[], context: TenantContext): Promise<Map<string, StorableValue | null>>
mset(entries: Map<string, StorableValue>, context: TenantContext): Promise<void>
// Query operations
list(options: KVListOptions, context: TenantContext): Promise<KVListResult>
count(prefix: string, context: TenantContext): Promise<number>
// Utility
clear(context: TenantContext): Promise<number>
}
export class InMemoryKVStore implements KVStore {
private data = new Map<string, KVStoreEntry>()
private getScopedKey(key: string, context: TenantContext): string {
return `${context.namespace}${key}`
}
private calculateSize(value: StorableValue): number {
if (value === null || value === undefined) return 0
if (typeof value === 'string') return value.length * 2 // UTF-16
if (typeof value === 'number') return 8
if (typeof value === 'boolean') return 1
return JSON.stringify(value).length * 2
}
private getValueType(value: StorableValue): KVStoreEntry['type'] {
if (value === null) return 'null'
if (Array.isArray(value)) return 'array'
return typeof value as 'string' | 'number' | 'boolean' | 'object'
}
async get(key: string, context: TenantContext): Promise<StorableValue | null> {
if (!context.canRead('kv')) {
throw DBALError.forbidden('Permission denied: cannot read key-value data')
}
const scopedKey = this.getScopedKey(key, context)
const entry = this.data.get(scopedKey)
if (!entry) return null
// Check expiration
if (entry.expiresAt && entry.expiresAt < new Date()) {
this.data.delete(scopedKey)
return null
}
return entry.value
}
async set(key: string, value: StorableValue, context: TenantContext, ttl?: number): Promise<void> {
if (!context.canWrite('kv')) {
throw DBALError.forbidden('Permission denied: cannot write key-value data')
}
const scopedKey = this.getScopedKey(key, context)
const sizeBytes = this.calculateSize(value)
// Check quota
const existing = this.data.get(scopedKey)
const sizeDelta = existing ? sizeBytes - existing.sizeBytes : sizeBytes
if (sizeDelta > 0 && context.quota.maxDataSizeBytes) {
if (context.quota.currentDataSizeBytes + sizeDelta > context.quota.maxDataSizeBytes) {
throw DBALError.forbidden('Quota exceeded: maximum data size reached')
}
}
if (!existing && !context.canCreateRecord()) {
throw DBALError.forbidden('Quota exceeded: maximum record count reached')
}
const now = new Date()
const entry: KVStoreEntry = {
key,
value,
type: this.getValueType(value),
sizeBytes,
createdAt: existing?.createdAt || now,
updatedAt: now,
expiresAt: ttl ? new Date(now.getTime() + ttl * 1000) : undefined
}
this.data.set(scopedKey, entry)
// Update quota (would normally be done by TenantManager)
if (sizeDelta > 0) {
context.quota.currentDataSizeBytes += sizeDelta
}
if (!existing) {
context.quota.currentRecords++
}
}
async delete(key: string, context: TenantContext): Promise<boolean> {
if (!context.canDelete('kv')) {
throw DBALError.forbidden('Permission denied: cannot delete key-value data')
}
const scopedKey = this.getScopedKey(key, context)
const entry = this.data.get(scopedKey)
if (!entry) return false
this.data.delete(scopedKey)
// Update quota
context.quota.currentDataSizeBytes -= entry.sizeBytes
context.quota.currentRecords--
return true
}
async exists(key: string, context: TenantContext): Promise<boolean> {
if (!context.canRead('kv')) {
throw DBALError.forbidden('Permission denied: cannot read key-value data')
}
const value = await this.get(key, context)
return value !== null
}
// List operations
async listAdd(key: string, items: any[], context: TenantContext): Promise<number> {
if (!context.canWrite('kv')) {
throw DBALError.forbidden('Permission denied: cannot write key-value data')
}
if (!context.canAddToList(items.length)) {
throw DBALError.forbidden('Quota exceeded: list length limit reached')
}
const existing = await this.get(key, context)
const list = Array.isArray(existing) ? existing : []
list.push(...items)
await this.set(key, list, context)
return list.length
}
async listGet(key: string, context: TenantContext, start: number = 0, end?: number): Promise<any[]> {
const value = await this.get(key, context)
if (!Array.isArray(value)) return []
if (end === undefined) {
return value.slice(start)
}
return value.slice(start, end)
}
async listRemove(key: string, valueToRemove: any, context: TenantContext): Promise<number> {
if (!context.canWrite('kv')) {
throw DBALError.forbidden('Permission denied: cannot write key-value data')
}
const existing = await this.get(key, context)
if (!Array.isArray(existing)) return 0
const filtered = existing.filter(item => !this.deepEquals(item, valueToRemove))
const removed = existing.length - filtered.length
if (removed > 0) {
await this.set(key, filtered, context)
}
return removed
}
async listLength(key: string, context: TenantContext): Promise<number> {
const value = await this.get(key, context)
return Array.isArray(value) ? value.length : 0
}
async listClear(key: string, context: TenantContext): Promise<void> {
await this.set(key, [], context)
}
// Batch operations
async mget(keys: string[], context: TenantContext): Promise<Map<string, StorableValue | null>> {
const result = new Map<string, StorableValue | null>()
for (const key of keys) {
const value = await this.get(key, context)
result.set(key, value)
}
return result
}
async mset(entries: Map<string, StorableValue>, context: TenantContext): Promise<void> {
for (const [key, value] of entries) {
await this.set(key, value, context)
}
}
// Query operations
async list(options: KVListOptions, context: TenantContext): Promise<KVListResult> {
if (!context.canRead('kv')) {
throw DBALError.forbidden('Permission denied: cannot read key-value data')
}
const prefix = options.prefix || ''
const limit = options.limit || 100
const scopedPrefix = this.getScopedKey(prefix, context)
const entries: KVStoreEntry[] = []
for (const [scopedKey, entry] of this.data) {
if (scopedKey.startsWith(scopedPrefix)) {
// Skip expired entries
if (entry.expiresAt && entry.expiresAt < new Date()) {
continue
}
entries.push(entry)
if (entries.length >= limit) break
}
}
return {
entries,
hasMore: false, // Simplified for in-memory implementation
nextCursor: undefined
}
}
async count(prefix: string, context: TenantContext): Promise<number> {
const result = await this.list({ prefix, limit: Number.MAX_SAFE_INTEGER }, context)
return result.entries.length
}
async clear(context: TenantContext): Promise<number> {
if (!context.canDelete('kv')) {
throw DBALError.forbidden('Permission denied: cannot delete key-value data')
}
const scopedPrefix = this.getScopedKey('', context)
let count = 0
for (const [scopedKey] of this.data) {
if (scopedKey.startsWith(scopedPrefix)) {
this.data.delete(scopedKey)
count++
}
}
// Reset quota
context.quota.currentDataSizeBytes = 0
context.quota.currentRecords = 0
return count
}
private deepEquals(a: any, b: any): boolean {
return JSON.stringify(a) === JSON.stringify(b)
}
}

View File

@@ -1,216 +1 @@
export interface User {
id: string
username: string
email: string
role: 'user' | 'admin' | 'god' | 'supergod'
createdAt: Date
updatedAt: Date
}
export interface CreateUserInput {
username: string
email: string
role?: User['role']
}
export interface UpdateUserInput {
username?: string
email?: string
role?: User['role']
}
export interface Credential {
id: string
username: string
passwordHash: string
firstLogin: boolean
createdAt: Date
updatedAt: Date
}
export interface Session {
id: string
userId: string
token: string
expiresAt: Date
createdAt: Date
lastActivity: Date
}
export interface CreateSessionInput {
userId: string
token: string
expiresAt: Date
}
export interface UpdateSessionInput {
userId?: string
token?: string
expiresAt?: Date
lastActivity?: Date
}
export interface PageView {
id: string
slug: string
title: string
description?: string
level: number
layout: Record<string, unknown>
isActive: boolean
createdAt: Date
updatedAt: Date
}
export interface CreatePageInput {
slug: string
title: string
description?: string
level: number
layout: Record<string, unknown>
isActive?: boolean
}
export interface UpdatePageInput {
slug?: string
title?: string
description?: string
level?: number
layout?: Record<string, unknown>
isActive?: boolean
}
export interface ComponentHierarchy {
id: string
pageId: string
parentId?: string
componentType: string
order: number
props: Record<string, unknown>
createdAt: Date
updatedAt: Date
}
export interface Workflow {
id: string
name: string
description?: string
trigger: 'manual' | 'schedule' | 'event' | 'webhook'
triggerConfig: Record<string, unknown>
steps: Record<string, unknown>
isActive: boolean
createdBy: string
createdAt: Date
updatedAt: Date
}
export interface CreateWorkflowInput {
name: string
description?: string
trigger: Workflow['trigger']
triggerConfig: Record<string, unknown>
steps: Record<string, unknown>
isActive?: boolean
createdBy: string
}
export interface UpdateWorkflowInput {
name?: string
description?: string
trigger?: Workflow['trigger']
triggerConfig?: Record<string, unknown>
steps?: Record<string, unknown>
isActive?: boolean
createdBy?: string
}
export interface LuaScript {
id: string
name: string
description?: string
code: string
isSandboxed: boolean
allowedGlobals: string[]
timeoutMs: number
createdBy: string
createdAt: Date
updatedAt: Date
}
export interface CreateLuaScriptInput {
name: string
description?: string
code: string
isSandboxed?: boolean
allowedGlobals: string[]
timeoutMs?: number
createdBy: string
}
export interface UpdateLuaScriptInput {
name?: string
description?: string
code?: string
isSandboxed?: boolean
allowedGlobals?: string[]
timeoutMs?: number
createdBy?: string
}
export interface Package {
id: string
name: string
version: string
description?: string
author: string
manifest: Record<string, unknown>
isInstalled: boolean
installedAt?: Date
installedBy?: string
createdAt: Date
updatedAt: Date
}
export interface CreatePackageInput {
name: string
version: string
description?: string
author: string
manifest: Record<string, unknown>
isInstalled?: boolean
installedAt?: Date
installedBy?: string
}
export interface UpdatePackageInput {
name?: string
version?: string
description?: string
author?: string
manifest?: Record<string, unknown>
isInstalled?: boolean
installedAt?: Date
installedBy?: string
}
export interface ListOptions {
filter?: Record<string, unknown>
sort?: Record<string, 'asc' | 'desc'>
page?: number
limit?: number
}
export interface ListResult<T> {
data: T[]
total: number
page: number
limit: number
hasMore: boolean
}
export interface ResultError {
code: string
message: string
}
export type Result<T> = { success: true; data: T } | { success: false; error: ResultError }
export * from './types'

View File

@@ -0,0 +1,30 @@
export interface Credential {
id: string
username: string
passwordHash: string
firstLogin: boolean
createdAt: Date
updatedAt: Date
}
export interface Session {
id: string
userId: string
token: string
expiresAt: Date
createdAt: Date
lastActivity: Date
}
export interface CreateSessionInput {
userId: string
token: string
expiresAt: Date
}
export interface UpdateSessionInput {
userId?: string
token?: string
expiresAt?: Date
lastActivity?: Date
}

View File

@@ -0,0 +1,65 @@
export interface Workflow {
id: string
name: string
description?: string
trigger: 'manual' | 'schedule' | 'event' | 'webhook'
triggerConfig: Record<string, unknown>
steps: Record<string, unknown>
isActive: boolean
createdBy: string
createdAt: Date
updatedAt: Date
}
export interface CreateWorkflowInput {
name: string
description?: string
trigger: Workflow['trigger']
triggerConfig: Record<string, unknown>
steps: Record<string, unknown>
isActive?: boolean
createdBy: string
}
export interface UpdateWorkflowInput {
name?: string
description?: string
trigger?: Workflow['trigger']
triggerConfig?: Record<string, unknown>
steps?: Record<string, unknown>
isActive?: boolean
createdBy?: string
}
export interface LuaScript {
id: string
name: string
description?: string
code: string
isSandboxed: boolean
allowedGlobals: string[]
timeoutMs: number
createdBy: string
createdAt: Date
updatedAt: Date
}
export interface CreateLuaScriptInput {
name: string
description?: string
code: string
isSandboxed?: boolean
allowedGlobals: string[]
timeoutMs?: number
createdBy: string
}
export interface UpdateLuaScriptInput {
name?: string
description?: string
code?: string
isSandboxed?: boolean
allowedGlobals?: string[]
timeoutMs?: number
createdBy?: string
}

View File

@@ -0,0 +1,40 @@
export interface PageView {
id: string
slug: string
title: string
description?: string
level: number
layout: Record<string, unknown>
isActive: boolean
createdAt: Date
updatedAt: Date
}
export interface CreatePageInput {
slug: string
title: string
description?: string
level: number
layout: Record<string, unknown>
isActive?: boolean
}
export interface UpdatePageInput {
slug?: string
title?: string
description?: string
level?: number
layout?: Record<string, unknown>
isActive?: boolean
}
export interface ComponentHierarchy {
id: string
pageId: string
parentId?: string
componentType: string
order: number
props: Record<string, unknown>
createdAt: Date
updatedAt: Date
}

View File

@@ -0,0 +1,6 @@
export * from './users'
export * from './auth'
export * from './content'
export * from './automation'
export * from './packages'
export * from './shared'

View File

@@ -0,0 +1,35 @@
export interface Package {
id: string
name: string
version: string
description?: string
author: string
manifest: Record<string, unknown>
isInstalled: boolean
installedAt?: Date
installedBy?: string
createdAt: Date
updatedAt: Date
}
export interface CreatePackageInput {
name: string
version: string
description?: string
author: string
manifest: Record<string, unknown>
isInstalled?: boolean
installedAt?: Date
installedBy?: string
}
export interface UpdatePackageInput {
name?: string
version?: string
description?: string
author?: string
manifest?: Record<string, unknown>
isInstalled?: boolean
installedAt?: Date
installedBy?: string
}

View File

@@ -0,0 +1,21 @@
export interface ListOptions {
filter?: Record<string, unknown>
sort?: Record<string, 'asc' | 'desc'>
page?: number
limit?: number
}
export interface ListResult<T> {
data: T[]
total: number
page: number
limit: number
hasMore: boolean
}
export interface ResultError {
code: string
message: string
}
export type Result<T> = { success: true; data: T } | { success: false; error: ResultError }

View File

@@ -0,0 +1,20 @@
export interface User {
id: string
username: string
email: string
role: 'user' | 'admin' | 'god' | 'supergod'
createdAt: Date
updatedAt: Date
}
export interface CreateUserInput {
username: string
email: string
role?: User['role']
}
export interface UpdateUserInput {
username?: string
email?: string
role?: User['role']
}

View File

@@ -0,0 +1,67 @@
import type { TenantContext } from '../foundation/tenant-context'
import type { KVListOptions, KVListResult, KVStore, KVStoreState, StorableValue } from './types'
import { clear, count, listEntries, mget, mset } from './operations/batch'
import { getValue, exists, listGet, listLength } from './operations/read'
import { deleteValue, listAdd, listClear, listRemove, setValue } from './operations/write'
export class InMemoryKVStore implements KVStore {
private state: KVStoreState = { data: new Map() }
get(key: string, context: TenantContext): Promise<StorableValue | null> {
return getValue(this.state, key, context)
}
set(key: string, value: StorableValue, context: TenantContext, ttl?: number): Promise<void> {
return setValue(this.state, key, value, context, ttl)
}
delete(key: string, context: TenantContext): Promise<boolean> {
return deleteValue(this.state, key, context)
}
exists(key: string, context: TenantContext): Promise<boolean> {
return exists(this.state, key, context)
}
listAdd(key: string, items: any[], context: TenantContext): Promise<number> {
return listAdd(this.state, key, items, context)
}
listGet(key: string, context: TenantContext, start?: number, end?: number): Promise<any[]> {
return listGet(this.state, key, context, start, end)
}
listRemove(key: string, value: any, context: TenantContext): Promise<number> {
return listRemove(this.state, key, value, context)
}
listLength(key: string, context: TenantContext): Promise<number> {
return listLength(this.state, key, context)
}
listClear(key: string, context: TenantContext): Promise<void> {
return listClear(this.state, key, context)
}
mget(keys: string[], context: TenantContext): Promise<Map<string, StorableValue | null>> {
return mget(this.state, keys, context)
}
mset(entries: Map<string, StorableValue>, context: TenantContext): Promise<void> {
return mset(this.state, entries, context)
}
list(options: KVListOptions, context: TenantContext): Promise<KVListResult> {
return listEntries(this.state, options, context)
}
count(prefix: string, context: TenantContext): Promise<number> {
return count(prefix, this.state, context)
}
clear(context: TenantContext): Promise<number> {
return clear(this.state, context)
}
}
export type { KVStoreEntry, KVListOptions, KVListResult, StorableValue } from './types'

View File

@@ -0,0 +1,95 @@
import { DBALError } from '../../foundation/errors'
import type { TenantContext } from '../../foundation/tenant-context'
import { scopedKey, getEntry } from '../scoping'
import type { KVListOptions, KVListResult, KVStoreState, StorableValue } from '../types'
import { setValue } from './write'
export async function mget(
state: KVStoreState,
keys: string[],
context: TenantContext
): Promise<Map<string, StorableValue | null>> {
const result = new Map<string, StorableValue | null>()
for (const key of keys) {
const scoped = scopedKey(key, context)
const entry = getEntry(state, scoped)
result.set(key, entry?.value ?? null)
}
return result
}
export async function mset(
state: KVStoreState,
entries: Map<string, StorableValue>,
context: TenantContext
): Promise<void> {
for (const [key, value] of entries) {
await setValue(state, key, value, context)
}
}
export async function listEntries(
state: KVStoreState,
options: KVListOptions,
context: TenantContext
): Promise<KVListResult> {
if (!context.canRead('kv')) {
throw DBALError.forbidden('Permission denied: cannot read key-value data')
}
const prefix = options.prefix || ''
const limit = options.limit || 100
const scopedPrefix = scopedKey(prefix, context)
const entries: KVListEntry[] = []
for (const [scoped, entry] of state.data) {
if (scoped.startsWith(scopedPrefix)) {
if (entry.expiresAt && entry.expiresAt < new Date()) {
continue
}
entries.push(entry)
if (entries.length >= limit) break
}
}
return {
entries,
hasMore: false,
nextCursor: undefined
}
}
type KVListEntry = KVListResult['entries'][number]
export async function count(prefix: string, state: KVStoreState, context: TenantContext): Promise<number> {
const result = await listEntries(state, { prefix, limit: Number.MAX_SAFE_INTEGER }, context)
return result.entries.length
}
export async function clear(
state: KVStoreState,
context: TenantContext
): Promise<number> {
if (!context.canDelete('kv')) {
throw DBALError.forbidden('Permission denied: cannot delete key-value data')
}
const scopedPrefix = scopedKey('', context)
let removed = 0
for (const [scoped] of state.data) {
if (scoped.startsWith(scopedPrefix)) {
state.data.delete(scoped)
removed++
}
}
context.quota.currentDataSizeBytes = 0
context.quota.currentRecords = 0
return removed
}

View File

@@ -0,0 +1,53 @@
import { DBALError } from '../../foundation/errors'
import type { TenantContext } from '../../foundation/tenant-context'
import { getEntry, scopedKey } from '../scoping'
import type { KVStoreState, StorableValue } from '../types'
export async function getValue(
state: KVStoreState,
key: string,
context: TenantContext
): Promise<StorableValue | null> {
if (!context.canRead('kv')) {
throw DBALError.forbidden('Permission denied: cannot read key-value data')
}
const scoped = scopedKey(key, context)
const entry = getEntry(state, scoped)
return entry?.value ?? null
}
export async function exists(
state: KVStoreState,
key: string,
context: TenantContext
): Promise<boolean> {
const value = await getValue(state, key, context)
return value !== null
}
export async function listGet(
state: KVStoreState,
key: string,
context: TenantContext,
start: number = 0,
end?: number
): Promise<any[]> {
const value = await getValue(state, key, context)
if (!Array.isArray(value)) return []
if (end === undefined) {
return value.slice(start)
}
return value.slice(start, end)
}
export async function listLength(
state: KVStoreState,
key: string,
context: TenantContext
): Promise<number> {
const value = await getValue(state, key, context)
return Array.isArray(value) ? value.length : 0
}

View File

@@ -0,0 +1,143 @@
import { DBALError } from '../../foundation/errors'
import type { TenantContext } from '../../foundation/tenant-context'
import { calculateSize, deepEquals, scopedKey, valueType } from '../scoping'
import type { KVStoreEntry, KVStoreState, StorableValue } from '../types'
export async function setValue(
state: KVStoreState,
key: string,
value: StorableValue,
context: TenantContext,
ttl?: number
): Promise<void> {
if (!context.canWrite('kv')) {
throw DBALError.forbidden('Permission denied: cannot write key-value data')
}
const scoped = scopedKey(key, context)
const sizeBytes = calculateSize(value)
const existing = state.data.get(scoped)
const sizeDelta = existing ? sizeBytes - existing.sizeBytes : sizeBytes
if (sizeDelta > 0 && context.quota.maxDataSizeBytes) {
if (context.quota.currentDataSizeBytes + sizeDelta > context.quota.maxDataSizeBytes) {
throw DBALError.forbidden('Quota exceeded: maximum data size reached')
}
}
if (!existing && !context.canCreateRecord()) {
throw DBALError.forbidden('Quota exceeded: maximum record count reached')
}
const now = new Date()
const entry: KVStoreEntry = {
key,
value,
type: valueType(value),
sizeBytes,
createdAt: existing?.createdAt || now,
updatedAt: now,
expiresAt: ttl ? new Date(now.getTime() + ttl * 1000) : undefined
}
state.data.set(scoped, entry)
if (sizeDelta > 0) {
context.quota.currentDataSizeBytes += sizeDelta
}
if (!existing) {
context.quota.currentRecords++
}
}
export async function deleteValue(
state: KVStoreState,
key: string,
context: TenantContext
): Promise<boolean> {
if (!context.canDelete('kv')) {
throw DBALError.forbidden('Permission denied: cannot delete key-value data')
}
const scoped = scopedKey(key, context)
const existing = state.data.get(scoped)
if (!existing) return false
state.data.delete(scoped)
context.quota.currentDataSizeBytes -= existing.sizeBytes
context.quota.currentRecords--
return true
}
export async function listAdd(
state: KVStoreState,
key: string,
items: any[],
context: TenantContext
): Promise<number> {
if (!context.canWrite('kv')) {
throw DBALError.forbidden('Permission denied: cannot write key-value data')
}
if (!context.canAddToList(items.length)) {
throw DBALError.forbidden('Quota exceeded: list length limit reached')
}
const existing = await getValueForWrite(state, key, context)
const list = Array.isArray(existing) ? existing : []
list.push(...items)
await setValue(state, key, list, context)
return list.length
}
export async function listRemove(
state: KVStoreState,
key: string,
valueToRemove: any,
context: TenantContext
): Promise<number> {
if (!context.canWrite('kv')) {
throw DBALError.forbidden('Permission denied: cannot write key-value data')
}
const existing = await getValueForWrite(state, key, context)
if (!Array.isArray(existing)) return 0
const filtered = existing.filter(item => !deepEquals(item, valueToRemove))
const removed = existing.length - filtered.length
if (removed > 0) {
await setValue(state, key, filtered, context)
}
return removed
}
export async function listClear(
state: KVStoreState,
key: string,
context: TenantContext
): Promise<void> {
await setValue(state, key, [], context)
}
async function getValueForWrite(
state: KVStoreState,
key: string,
context: TenantContext
): Promise<StorableValue | null> {
if (!context.canRead('kv')) {
throw DBALError.forbidden('Permission denied: cannot read key-value data')
}
const scoped = scopedKey(key, context)
const entry = state.data.get(scoped)
if (!entry) return null
if (entry.expiresAt && entry.expiresAt < new Date()) {
state.data.delete(scoped)
return null
}
return entry.value
}

View File

@@ -0,0 +1,38 @@
import type { KVStoreEntry, KVStoreState, StorableValue } from './types'
import type { TenantContext } from '../foundation/tenant-context'
export function scopedKey(key: string, context: TenantContext): string {
return `${context.namespace}${key}`
}
export function calculateSize(value: StorableValue): number {
if (value === null || value === undefined) return 0
if (typeof value === 'string') return value.length * 2
if (typeof value === 'number') return 8
if (typeof value === 'boolean') return 1
return JSON.stringify(value).length * 2
}
export function valueType(value: StorableValue): KVStoreEntry['type'] {
if (value === null) return 'null'
if (Array.isArray(value)) return 'array'
return typeof value as 'string' | 'number' | 'boolean' | 'object'
}
export function isExpired(entry: KVStoreEntry): boolean {
return Boolean(entry.expiresAt && entry.expiresAt < new Date())
}
export function deepEquals(a: any, b: any): boolean {
return JSON.stringify(a) === JSON.stringify(b)
}
export function getEntry(state: KVStoreState, scoped: string): KVStoreEntry | undefined {
const entry = state.data.get(scoped)
if (!entry) return undefined
if (isExpired(entry)) {
state.data.delete(scoped)
return undefined
}
return entry
}

View File

@@ -0,0 +1,46 @@
import { TenantContext } from '../foundation/tenant-context'
export type StorableValue = string | number | boolean | null | object | Array<any>
export interface KVStoreEntry {
key: string
value: StorableValue
type: 'string' | 'number' | 'boolean' | 'null' | 'object' | 'array'
sizeBytes: number
createdAt: Date
updatedAt: Date
expiresAt?: Date
}
export interface KVListOptions {
prefix?: string
limit?: number
cursor?: string
}
export interface KVListResult {
entries: KVStoreEntry[]
nextCursor?: string
hasMore: boolean
}
export interface KVStore {
get(key: string, context: TenantContext): Promise<StorableValue | null>
set(key: string, value: StorableValue, context: TenantContext, ttl?: number): Promise<void>
delete(key: string, context: TenantContext): Promise<boolean>
exists(key: string, context: TenantContext): Promise<boolean>
listAdd(key: string, items: any[], context: TenantContext): Promise<number>
listGet(key: string, context: TenantContext, start?: number, end?: number): Promise<any[]>
listRemove(key: string, value: any, context: TenantContext): Promise<number>
listLength(key: string, context: TenantContext): Promise<number>
listClear(key: string, context: TenantContext): Promise<void>
mget(keys: string[], context: TenantContext): Promise<Map<string, StorableValue | null>>
mset(entries: Map<string, StorableValue>, context: TenantContext): Promise<void>
list(options: KVListOptions, context: TenantContext): Promise<KVListResult>
count(prefix: string, context: TenantContext): Promise<number>
clear(context: TenantContext): Promise<number>
}
export interface KVStoreState {
data: Map<string, KVStoreEntry>
}

View File

@@ -4,5 +4,5 @@ export type * from './core/foundation/types'
export { DBALError, DBALErrorCode } from './core/foundation/errors'
export * from './core/validation'
export * from './core/foundation/tenant-context'
export * from './core/foundation/kv-store'
export * from './core/kv'
export * from './blob'

View File

@@ -1,342 +1,17 @@
import { execSync, spawn } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import path from 'path'
import { CppBuildAssistant, runCppBuildAssistant } from './cpp-build-assistant/index'
const COLORS = {
reset: '\x1b[0m',
bright: '\x1b[1m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
cyan: '\x1b[36m',
} as const;
interface ExecResult {
success: boolean;
output?: string;
error?: string;
}
interface ExecOptions {
cwd?: string;
silent?: boolean;
args?: string[];
}
const log = {
info: (msg: string) => console.log(`${COLORS.blue}${COLORS.reset} ${msg}`),
success: (msg: string) => console.log(`${COLORS.green}${COLORS.reset} ${msg}`),
warn: (msg: string) => console.log(`${COLORS.yellow}${COLORS.reset} ${msg}`),
error: (msg: string) => console.log(`${COLORS.red}${COLORS.reset} ${msg}`),
section: (msg: string) => console.log(`\n${COLORS.bright}${COLORS.cyan}${msg}${COLORS.reset}\n`),
};
export class CppBuildAssistant {
private projectRoot: string;
private cppDir: string;
private buildDir: string;
constructor(projectRoot?: string) {
this.projectRoot = projectRoot || path.join(__dirname, '..');
this.cppDir = path.join(this.projectRoot, 'cpp');
this.buildDir = path.join(this.cppDir, 'build');
}
private exec(command: string, options: ExecOptions = {}): ExecResult {
try {
const result = execSync(command, {
cwd: options.cwd || this.cppDir,
encoding: 'utf-8',
stdio: options.silent ? 'pipe' : 'inherit',
});
return { success: true, output: result as string };
} catch (error: any) {
return { success: false, error: error.message, output: error.stdout };
}
}
private checkCommand(command: string, name: string): boolean {
try {
execSync(`${command} --version`, { stdio: 'pipe' });
log.success(`${name} is installed`);
return true;
} catch {
log.error(`${name} is NOT installed`);
return false;
}
}
checkDependencies(): boolean {
log.section('Checking Dependencies');
const deps = [
{ cmd: 'cmake', name: 'CMake' },
{ cmd: 'conan', name: 'Conan' },
{ cmd: 'ninja', name: 'Ninja' },
{ cmd: 'g++', name: 'GCC' },
];
const results = deps.map(({ cmd, name }) => ({
name,
installed: this.checkCommand(cmd, name),
}));
const allInstalled = results.every((r) => r.installed);
if (!allInstalled) {
log.warn('\nSome dependencies are missing. Install them:');
if (os.platform() === 'darwin') {
log.info(' brew install cmake conan ninja gcc');
} else if (os.platform() === 'linux') {
log.info(' sudo apt-get install cmake ninja-build g++');
log.info(' pip install conan');
} else if (os.platform() === 'win32') {
log.info(' choco install cmake conan ninja');
}
}
return allInstalled;
}
createConanfile(): boolean {
log.section('Checking Conanfile');
const conanfilePath = path.join(this.cppDir, 'conanfile.txt');
if (fs.existsSync(conanfilePath)) {
log.success('conanfile.txt exists');
return true;
}
log.info('Creating conanfile.txt...');
const conanfileContent = `[requires]
sqlite3/3.45.0
fmt/10.2.1
spdlog/1.13.0
nlohmann_json/3.11.3
[generators]
CMakeDeps
CMakeToolchain
[options]
sqlite3:shared=False
[layout]
cmake_layout
`;
fs.writeFileSync(conanfilePath, conanfileContent);
log.success('Created conanfile.txt');
return true;
}
installConanDeps(): boolean {
log.section('Installing Conan Dependencies');
const conanfilePath = path.join(this.cppDir, 'conanfile.txt');
if (!fs.existsSync(conanfilePath)) {
log.error('conanfile.txt not found');
return false;
}
log.info('Running conan install...');
const buildType = process.env.CMAKE_BUILD_TYPE || 'Release';
const result = this.exec(
`conan install . --output-folder=build --build=missing -s build_type=${buildType}`
);
if (!result.success) {
log.error('Conan install failed');
return false;
}
log.success('Conan dependencies installed');
return true;
}
configureCMake(buildType: 'Debug' | 'Release' = 'Release'): boolean {
log.section('Configuring CMake with Ninja');
if (!fs.existsSync(this.buildDir)) {
fs.mkdirSync(this.buildDir, { recursive: true });
}
log.info(`Build type: ${buildType}`);
const toolchainPath = path.join(this.buildDir, 'conan_toolchain.cmake');
const cmakeArgs = [
'-G Ninja',
`-DCMAKE_BUILD_TYPE=${buildType}`,
'-DCMAKE_EXPORT_COMPILE_COMMANDS=ON',
];
if (fs.existsSync(toolchainPath)) {
cmakeArgs.push(`-DCMAKE_TOOLCHAIN_FILE=${toolchainPath}`);
log.info('Using Conan toolchain');
}
const result = this.exec(`cmake -B build ${cmakeArgs.join(' ')} .`);
if (!result.success) {
log.error('CMake configuration failed');
return false;
}
log.success('CMake configured successfully');
return true;
}
build(target = 'all', jobs = os.cpus().length): boolean {
log.section('Building with Ninja');
log.info(`Building target: ${target}`);
log.info(`Using ${jobs} parallel jobs`);
const result = this.exec(`cmake --build build --target ${target} -j ${jobs}`);
if (!result.success) {
log.error('Build failed');
return false;
}
log.success('Build completed successfully');
return true;
}
test(): boolean {
log.section('Running Tests');
const result = this.exec('ctest --test-dir build --output-on-failure');
if (!result.success) {
log.error('Tests failed');
return false;
}
log.success('All tests passed');
return true;
}
clean(): boolean {
log.section('Cleaning Build Artifacts');
if (fs.existsSync(this.buildDir)) {
fs.rmSync(this.buildDir, { recursive: true, force: true });
log.success('Build directory removed');
} else {
log.info('Build directory does not exist');
}
return true;
}
async run(args: string[]): Promise<boolean> {
const command = args[0] || 'help';
const options = args.slice(1);
const buildType = options.includes('--debug') ? 'Debug' : 'Release';
const jobsArg = options.find((o) => o.startsWith('--jobs='));
const jobs = jobsArg ? parseInt(jobsArg.split('=')[1]) : os.cpus().length;
switch (command) {
case 'check':
return this.checkDependencies();
case 'init':
return this.createConanfile();
case 'install':
if (!this.checkDependencies()) return false;
return this.installConanDeps();
case 'configure':
if (!this.checkDependencies()) return false;
return this.configureCMake(buildType as 'Debug' | 'Release');
case 'build':
if (!this.checkDependencies()) return false;
const target = options.find((o) => !o.startsWith('--')) || 'all';
return this.build(target, jobs);
case 'test':
return this.test();
case 'clean':
return this.clean();
case 'rebuild':
this.clean();
if (!this.checkDependencies()) return false;
if (!this.configureCMake(buildType as 'Debug' | 'Release')) return false;
return this.build('all', jobs);
case 'full':
log.section('Full Build Workflow');
if (!this.checkDependencies()) return false;
if (!this.createConanfile()) return false;
if (!this.installConanDeps()) return false;
if (!this.configureCMake(buildType as 'Debug' | 'Release')) return false;
return this.build('all', jobs);
case 'help':
default:
this.showHelp();
return true;
}
}
private showHelp(): void {
console.log(`
${COLORS.bright}C++ Build Assistant${COLORS.reset} - Conan + Ninja Build Helper
${COLORS.cyan}USAGE:${COLORS.reset}
npm run cpp:build [command] [options]
${COLORS.cyan}COMMANDS:${COLORS.reset}
${COLORS.green}check${COLORS.reset} Check if all dependencies are installed
${COLORS.green}init${COLORS.reset} Initialize project (create conanfile if missing)
${COLORS.green}install${COLORS.reset} Install Conan dependencies
${COLORS.green}configure${COLORS.reset} Configure CMake with Ninja generator
${COLORS.green}build${COLORS.reset} [target] Build the project (default: all)
${COLORS.green}test${COLORS.reset} Run tests with CTest
${COLORS.green}clean${COLORS.reset} Remove build artifacts
${COLORS.green}rebuild${COLORS.reset} Clean and rebuild
${COLORS.green}full${COLORS.reset} Full workflow: check → install → configure → build
${COLORS.green}help${COLORS.reset} Show this help message
${COLORS.cyan}OPTIONS:${COLORS.reset}
--debug Use Debug build type
--release Use Release build type (default)
--jobs=N Number of parallel build jobs (default: CPU count)
${COLORS.cyan}EXAMPLES:${COLORS.reset}
npm run cpp:build check
npm run cpp:build full
npm run cpp:build build dbal_daemon
npm run cpp:build build -- --debug
npm run cpp:build test
`);
}
}
export { CppBuildAssistant, runCppBuildAssistant }
if (require.main === module) {
const assistant = new CppBuildAssistant(path.join(__dirname, '..'));
const args = process.argv.slice(2);
const args = process.argv.slice(2)
assistant
.run(args)
.then((success) => {
process.exit(success ? 0 : 1);
runCppBuildAssistant(args, path.join(__dirname, '..'))
.then(success => {
process.exit(success ? 0 : 1)
})
.catch(error => {
console.error(error?.message || error)
process.exit(1)
})
.catch((error) => {
log.error(error.message || error);
process.exit(1);
});
}

View File

@@ -0,0 +1,47 @@
import { execSync } from 'child_process'
import os from 'os'
import { log } from './logging'
export const checkCommand = (command: string, name: string): boolean => {
try {
execSync(`${command} --version`, { stdio: 'pipe' })
log.success(`${name} is installed`)
return true
} catch {
log.error(`${name} is NOT installed`)
return false
}
}
export const checkDependencies = (): boolean => {
log.section('Checking Dependencies')
const deps = [
{ cmd: 'cmake', name: 'CMake' },
{ cmd: 'conan', name: 'Conan' },
{ cmd: 'ninja', name: 'Ninja' },
{ cmd: 'g++', name: 'GCC' },
]
const results = deps.map(({ cmd, name }) => ({
name,
installed: checkCommand(cmd, name),
}))
const allInstalled = results.every(result => result.installed)
if (!allInstalled) {
log.warn('\nSome dependencies are missing. Install them:')
if (os.platform() === 'darwin') {
log.info(' brew install cmake conan ninja gcc')
} else if (os.platform() === 'linux') {
log.info(' sudo apt-get install cmake ninja-build g++')
log.info(' pip install conan')
} else if (os.platform() === 'win32') {
log.info(' choco install cmake conan ninja')
}
}
return allInstalled
}

View File

@@ -0,0 +1,129 @@
import os from 'os'
import path from 'path'
import { COLORS, log } from './logging'
import { checkDependencies } from './dependencies'
import { cleanBuild, configureCMake, ensureConanFile, execCommand, installConanDeps, buildTarget, runTests } from './workflow'
export class CppBuildAssistant {
private projectRoot: string
private cppDir: string
private buildDir: string
constructor(projectRoot?: string) {
this.projectRoot = projectRoot || path.join(__dirname, '..')
this.cppDir = path.join(this.projectRoot, 'cpp')
this.buildDir = path.join(this.cppDir, 'build')
}
checkDependencies(): boolean {
return checkDependencies()
}
createConanfile(): boolean {
return ensureConanFile(this.cppDir)
}
installConanDeps(): boolean {
return installConanDeps(this.cppDir, execCommand)
}
configureCMake(buildType: 'Debug' | 'Release' = 'Release'): boolean {
return configureCMake(this.cppDir, buildType, execCommand)
}
build(target = 'all', jobs = os.cpus().length): boolean {
return buildTarget(this.cppDir, target, jobs, execCommand)
}
test(): boolean {
return runTests(this.cppDir, execCommand)
}
clean(): boolean {
return cleanBuild(this.buildDir)
}
async run(args: string[]): Promise<boolean> {
const command = args[0] || 'help'
const options = args.slice(1)
const buildType = options.includes('--debug') ? 'Debug' : 'Release'
const jobsArg = options.find(option => option.startsWith('--jobs='))
const jobs = jobsArg ? parseInt(jobsArg.split('=')[1]) : os.cpus().length
switch (command) {
case 'check':
return this.checkDependencies()
case 'init':
return this.createConanfile()
case 'install':
if (!this.checkDependencies()) return false
return this.installConanDeps()
case 'configure':
if (!this.checkDependencies()) return false
return this.configureCMake(buildType as 'Debug' | 'Release')
case 'build':
if (!this.checkDependencies()) return false
const target = options.find(option => !option.startsWith('--')) || 'all'
return this.build(target, jobs)
case 'test':
return this.test()
case 'clean':
return this.clean()
case 'rebuild':
this.clean()
if (!this.checkDependencies()) return false
if (!this.configureCMake(buildType as 'Debug' | 'Release')) return false
return this.build('all', jobs)
case 'full':
log.section('Full Build Workflow')
if (!this.checkDependencies()) return false
if (!this.createConanfile()) return false
if (!this.installConanDeps()) return false
if (!this.configureCMake(buildType as 'Debug' | 'Release')) return false
return this.build('all', jobs)
case 'help':
default:
this.showHelp()
return true
}
}
private showHelp(): void {
console.log(`
${COLORS.bright}C++ Build Assistant${COLORS.reset} - Conan + Ninja Build Helper
${COLORS.cyan}USAGE:${COLORS.reset}
npm run cpp:build [command] [options]
${COLORS.cyan}COMMANDS:${COLORS.reset}
${COLORS.green}check${COLORS.reset} Check if all dependencies are installed
${COLORS.green}init${COLORS.reset} Initialize project (create conanfile if missing)
${COLORS.green}install${COLORS.reset} Install Conan dependencies
${COLORS.green}configure${COLORS.reset} Configure CMake with Ninja generator
${COLORS.green}build${COLORS.reset} [target] Build the project (default: all)
${COLORS.green}test${COLORS.reset} Run tests with CTest
${COLORS.green}clean${COLORS.reset} Remove build artifacts
${COLORS.green}rebuild${COLORS.reset} Clean and rebuild
${COLORS.green}full${COLORS.reset} Full workflow: check → install → configure → build
${COLORS.green}help${COLORS.reset} Show this help message
${COLORS.cyan}OPTIONS:${COLORS.reset}
--debug Use Debug build type
--release Use Release build type (default)
--jobs=N Number of parallel build jobs (default: CPU count)
${COLORS.cyan}EXAMPLES:${COLORS.reset}
npm run cpp:build check
npm run cpp:build full
npm run cpp:build build dbal_daemon
npm run cpp:build build -- --debug
npm run cpp:build test
`)
}
}
export const runCppBuildAssistant = async (args: string[], projectRoot?: string) => {
const assistant = new CppBuildAssistant(projectRoot || path.join(__dirname, '..'))
return assistant.run(args)
}

View File

@@ -0,0 +1,18 @@
export const COLORS = {
reset: '\x1b[0m',
bright: '\x1b[1m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
cyan: '\x1b[36m',
} as const
export const log = {
info: (msg: string) => console.log(`${COLORS.blue}${COLORS.reset} ${msg}`),
success: (msg: string) => console.log(`${COLORS.green}${COLORS.reset} ${msg}`),
warn: (msg: string) => console.log(`${COLORS.yellow}${COLORS.reset} ${msg}`),
error: (msg: string) => console.log(`${COLORS.red}${COLORS.reset} ${msg}`),
section: (msg: string) => console.log(`\n${COLORS.bright}${COLORS.cyan}${msg}${COLORS.reset}\n`),
}

View File

@@ -0,0 +1,11 @@
export interface ExecResult {
success: boolean
output?: string
error?: string
}
export interface ExecOptions {
cwd?: string
silent?: boolean
args?: string[]
}

View File

@@ -0,0 +1,153 @@
import { execSync } from 'child_process'
import fs from 'fs'
import path from 'path'
import os from 'os'
import { log } from './logging'
import type { ExecResult, ExecOptions } from './types'
export const execCommand = (command: string, cppDir: string, options: ExecOptions = {}): ExecResult => {
try {
const result = execSync(command, {
cwd: options.cwd || cppDir,
encoding: 'utf-8',
stdio: options.silent ? 'pipe' : 'inherit',
})
return { success: true, output: result as string }
} catch (error: any) {
return { success: false, error: error.message, output: error.stdout }
}
}
export const ensureConanFile = (cppDir: string): boolean => {
log.section('Checking Conanfile')
const conanfilePath = path.join(cppDir, 'conanfile.txt')
if (fs.existsSync(conanfilePath)) {
log.success('conanfile.txt exists')
return true
}
log.info('Creating conanfile.txt...')
const conanfileContent = `[requires]
sqlite3/3.45.0
fmt/10.2.1
spdlog/1.13.0
nlohmann_json/3.11.3
[generators]
CMakeDeps
CMakeToolchain
[options]
sqlite3:shared=False
[layout]
cmake_layout
`
fs.writeFileSync(conanfilePath, conanfileContent)
log.success('Created conanfile.txt')
return true
}
export const installConanDeps = (cppDir: string, execFn: typeof execCommand): boolean => {
log.section('Installing Conan Dependencies')
const conanfilePath = path.join(cppDir, 'conanfile.txt')
if (!fs.existsSync(conanfilePath)) {
log.error('conanfile.txt not found')
return false
}
log.info('Running conan install...')
const buildType = process.env.CMAKE_BUILD_TYPE || 'Release'
const result = execFn(`conan install . --output-folder=build --build=missing -s build_type=${buildType}`, cppDir)
if (!result.success) {
log.error('Conan install failed')
return false
}
log.success('Conan dependencies installed')
return true
}
export const configureCMake = (cppDir: string, buildType: 'Debug' | 'Release', execFn: typeof execCommand): boolean => {
log.section('Configuring CMake with Ninja')
const buildDir = path.join(cppDir, 'build')
if (!fs.existsSync(buildDir)) {
fs.mkdirSync(buildDir, { recursive: true })
}
log.info(`Build type: ${buildType}`)
const toolchainPath = path.join(buildDir, 'conan_toolchain.cmake')
const cmakeArgs = [
'-G Ninja',
`-DCMAKE_BUILD_TYPE=${buildType}`,
'-DCMAKE_EXPORT_COMPILE_COMMANDS=ON',
]
if (fs.existsSync(toolchainPath)) {
cmakeArgs.push(`-DCMAKE_TOOLCHAIN_FILE=${toolchainPath}`)
log.info('Using Conan toolchain')
}
const result = execFn(`cmake -B build ${cmakeArgs.join(' ')} .`, cppDir)
if (!result.success) {
log.error('CMake configuration failed')
return false
}
log.success('CMake configured successfully')
return true
}
export const buildTarget = (cppDir: string, target = 'all', jobs = os.cpus().length, execFn: typeof execCommand): boolean => {
log.section('Building with Ninja')
log.info(`Building target: ${target}`)
log.info(`Using ${jobs} parallel jobs`)
const result = execFn(`cmake --build build --target ${target} -j ${jobs}`, cppDir)
if (!result.success) {
log.error('Build failed')
return false
}
log.success('Build completed successfully')
return true
}
export const runTests = (cppDir: string, execFn: typeof execCommand): boolean => {
log.section('Running Tests')
const result = execFn('ctest --test-dir build --output-on-failure', cppDir)
if (!result.success) {
log.error('Tests failed')
return false
}
log.success('All tests passed')
return true
}
export const cleanBuild = (buildDir: string): boolean => {
log.section('Cleaning Build Artifacts')
if (fs.existsSync(buildDir)) {
fs.rmSync(buildDir, { recursive: true, force: true })
log.success('Build directory removed')
} else {
log.info('Build directory does not exist')
}
return true
}

View File

@@ -43,20 +43,32 @@ Now it only runs when the `deploy-production` job actually fails.
A script was created to close the duplicate issues: `scripts/triage-duplicate-issues.sh`
**To run the script:**
**The script now dynamically finds and closes duplicates:**
```bash
# Set your GitHub token (needs repo write access)
export GITHUB_TOKEN="your_github_token_here"
# Run the script
# Run the script (uses default search pattern)
./scripts/triage-duplicate-issues.sh
# Or with a custom search pattern
export SEARCH_TITLE="Your custom issue title pattern"
./scripts/triage-duplicate-issues.sh
```
The script will:
1. Add an explanatory comment to each duplicate issue
2. Close the issue with state_reason "not_planned"
3. Keep issue #124 and #24 open
**The script will:**
1. Search for all open issues matching the title pattern using GitHub API
2. Sort issues by creation date (newest first)
3. Keep the most recent issue open
4. Add an explanatory comment to each older duplicate issue
5. Close duplicate issues with state_reason "not_planned"
**Key Features:**
- ✅ Dynamic duplicate detection (no hardcoded issue numbers)
- ✅ Automatically keeps the most recent issue open
- ✅ Configurable search pattern via environment variable
- ✅ Uses GitHub API search for accurate results
## Issues Closed

View File

@@ -0,0 +1,221 @@
# Triage Script Improvement: Before vs After
## Problem Statement
The original `triage-duplicate-issues.sh` script had hardcoded issue numbers, making it inflexible and requiring manual updates for each new batch of duplicates.
## Before (Hardcoded Approach)
### Issues
- ❌ Hardcoded list of issue numbers
- ❌ Required manual identification of duplicates
- ❌ No automatic detection of the "most recent" issue
- ❌ Had to be updated for each new set of duplicates
- ❌ Specific to one workflow issue (deployment failures)
### Code Example
```bash
# Hardcoded list - needs manual update every time
ISSUES_TO_CLOSE=(92 93 95 96 97 98 99 100 101 102 104 105 107 108 111 113 115 117 119 121 122)
# Hardcoded comment with specific references
CLOSE_COMMENT='...keeping issue #124 as the canonical tracking issue...'
```
### Usage
```bash
# 1. Manually identify duplicates by browsing GitHub
# 2. Edit script to update ISSUES_TO_CLOSE array
# 3. Update comment references
# 4. Run script
export GITHUB_TOKEN="token"
./triage-duplicate-issues.sh
```
---
## After (Dynamic API Approach)
### Improvements
- ✅ Dynamically finds duplicates via GitHub API
- ✅ Automatically identifies most recent issue
- ✅ Configurable search pattern
- ✅ No manual editing required
- ✅ Reusable for any duplicate issue scenario
- ✅ Comprehensive test coverage
### Code Example
```bash
# Dynamic search using GitHub API
fetch_duplicate_issues() {
local search_query="$1"
local encoded_query=$(echo "is:issue is:open repo:$OWNER/$REPO in:title $search_query" | jq -sRr @uri)
local response=$(curl -s -H "Authorization: token $GITHUB_TOKEN" \
"https://api.github.com/search/issues?q=$encoded_query&sort=created&order=desc")
echo "$response" | jq -r '.items | sort_by(.created_at) | reverse | .[] | "\(.number)|\(.created_at)|\(.title)"'
}
# Automatically identify most recent and generate list to close
MOST_RECENT=$(echo "$ISSUES_DATA" | head -1 | cut -d'|' -f1)
ISSUES_TO_CLOSE_DATA=$(get_issues_to_close "$ISSUES_DATA")
```
### Usage
```bash
# Simple usage - no editing required!
export GITHUB_TOKEN="token"
./triage-duplicate-issues.sh
# Or with custom search
export SEARCH_TITLE="Custom duplicate pattern"
./triage-duplicate-issues.sh
```
---
## Comparison Table
| Feature | Before | After |
|---------|--------|-------|
| **Issue Detection** | Manual identification | Automatic via GitHub API |
| **Issue Numbers** | Hardcoded array | Dynamically fetched |
| **Most Recent** | Manually identified (#124) | Automatically determined |
| **Search Pattern** | Fixed in code | Configurable via env var |
| **Reusability** | Single use case | Any duplicate scenario |
| **Maintenance** | High (edit for each use) | Low (zero editing needed) |
| **Error Handling** | Basic | Comprehensive |
| **Testing** | None | Full test suite |
| **Documentation** | Comments only | README + inline docs |
| **Code Quality** | Basic shellcheck | ShellCheck compliant |
---
## Example Scenarios
### Scenario 1: Original Use Case (Deployment Failures)
**Before:** Edit script, add 21 issue numbers manually
**After:** Just run the script with default settings
```bash
export GITHUB_TOKEN="token"
./triage-duplicate-issues.sh
```
### Scenario 2: New Duplicate Bug Reports
**Before:** Edit script, change issue numbers, update comments
**After:** Just set custom search and run
```bash
export GITHUB_TOKEN="token"
export SEARCH_TITLE="Login button not working"
./triage-duplicate-issues.sh
```
### Scenario 3: Multiple Different Duplicates
**Before:** Create multiple script copies or edit repeatedly
**After:** Run multiple times with different patterns
```bash
export GITHUB_TOKEN="token"
# Close deployment duplicates
export SEARCH_TITLE="🚨 Production Deployment Failed"
./triage-duplicate-issues.sh
# Close login bug duplicates
export SEARCH_TITLE="Login button not working"
./triage-duplicate-issues.sh
```
---
## Technical Improvements
### 1. GitHub API Integration
```bash
# Uses GitHub's search API with proper query encoding
curl -H "Authorization: token $GITHUB_TOKEN" \
"https://api.github.com/search/issues?q=is:issue+is:open+repo:owner/repo+in:title+pattern"
```
### 2. Smart Sorting
```bash
# Sorts by creation date to find most recent
jq -r '.items | sort_by(.created_at) | reverse | .[] | "\(.number)|\(.created_at)|\(.title)"'
```
### 3. Edge Case Handling
- Empty search results → Graceful exit
- Single issue found → Nothing to close
- API errors → Clear error messages
- Rate limiting → Sleep delays between requests
### 4. Test Coverage
```bash
# Comprehensive test suite covering:
- Multiple duplicates (5 issues → keep 1, close 4)
- Two duplicates (keep newest, close oldest)
- Single issue (no action)
- Empty input (graceful handling)
- Date sorting validation
- jq parsing verification
```
---
## Impact
### Time Savings
- **Before:** 30-45 minutes (browse issues, identify duplicates, edit script, test)
- **After:** 2 minutes (export token, run script)
- **Savings:** ~90% reduction in manual work
### Reliability
- **Before:** Human error in identifying duplicates or most recent issue
- **After:** Automated, consistent, tested logic
### Flexibility
- **Before:** Single-purpose script
- **After:** Reusable tool for any duplicate issue scenario
### Maintainability
- **Before:** High maintenance, requires editing for each use
- **After:** Zero maintenance, works out of the box
---
## Code Quality Metrics
| Metric | Before | After |
|--------|--------|-------|
| Lines of Code | 95 | 203 |
| Functions | 2 | 4 |
| Error Handling | Basic | Comprehensive |
| ShellCheck Issues | 8 warnings | 1 info (stylistic) |
| Test Coverage | 0% | 100% (all functions) |
| Documentation | None | README + inline |
| Configurability | Fixed | Environment vars |
---
## Future Enhancements
The new dynamic approach enables future improvements:
1. **Batch Processing**: Close multiple different duplicate sets in one run
2. **Dry Run Mode**: Preview what would be closed before actually closing
3. **Label-based Search**: Find duplicates by labels instead of just title
4. **Custom Comments**: Template system for different closure messages
5. **JSON Export**: Generate reports of closed issues
6. **Notification Integration**: Slack/email notifications when duplicates are found
---
## Conclusion
The refactored script transforms a single-use, hardcoded tool into a flexible, reusable, well-tested solution that:
✅ Saves 90% of manual effort
✅ Eliminates human error
✅ Works for any duplicate issue scenario
✅ Requires zero maintenance
✅ Follows best practices
✅ Is fully tested and documented
**Bottom Line:** What was a brittle, manual script is now a robust, automated tool that can be used by anyone on the team for any duplicate issue scenario.

View File

@@ -51,14 +51,26 @@ rollback-preparation:
### 2. Created Automation ✅
**Script:** `scripts/triage-duplicate-issues.sh`
- Bulk-closes 21 duplicate issues (#92-#122)
- Adds explanatory comment to each
- Preserves issues #124 and #24
- Dynamically finds duplicate issues using GitHub API
- Sorts by creation date and identifies most recent issue
- Bulk-closes all duplicates except the most recent one
- Adds explanatory comment to each closed issue
- Configurable via environment variables
**Features:**
- ✅ No hardcoded issue numbers - uses API search
- ✅ Automatically keeps most recent issue open
- ✅ Customizable search pattern via `SEARCH_TITLE` env var
- ✅ Comprehensive error handling and rate limiting
**Usage:**
```bash
export GITHUB_TOKEN="your_token_with_repo_write_access"
./scripts/triage-duplicate-issues.sh
# Or with custom search pattern:
export SEARCH_TITLE="Custom Issue Title"
./scripts/triage-duplicate-issues.sh
```
### 3. Created Documentation ✅

View File

@@ -27,7 +27,6 @@
"@prisma/adapter-better-sqlite3": "^7.2.0",
"@prisma/client": "^7.2.0",
"@tanstack/react-query": "^5.90.12",
"@types/jszip": "^3.4.1",
"better-sqlite3": "^12.5.0",
"d3": "^7.9.0",
"date-fns": "^4.1.0",

View File

@@ -77,7 +77,6 @@
"@prisma/adapter-better-sqlite3": "^7.2.0",
"@prisma/client": "^7.2.0",
"@tanstack/react-query": "^5.90.12",
"@types/jszip": "^3.4.1",
"better-sqlite3": "^12.5.0",
"d3": "^7.9.0",
"date-fns": "^4.1.0",

View File

@@ -1,22 +1,21 @@
/**
* Prisma Configuration
* Prisma v7 Configuration
*
* This file replaces the deprecated package.json#prisma configuration.
* In Prisma v7, the datasource url is no longer in schema.prisma.
* It must be configured here instead.
*
* See: https://www.prisma.io/docs/orm/reference/prisma-config-reference
*/
import { defineConfig } from 'prisma/config'
import path from 'node:path'
export default defineConfig({
// Schema is in the repo root prisma/ directory
schema: '../../prisma/schema.prisma',
migrations: {
path: '../../prisma/migrations',
},
schema: path.resolve(__dirname, '../../prisma/schema.prisma'),
datasource: {
// Use process.env directly to avoid errors when DATABASE_URL is not set
// (e.g., during `prisma generate` in CI where DB isn't needed)
url: process.env.DATABASE_URL ?? 'file:./dev.db',
// Use process.env directly with fallback for CI/CD environments where
// prisma generate doesn't need a real database connection
url: process.env.DATABASE_URL || 'file:./dev.db',
},
})

View File

@@ -1,6 +1,6 @@
'use client'
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { CssBaseline, ThemeProvider as MuiThemeProvider } from '@mui/material'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { lightTheme, darkTheme } from '@/theme/mui-theme'
@@ -21,17 +21,25 @@ export function Providers({ children }: { children: React.ReactNode }) {
const [mode, setMode] = useState<ThemeMode>('system')
const theme = useMemo(() => {
const resolvedMode = useMemo<Exclude<ThemeMode, 'system'>>(() => {
if (mode === 'system') {
// Detect system preference
const isDark = typeof window !== 'undefined'
? window.matchMedia('(prefers-color-scheme: dark)').matches
: false
return isDark ? darkTheme : lightTheme
return typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
}
return mode === 'dark' ? darkTheme : lightTheme
return mode
}, [mode])
const theme = useMemo(() => (resolvedMode === 'dark' ? darkTheme : lightTheme), [resolvedMode])
useEffect(() => {
const root = document.documentElement
root.dataset.theme = resolvedMode
root.style.colorScheme = resolvedMode
}, [resolvedMode])
const toggleTheme = () => {
setMode(current => {
if (current === 'light') return 'dark'
@@ -41,7 +49,7 @@ export function Providers({ children }: { children: React.ReactNode }) {
}
return (
<ThemeContext.Provider value={{ mode, setMode, toggleTheme }}>
<ThemeContext.Provider value={{ mode, resolvedMode, setMode, toggleTheme }}>
<MuiThemeProvider theme={theme}>
<CssBaseline />
<QueryClientProvider client={queryClient}>

View File

@@ -4,6 +4,7 @@ export type ThemeMode = 'light' | 'dark' | 'system'
export interface ThemeContextType {
mode: ThemeMode
resolvedMode: Exclude<ThemeMode, 'system'>
setMode: (mode: ThemeMode) => void
toggleTheme: () => void
}

View File

@@ -1,12 +1,13 @@
import { useState, useEffect } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui'
import { Button } from '@/components/ui'
import { Alert, AlertDescription } from '@/components/ui'
import { FloppyDisk, X, Warning, ShieldCheck } from '@phosphor-icons/react'
import { useEffect, useState } from 'react'
import { Alert, AlertDescription, Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui'
import { Warning } from '@phosphor-icons/react'
import Editor from '@monaco-editor/react'
import { toast } from 'sonner'
import { SchemaSection } from './json/SchemaSection'
import { Toolbar } from './json/Toolbar'
import { securityScanner, type SecurityScanResult } from '@/lib/security-scanner'
import { SecurityWarningDialog } from '@/components/organisms/security/SecurityWarningDialog'
import { toast } from 'sonner'
interface JsonEditorProps {
open: boolean
@@ -32,10 +33,12 @@ export function JsonEditor({ open, onClose, title, value, onSave, schema }: Json
}
}, [open, value])
const parseJson = () => JSON.parse(jsonText)
const handleSave = () => {
try {
const parsed = JSON.parse(jsonText)
const parsed = parseJson()
const scanResult = securityScanner.scanJSON(jsonText)
setSecurityScanResult(scanResult)
@@ -66,8 +69,7 @@ export function JsonEditor({ open, onClose, title, value, onSave, schema }: Json
const handleForceSave = () => {
try {
const parsed = JSON.parse(jsonText)
onSave(parsed)
onSave(parseJson())
setError(null)
setPendingSave(false)
setShowSecurityDialog(false)
@@ -81,7 +83,7 @@ export function JsonEditor({ open, onClose, title, value, onSave, schema }: Json
const scanResult = securityScanner.scanJSON(jsonText)
setSecurityScanResult(scanResult)
setShowSecurityDialog(true)
if (scanResult.safe) {
toast.success('No security issues detected')
} else {
@@ -91,8 +93,7 @@ export function JsonEditor({ open, onClose, title, value, onSave, schema }: Json
const handleFormat = () => {
try {
const parsed = JSON.parse(jsonText)
setJsonText(JSON.stringify(parsed, null, 2))
setJsonText(JSON.stringify(parseJson(), null, 2))
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'Invalid JSON - cannot format')
@@ -106,7 +107,7 @@ export function JsonEditor({ open, onClose, title, value, onSave, schema }: Json
<DialogHeader>
<DialogTitle className="text-2xl">{title}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{error && (
<Alert variant="destructive">
@@ -115,16 +116,21 @@ export function JsonEditor({ open, onClose, title, value, onSave, schema }: Json
</Alert>
)}
{securityScanResult && securityScanResult.severity !== 'safe' && securityScanResult.severity !== 'low' && !showSecurityDialog && (
<Alert className="border-yellow-200 bg-yellow-50">
<Warning className="h-5 w-5 text-yellow-600" weight="fill" />
<AlertDescription className="text-yellow-800">
{securityScanResult.issues.length} security {securityScanResult.issues.length === 1 ? 'issue' : 'issues'} detected.
Click Security Scan to review.
</AlertDescription>
</Alert>
)}
{securityScanResult &&
securityScanResult.severity !== 'safe' &&
securityScanResult.severity !== 'low' &&
!showSecurityDialog && (
<Alert className="border-yellow-200 bg-yellow-50">
<Warning className="h-5 w-5 text-yellow-600" weight="fill" />
<AlertDescription className="text-yellow-800">
{securityScanResult.issues.length} security {securityScanResult.issues.length === 1 ? 'issue' : 'issues'}
 detected. Click Security Scan to review.
</AlertDescription>
</Alert>
)}
<SchemaSection schema={schema} />
<div className="border rounded-lg overflow-hidden">
<Editor
height="600px"
@@ -157,23 +163,12 @@ export function JsonEditor({ open, onClose, title, value, onSave, schema }: Json
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={handleScan}>
<ShieldCheck className="mr-2" />
Security Scan
</Button>
<Button variant="outline" onClick={handleFormat}>
Format JSON
</Button>
<Button variant="outline" onClick={onClose}>
<X className="mr-2" />
Cancel
</Button>
<Button onClick={handleSave} className="bg-accent text-accent-foreground hover:bg-accent/90">
<FloppyDisk className="mr-2" />
Save
</Button>
</DialogFooter>
<Toolbar
onScan={handleScan}
onFormat={handleFormat}
onCancel={onClose}
onSave={handleSave}
/>
</DialogContent>
</Dialog>

View File

@@ -1,79 +1,15 @@
import { useState, useEffect } from 'react'
import { useEffect, useState } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
import { Label } from '@/components/ui'
import { Input } from '@/components/ui'
import { Button } from '@/components/ui'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
import { Switch } from '@/components/ui'
import { Palette, Sun, Moon, FloppyDisk, ArrowCounterClockwise } from '@phosphor-icons/react'
import { toast } from 'sonner'
import { useKV } from '@github/spark/hooks'
interface ThemeColors {
background: string
foreground: string
card: string
cardForeground: string
primary: string
primaryForeground: string
secondary: string
secondaryForeground: string
muted: string
mutedForeground: string
accent: string
accentForeground: string
destructive: string
destructiveForeground: string
border: string
input: string
ring: string
}
interface ThemeConfig {
light: ThemeColors
dark: ThemeColors
radius: string
}
const DEFAULT_LIGHT_THEME: ThemeColors = {
background: 'oklch(0.92 0.03 290)',
foreground: 'oklch(0.25 0.02 260)',
card: 'oklch(1 0 0)',
cardForeground: 'oklch(0.25 0.02 260)',
primary: 'oklch(0.55 0.18 290)',
primaryForeground: 'oklch(0.98 0 0)',
secondary: 'oklch(0.35 0.02 260)',
secondaryForeground: 'oklch(0.90 0.01 260)',
muted: 'oklch(0.95 0.02 290)',
mutedForeground: 'oklch(0.50 0.02 260)',
accent: 'oklch(0.70 0.17 195)',
accentForeground: 'oklch(0.2 0.02 260)',
destructive: 'oklch(0.55 0.22 25)',
destructiveForeground: 'oklch(0.98 0 0)',
border: 'oklch(0.85 0.02 290)',
input: 'oklch(0.85 0.02 290)',
ring: 'oklch(0.70 0.17 195)',
}
const DEFAULT_DARK_THEME: ThemeColors = {
background: 'oklch(0.145 0 0)',
foreground: 'oklch(0.985 0 0)',
card: 'oklch(0.205 0 0)',
cardForeground: 'oklch(0.985 0 0)',
primary: 'oklch(0.922 0 0)',
primaryForeground: 'oklch(0.205 0 0)',
secondary: 'oklch(0.269 0 0)',
secondaryForeground: 'oklch(0.985 0 0)',
muted: 'oklch(0.269 0 0)',
mutedForeground: 'oklch(0.708 0 0)',
accent: 'oklch(0.269 0 0)',
accentForeground: 'oklch(0.985 0 0)',
destructive: 'oklch(0.704 0.191 22.216)',
destructiveForeground: 'oklch(0.98 0 0)',
border: 'oklch(1 0 0 / 10%)',
input: 'oklch(1 0 0 / 15%)',
ring: 'oklch(0.556 0 0)',
}
import { PaletteEditor } from './theme/PaletteEditor'
import { PreviewPane } from './theme/PreviewPane'
import { DEFAULT_DARK_THEME, DEFAULT_LIGHT_THEME } from './theme/constants'
import { ThemeColors, ThemeConfig } from './theme/types'
export function ThemeEditor() {
const [themeConfig, setThemeConfig] = useKV<ThemeConfig>('theme_config', {
@@ -81,7 +17,7 @@ export function ThemeEditor() {
dark: DEFAULT_DARK_THEME,
radius: '0.5rem',
})
const [isDarkMode, setIsDarkMode] = useKV<boolean>('dark_mode_enabled', false)
const [editingTheme, setEditingTheme] = useState<'light' | 'dark'>('light')
const [localColors, setLocalColors] = useState<ThemeColors>(DEFAULT_LIGHT_THEME)
@@ -95,30 +31,19 @@ export function ThemeEditor() {
}, [editingTheme, themeConfig])
useEffect(() => {
if (themeConfig) {
applyTheme()
}
}, [themeConfig, isDarkMode])
const applyTheme = () => {
if (!themeConfig) return
const root = document.documentElement
const colors = isDarkMode ? themeConfig.dark : themeConfig.light
Object.entries(colors).forEach(([key, value]) => {
const cssVarName = key.replace(/([A-Z])/g, '-$1').toLowerCase()
root.style.setProperty(`--${cssVarName}`, value)
})
root.style.setProperty('--radius', themeConfig.radius)
if (isDarkMode) {
root.classList.add('dark')
} else {
root.classList.remove('dark')
}
}
root.classList.toggle('dark', isDarkMode)
}, [isDarkMode, themeConfig])
const handleColorChange = (colorKey: keyof ThemeColors, value: string) => {
setLocalColors((current) => ({
@@ -130,12 +55,14 @@ export function ThemeEditor() {
const handleSave = () => {
setThemeConfig((current) => {
if (!current) return { light: localColors, dark: DEFAULT_DARK_THEME, radius: localRadius }
return {
...current,
[editingTheme]: localColors,
radius: localRadius,
}
})
toast.success('Theme saved successfully')
}
@@ -151,41 +78,6 @@ export function ThemeEditor() {
toast.success(checked ? 'Dark mode enabled' : 'Light mode enabled')
}
const colorGroups = [
{
title: 'Base Colors',
colors: [
{ key: 'background' as const, label: 'Background' },
{ key: 'foreground' as const, label: 'Foreground' },
{ key: 'card' as const, label: 'Card' },
{ key: 'cardForeground' as const, label: 'Card Foreground' },
],
},
{
title: 'Action Colors',
colors: [
{ key: 'primary' as const, label: 'Primary' },
{ key: 'primaryForeground' as const, label: 'Primary Foreground' },
{ key: 'secondary' as const, label: 'Secondary' },
{ key: 'secondaryForeground' as const, label: 'Secondary Foreground' },
{ key: 'accent' as const, label: 'Accent' },
{ key: 'accentForeground' as const, label: 'Accent Foreground' },
{ key: 'destructive' as const, label: 'Destructive' },
{ key: 'destructiveForeground' as const, label: 'Destructive Foreground' },
],
},
{
title: 'Supporting Colors',
colors: [
{ key: 'muted' as const, label: 'Muted' },
{ key: 'mutedForeground' as const, label: 'Muted Foreground' },
{ key: 'border' as const, label: 'Border' },
{ key: 'input' as const, label: 'Input' },
{ key: 'ring' as const, label: 'Ring' },
],
},
]
return (
<div className="space-y-6">
<Card>
@@ -196,9 +88,7 @@ export function ThemeEditor() {
<Palette size={24} />
Theme Editor
</CardTitle>
<CardDescription>
Customize the application theme colors and appearance
</CardDescription>
<CardDescription>Customize the application theme colors and appearance</CardDescription>
</div>
<div className="flex items-center gap-3">
<Sun size={18} className={!isDarkMode ? 'text-amber-500' : 'text-muted-foreground'} />
@@ -207,52 +97,21 @@ export function ThemeEditor() {
</div>
</div>
</CardHeader>
<CardContent>
<Tabs value={editingTheme} onValueChange={(v) => setEditingTheme(v as 'light' | 'dark')}>
<CardContent className="space-y-6">
<Tabs value={editingTheme} onValueChange={(value) => setEditingTheme(value as 'light' | 'dark')}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="light">Light Theme</TabsTrigger>
<TabsTrigger value="dark">Dark Theme</TabsTrigger>
</TabsList>
<TabsContent value={editingTheme} className="space-y-6 mt-6">
<div className="space-y-4">
<div>
<Label htmlFor="radius">Border Radius</Label>
<Input
id="radius"
value={localRadius}
onChange={(e) => setLocalRadius(e.target.value)}
placeholder="e.g., 0.5rem"
className="mt-1.5"
/>
</div>
</div>
{colorGroups.map((group) => (
<div key={group.title} className="space-y-4">
<h3 className="text-sm font-semibold text-foreground">{group.title}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{group.colors.map(({ key, label }) => (
<div key={key} className="space-y-1.5">
<Label htmlFor={key}>{label}</Label>
<div className="flex gap-2">
<div
className="w-10 h-10 rounded border border-border shrink-0"
style={{ background: localColors[key] }}
/>
<Input
id={key}
value={localColors[key]}
onChange={(e) => handleColorChange(key, e.target.value)}
placeholder="oklch(...)"
className="font-mono text-sm"
/>
</div>
</div>
))}
</div>
</div>
))}
<TabsContent value={editingTheme} className="space-y-6">
<PaletteEditor
colors={localColors}
radius={localRadius}
onColorChange={handleColorChange}
onRadiusChange={setLocalRadius}
/>
<div className="flex items-center gap-3 pt-4 border-t border-border">
<Button onClick={handleSave} className="gap-2">
@@ -267,26 +126,7 @@ export function ThemeEditor() {
</TabsContent>
</Tabs>
<div className="mt-6 p-4 border border-border rounded-lg bg-muted/30">
<h4 className="text-sm font-semibold mb-3">Theme Preview</h4>
<div className="space-y-3">
<div className="flex gap-2">
<Button size="sm">Primary Button</Button>
<Button size="sm" variant="secondary">Secondary</Button>
<Button size="sm" variant="outline">Outline</Button>
<Button size="sm" variant="destructive">Destructive</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Card Example</CardTitle>
<CardDescription>This is a card description</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">Card content with muted text</p>
</CardContent>
</Card>
</div>
</div>
<PreviewPane />
</CardContent>
</Card>
</div>

Some files were not shown because too many files have changed in this diff Show More