Compare commits

..

9 Commits

Author SHA1 Message Date
github-actions[bot]
60c86964df Add release notes for PR #7861 2026-05-16 15:01:51 +01:00
github-actions[bot]
0c12852e5a [AI] sync-server.Dockerfile: unbreak yarn build:server after lage migration
After #7602 moved the browser build to lage, the root sync-server.Dockerfile
broke in two ways:

1. lage.config.js was never copied into the deps stage, so `lage` had no
   pipeline config inside the image.
2. lage's task hasher invokes `git ls-tree HEAD` during initialization (even
   for targets with `cache: false`), but .dockerignore strips `.git`, so the
   build aborts before vite ever runs.

Copy lage.config.js alongside the other root config files, and seed a
throwaway single-commit git repo in the builder stage right before
`yarn build:server` so lage's hasher has a HEAD to read.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:55:50 +01:00
Matt Fiddaman
9b19cd2616 automation UI: various tweaks and fixes (#7832)
* warning when only a balance cap is used

* add tooltip for short descriptions

* fix parsing issue with template 0 up to templates

* preselect value to make deletion easier

* note

* fix balance cap note

* fix tests

* remove recurring

* Goal/Automation wording
2026-05-15 22:13:30 +00:00
Matiss Janis Aboltins
1f101077d6 [AI] zizmor: add environment references to secret-consuming workflows (#7856)
* [AI] Reference dedicated environments for workflows using secrets

Assigns each secret-consuming workflow to a dedicated GitHub
environment so zizmor's secrets-without-environment audit passes:

- ai-generated-release-notes.yml -> ai-release-notes
- docs-spelling.yml (update job) -> docs-spelling
- i18n-string-extract-master.yml -> i18n
- release-notes.yml -> pr-automation
- vrt-update-apply.yml -> pr-automation

* [AI] Rename release notes file to match PR #7856

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-15 21:45:12 +00:00
Matiss Janis Aboltins
62d7c0e479 [AI] zizmor: fix template injection in setup action's Lage cache step (#7858)
* [AI] Fix template injection in setup action's Lage cache step

The 'Ensure Lage cache directory exists' step expanded
${{ inputs.working-directory }} directly into the shell command via
format(), which zizmor flags as a code-injection risk. Pass the input
through an env var and reference it with shell expansion instead.

* [AI] Add release note for template injection fix

* [AI] Rename release note to match PR #7858

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-15 21:34:22 +00:00
dependabot[bot]
740392941d Bump qs from 6.13.0 to 6.15.1 (#7857)
Bumps [qs](https://github.com/ljharb/qs) from 6.13.0 to 6.15.1.
- [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/qs/compare/v6.13.0...v6.15.1)

---
updated-dependencies:
- dependency-name: qs
  dependency-version: 6.15.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-15 20:53:31 +00:00
dependabot[bot]
d4528e18ea Bump lodash-es from 4.17.21 to 4.18.1 (#7854)
Bumps [lodash-es](https://github.com/lodash/lodash) from 4.17.21 to 4.18.1.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.21...4.18.1)

---
updated-dependencies:
- dependency-name: lodash-es
  dependency-version: 4.18.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-15 20:23:31 +00:00
Matiss Janis Aboltins
3d47eae87b [AI] Replace GitHub Actions with native gh CLI commands (#7852)
* [AI] Replace superfluous actions flagged by zizmor

Address zizmor's `superfluous-actions` audit by replacing actions whose
functionality is already provided by the runner's pre-installed `gh` CLI:

- `actions-ecosystem/action-add-labels` -> `gh issue edit --add-label`
- `peter-evans/create-or-update-comment` -> `gh issue comment`
- `softprops/action-gh-release` -> `gh release create` / `gh release upload`

For the Electron release workflow, the create step is race-safe across
the three matrix OS jobs that share the same draft release.

* [AI] Simplify electron release upload script

- Drop the `gh release view` existence check; `gh release create ... || true`
  already handles the matrix-job race against the same draft release.
- Use `extglob` to exclude `Actual-windows.exe` inline instead of looping
  over `.exe` separately.

* Add release notes for PR #7852

* [AI] Narrow error suppression on gh release create

Only swallow the "already_exists" error from the parallel-matrix race;
propagate any other failure (auth, network, API) instead of masking it.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-15 19:51:40 +00:00
Sebastián Maluk
90a1e9bdd3 [AI] Color balance forecast line by zero crossing (#7850)
* [AI] Color forecast line by zero crossing

* [AI] Add release note for forecast line coloring
2026-05-15 17:50:40 +00:00
51 changed files with 318 additions and 704 deletions

View File

@@ -39,8 +39,10 @@ runs:
path: ${{ format('{0}/**/node_modules', inputs.working-directory) }}
key: yarn-v1-${{ runner.os }}-${{ steps.get-node.outputs.version }}-${{ hashFiles(format('{0}/**/yarn.lock', inputs.working-directory)) }}
- name: Ensure Lage cache directory exists
run: mkdir -p ${{ format('{0}/.lage', inputs.working-directory) }}
run: mkdir -p "$WORKING_DIRECTORY/.lage"
shell: bash
env:
WORKING_DIRECTORY: ${{ inputs.working-directory }}
- name: Cache Lage
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
if: ${{ inputs.cache == 'true' }}

View File

@@ -9,6 +9,7 @@ jobs:
# Only run on PR comments from CodeRabbit bot
if: github.event.issue.pull_request && github.event.comment.user.login == 'coderabbitai[bot]'
runs-on: ubuntu-latest
environment: ai-release-notes
timeout-minutes: 10
permissions:
contents: write

View File

@@ -146,6 +146,7 @@ jobs:
pull-requests: write
actions: read
runs-on: ubuntu-latest
environment: docs-spelling
if: ${{
github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&

View File

@@ -100,10 +100,11 @@ jobs:
path: |
packages/desktop-electron/dist/*.appx
- name: Add to new release
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
with:
draft: true
body: |
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }}
RELEASE_NOTES: |
:link: [View release notes](https://actualbudget.org/blog/release-${{ steps.process_version.outputs.version }})
## Desktop releases
@@ -114,13 +115,27 @@ jobs:
<img src="data:image/gif;base64,R0lGODlhAQABAAAAACw=" width="12" height="1" alt="" />
<a href="https://flathub.org/apps/com.actualbudget.actual"><img width="165" style="margin-left:12px;" alt="Get it on Flathub" src="https://flathub.org/api/badge?locale=en" /></a>
</p>
files: |
run: |
# The matrix runs three OS jobs in parallel against one release;
# only ignore the "already exists" error that the race losers hit.
if ! create_output=$(gh release create "$TAG" --draft --title "$TAG" --notes "$RELEASE_NOTES" 2>&1); then
if [[ "$create_output" != *already_exists* ]]; then
echo "$create_output" >&2
exit 1
fi
fi
shopt -s extglob nullglob
files=(
packages/desktop-electron/dist/*.dmg
packages/desktop-electron/dist/*.exe
!packages/desktop-electron/dist/Actual-windows.exe
packages/desktop-electron/dist/!(Actual-windows).exe
packages/desktop-electron/dist/*.AppImage
packages/desktop-electron/dist/*.flatpak
packages/desktop-electron/dist/*.appx
)
if [ ${#files[@]} -gt 0 ]; then
gh release upload "$TAG" --clobber "${files[@]}"
fi
outputs:
version: ${{ steps.process_version.outputs.version }}

View File

@@ -12,6 +12,7 @@ permissions:
jobs:
extract-and-upload-i18n-strings:
runs-on: ubuntu-latest
environment: i18n
if: github.repository == 'actualbudget/actual'
steps:
- name: Check out main repository

View File

@@ -11,21 +11,21 @@ jobs:
needs-votes:
if: ${{ github.event.label.name == 'feature' }}
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
steps:
- uses: actions-ecosystem/action-add-labels@bd52874380e3909a1ac983768df6976535ece7f8 # v1.1.0
with:
labels: needs votes
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Add needs votes label
run: gh issue edit "$ISSUE_NUMBER" --add-label "needs votes"
- name: Add reactions
uses: aidan-mundy/react-to-issue@109392cac5159c2df6c47c8ab3b5d6b708852fe5 # v1.1.2
with:
issue-number: ${{ github.event.issue.number }}
reactions: '+1'
- name: Create comment
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
with:
issue-number: ${{ github.event.issue.number }}
body: |
env:
COMMENT_BODY: |
:sparkles: Thanks for sharing your idea! :sparkles:
This repository uses a voting-based system for feature requests. While enhancement issues are automatically closed, we still welcome feature requests! The voting system helps us gauge community interest in potential features. We also encourage community contributions for any feature requests marked as needing votes (just post a comment first so we can help guide you toward a successful contribution).
@@ -35,7 +35,6 @@ jobs:
Don't forget to upvote the top comment with 👍!
<!-- feature-auto-close-comment -->
run: gh issue comment "$ISSUE_NUMBER" --body "$COMMENT_BODY"
- name: Close Issue
run: gh issue close "https://github.com/actualbudget/actual/issues/${{ github.event.issue.number }}"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh issue close "$ISSUE_NUMBER"

View File

@@ -14,6 +14,7 @@ concurrency:
jobs:
release-notes:
runs-on: ubuntu-latest
environment: pr-automation
steps:
- name: Check if triggered by bot
id: bot-check

View File

@@ -16,6 +16,7 @@ jobs:
apply-vrt-updates:
name: Apply VRT Updates
runs-on: ubuntu-latest
environment: pr-automation
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- name: Download patch artifact

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 83 KiB

View File

@@ -193,7 +193,7 @@ export function BalanceWithCarryover({
<div>
{
{
type: longGoalValue === 1 ? t('Long') : t('Template'),
type: longGoalValue === 1 ? t('Goal') : t('Automation'),
} as TransObjectLiteral
}
</div>

View File

@@ -19,6 +19,8 @@ export function AutomationErrorTitle({
return <Trans>Schedule not found</Trans>;
case 'refill-no-cap':
return <Trans>Refill needs a balance cap</Trans>;
case 'limit-no-contributor':
return <Trans>Balance cap needs a contributing automation</Trans>;
case 'percentage-out-of-range':
return <Trans>Percentage out of range</Trans>;
case 'percentage-no-source':
@@ -53,7 +55,9 @@ export function AutomationErrorShort({
<Trans>Pick a schedule</Trans>
);
case 'refill-no-cap':
return <Trans>Add a balance cap above</Trans>;
return <Trans>Add a balance cap</Trans>;
case 'limit-no-contributor':
return <Trans>Add an automation that contributes funds</Trans>;
case 'percentage-out-of-range':
return (
<Trans>{{ percent: error.percent }}% must be between 0 and 100</Trans>
@@ -100,6 +104,14 @@ export function AutomationErrorDetail({
added to use as the target.
</Trans>
);
case 'limit-no-contributor':
return (
<Trans>
A balance cap on its own does nothing. Add a contributing automation
(such as a fixed amount, save by date, or whatever is left) so the cap
has something to clamp.
</Trans>
);
case 'percentage-out-of-range':
return <Trans>Set a value greater than 0% and at most 100%.</Trans>;
case 'percentage-no-source':

View File

@@ -38,7 +38,7 @@ export function getDisplayTemplateMeta(
case 'schedule':
return {
label: t('Cover schedule'),
description: t('Save up for a recurring scheduled transaction.'),
description: t('Save up for a scheduled transaction.'),
icon: SvgCalendar3,
};
case 'by':

View File

@@ -7,6 +7,7 @@ import type { DisplayTemplateType } from './constants';
export type AutomationErrorKind =
| { kind: 'schedule-not-found'; name: string }
| { kind: 'refill-no-cap' }
| { kind: 'limit-no-contributor' }
| { kind: 'percentage-out-of-range'; percent: number }
| { kind: 'percentage-no-source' }
| { kind: 'percentage-source-not-found'; source: string }
@@ -48,6 +49,15 @@ export function validateAutomation(
return { kind: 'refill-no-cap' };
}
return null;
case 'limit':
if (
!allTemplates.some(
t => t.type !== 'limit' && t.type !== 'goal' && t.type !== 'error',
)
) {
return { kind: 'limit-no-contributor' };
}
return null;
case 'percentage':
if (template.type !== 'percentage') return null;
if (!template.category) return { kind: 'percentage-no-source' };

View File

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { SvgAlertTriangle } from '@actual-app/components/icons/v2';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { Tooltip } from '@actual-app/components/tooltip';
import { View } from '@actual-app/components/view';
import type { AutomationEntry } from '#components/budget/goals/automationExamples';
@@ -126,18 +127,24 @@ export function AutomationListRow({
/>
)}
</View>
<Text
style={{
fontSize: 11,
color: subtitleColor,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'block',
}}
<Tooltip
content={
<Text style={{ display: 'block', maxWidth: 320 }}>{subtitle}</Text>
}
>
{subtitle}
</Text>
<Text
style={{
fontSize: 11,
color: subtitleColor,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'block',
}}
>
{subtitle}
</Text>
</Tooltip>
</View>
{!NON_CONTRIBUTION_TYPES.has(entry.displayType) && (
<View

View File

@@ -100,6 +100,31 @@ describe('migrateTemplatesToAutomations', () => {
});
});
it('expands `#template 0 up to N` into limit + fixed-zero (not refill)', () => {
const simpleTemplate = {
type: 'simple',
directive: 'template',
priority: 4,
monthly: 0,
limit: {
amount: 1000,
hold: false,
period: 'monthly',
},
} satisfies Template;
const result = migrateTemplatesToAutomations([simpleTemplate]);
expect(result).toHaveLength(2);
expect(result.map(entry => entry.displayType)).toEqual(['limit', 'fixed']);
expect(result[1].template).toMatchObject({
type: 'periodic',
amount: 0,
directive: 'template',
priority: 4,
});
});
it('expands a simple template with both limit and monthly into limit + periodic (no implicit refill)', () => {
// `#template 20 up to 200 per week` budgets 20/month and caps at the
// limit — the engine's runSimple returns just the monthly value, so

View File

@@ -47,7 +47,8 @@ export function migrateTemplatesToAutomations(
templates.forEach(template => {
if (template.type === 'simple') {
const monthly = template.monthly;
const hasMonthly = monthly != null && monthly !== 0;
const hasMonthly =
monthly != null && (monthly !== 0 || template.limit != null);
if (template.limit) {
entries.push(
@@ -64,10 +65,7 @@ export function migrateTemplatesToAutomations(
'limit',
),
);
// The implicit refill only applies to a limit-only simple template
// (e.g. `#template up to 200`). When a monthly amount is also set
// (`#template 50 up to 200`), the engine just budgets the monthly
// amount and clamps to the cap — no top-up to the limit.
if (!hasMonthly) {
entries.push(
createAutomationEntry(
@@ -81,6 +79,7 @@ export function migrateTemplatesToAutomations(
);
}
}
if (hasMonthly) {
entries.push(
createAutomationEntry(

View File

@@ -46,6 +46,7 @@ import { useDispatch } from '#redux';
import {
buildBalanceForecastChartData,
countForecastScheduledOccurrences,
getZeroCrossingGradientOffset,
} from './balanceForecastChartData';
export function BalanceForecast() {
@@ -280,6 +281,7 @@ function BalanceForecastInner({ widget }: BalanceForecastInnerProps) {
const lowestPoint = forecastData?.lowestBalance;
const hasNegativeBalance = chartData.some(d => d.balance < 0);
const zeroCrossingGradientOffset = getZeroCrossingGradientOffset(chartData);
const todayReferenceDate =
granularity === 'Daily'
? monthUtils.currentDay()
@@ -384,6 +386,37 @@ function BalanceForecastInner({ widget }: BalanceForecastInnerProps) {
data={chartData}
margin={{ top: 10, right: 10, left: 5, bottom: 10 }}
>
<defs>
<linearGradient
id="balance-forecast-line-gradient"
x1="0"
y1="0"
x2="0"
y2="1"
>
{zeroCrossingGradientOffset == null ? (
<stop
offset="0%"
stopColor={
hasNegativeBalance
? theme.errorText
: theme.noticeText
}
/>
) : (
<>
<stop
offset={`${zeroCrossingGradientOffset}%`}
stopColor={theme.noticeText}
/>
<stop
offset={`${zeroCrossingGradientOffset}%`}
stopColor={theme.errorText}
/>
</>
)}
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="date"
@@ -461,14 +494,13 @@ function BalanceForecastInner({ widget }: BalanceForecastInnerProps) {
}}
/>
)}
{hasNegativeBalance && (
<ReferenceLine y={0} stroke={theme.pageTextSubdued} />
)}
<Line
type="monotone"
dataKey="balance"
stroke={
hasNegativeBalance
? theme.errorText
: theme.noticeText
}
stroke="url(#balance-forecast-line-gradient)"
strokeWidth={2}
dot={false}
activeDot={{ r: 6 }}

View File

@@ -33,6 +33,7 @@ import { useFormat } from '#hooks/useFormat';
import {
buildBalanceForecastChartData,
countForecastScheduledOccurrences,
getZeroCrossingGradientOffset,
} from './balanceForecastChartData';
type BalanceForecastCardProps = {
@@ -123,6 +124,9 @@ export function BalanceForecastCard({
end: chartRange.end,
granularity: 'Monthly',
});
const hasNegativeBalance = chartData.some(d => d.balance < 0);
const zeroCrossingGradientOffset = getZeroCrossingGradientOffset(chartData);
const gradientId = `balance-forecast-card-line-gradient-${widgetId}`;
const isUpdatingForecast = isFetching && isPlaceholderData;
const todayReferenceDate = monthUtils.currentMonth();
const showsTodayReferenceLine = chartData.some(
@@ -234,6 +238,37 @@ export function BalanceForecastCard({
data={chartData}
margin={{ top: 5, right: 5, left: 5, bottom: 5 }}
>
<defs>
<linearGradient
id={gradientId}
x1="0"
y1="0"
x2="0"
y2="1"
>
{zeroCrossingGradientOffset == null ? (
<stop
offset="0%"
stopColor={
hasNegativeBalance
? theme.errorText
: theme.noticeText
}
/>
) : (
<>
<stop
offset={`${zeroCrossingGradientOffset}%`}
stopColor={theme.noticeText}
/>
<stop
offset={`${zeroCrossingGradientOffset}%`}
stopColor={theme.errorText}
/>
</>
)}
</linearGradient>
</defs>
<Tooltip
isAnimationActive={false}
content={({ active, payload }) => {
@@ -272,10 +307,13 @@ export function BalanceForecastCard({
strokeDasharray="4 4"
/>
)}
{hasNegativeBalance && (
<ReferenceLine y={0} stroke={theme.pageTextSubdued} />
)}
<Line
type="monotone"
dataKey="balance"
stroke={hasNegative ? theme.errorText : theme.noticeText}
stroke={`url(#${gradientId})`}
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}

View File

@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
import {
buildBalanceForecastChartData,
countForecastScheduledOccurrences,
getZeroCrossingGradientOffset,
} from './balanceForecastChartData';
describe('buildBalanceForecastChartData', () => {
@@ -257,3 +258,23 @@ describe('countForecastScheduledOccurrences', () => {
expect(count).toBe(2);
});
});
describe('getZeroCrossingGradientOffset', () => {
it('returns the zero threshold offset when balances cross zero', () => {
expect(
getZeroCrossingGradientOffset([
{ date: '2024-03', balance: 100 },
{ date: '2024-04', balance: -100 },
]),
).toBe(50);
});
it('returns null when balances do not cross zero', () => {
expect(
getZeroCrossingGradientOffset([
{ date: '2024-03', balance: 100 },
{ date: '2024-04', balance: 50 },
]),
).toBeNull();
});
});

View File

@@ -128,3 +128,19 @@ export function countForecastScheduledOccurrences(
return occurrenceKeys.size;
}
export function getZeroCrossingGradientOffset(chartData: ChartDataPoint[]) {
if (chartData.length === 0) {
return null;
}
const balances = chartData.map(point => point.balance);
const minBalance = Math.min(...balances);
const maxBalance = Math.max(...balances);
if (minBalance >= 0 || maxBalance <= 0) {
return null;
}
return (maxBalance / (maxBalance - minBalance)) * 100;
}

View File

@@ -220,6 +220,7 @@ export function AmountInput({
onFocus={e => {
setIsFocused(true);
setValue(format.forEdit(Math.abs(initialValue ?? 0)));
setTimeout(() => innerRef.current?.select(), 0);
onFocus?.(e);
}}
onBlur={e => {

View File

@@ -152,14 +152,13 @@ async function stagePluginsService(): Promise<void> {
}
async function stagePublicData(): Promise<void> {
// The current loot-core worker inlines everything it reads at init, so
// new clients never touch `data/`. `default-db.sqlite` is still staged
// here for one release so older clients pinned by a stale service-worker
// cache can finish populating their in-memory FS after upgrade.
const migrationsDest = path.resolve(publicDataDir, 'migrations');
await mkdir(publicDataDir, { recursive: true });
await rm(migrationsDest, { recursive: true, force: true });
await Promise.all([
cp(path.resolve(lootCoreRoot, 'migrations'), migrationsDest, {
recursive: true,
}),
cp(
path.resolve(lootCoreRoot, 'default-db.sqlite'),
path.resolve(publicDataDir, 'default-db.sqlite'),

View File

@@ -89,7 +89,6 @@
"#types/*": "./src/types/*.ts",
"#mocks/*": "./src/mocks/*.ts",
"#migrations/*": "./migrations/*.js",
"#default-db.sqlite": "./default-db.sqlite",
"#*": "./src/*.ts"
},
"exports": {

View File

@@ -1,39 +0,0 @@
import { readFileSync } from 'node:fs';
import type { Plugin } from 'vite';
// `import bytes from './file?bytes'` inlines the file as a Uint8Array. Used
// to embed assets that backend init must read without hitting the network —
// SharedWorker fetches bypass the service-worker cache in some browser/PWA
// contexts, so anything fetched at init breaks offline app load.
const BYTES_QUERY = '?bytes';
export function bytesLoader(): Plugin {
return {
name: 'loot-core-bytes-loader',
enforce: 'pre',
async resolveId(id, importer) {
if (!id.endsWith(BYTES_QUERY)) return null;
const base = id.slice(0, -BYTES_QUERY.length);
// Delegate so package imports (`#path/...`) resolve via Vite/Rolldown.
const resolved = await this.resolve(base, importer, { skipSelf: true });
if (!resolved) return null;
return resolved.id + BYTES_QUERY;
},
load(id) {
if (!id.endsWith(BYTES_QUERY)) return null;
const filePath = id.slice(0, -BYTES_QUERY.length);
const base64 = readFileSync(filePath).toString('base64');
// Block-scope the base64 + intermediate binary string so V8 can GC
// them after the IIFE returns; only the Uint8Array stays resident.
return `const bytes = (() => {
const bin = atob(${JSON.stringify(base64)});
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
})();
export default bytes;
`;
},
};
}

View File

@@ -71,11 +71,8 @@ vi.mock('#server/migrate/migrations', async () => {
...realMigrations,
migrate: async db => {
_id = 100_000_000;
try {
return await realMigrations.migrate(db);
} finally {
_id = 1;
}
await realMigrations.migrate(db);
_id = 1;
},
};
});

View File

@@ -1,10 +1,6 @@
import 'fake-indexeddb/auto';
import { readFileSync } from 'fs';
import path from 'path';
import { IDBFactory } from 'fake-indexeddb';
import defaultDbBytes from '#default-db.sqlite?bytes';
import { patchFetchForSqlJS } from '#mocks/util';
import * as idb from '#platform/server/indexeddb';
import * as sqlite from '#platform/server/sqlite';
@@ -119,18 +115,3 @@ describe('join', () => {
expect(join('/foo', '../bar')).toBe('/bar');
});
});
describe('bundled default-db.sqlite', () => {
const onDiskPath = path.resolve(__dirname, '../../../../default-db.sqlite');
test('matches the on-disk file byte-for-byte', () => {
expect(defaultDbBytes).toBeInstanceOf(Uint8Array);
// SQLite file header `SQLite format 3\0` — readable failure if a future
// loader change accidentally decodes the asset as text.
expect(Array.from(defaultDbBytes.slice(0, 16))).toEqual([
0x53, 0x51, 0x4c, 0x69, 0x74, 0x65, 0x20, 0x66, 0x6f, 0x72, 0x6d, 0x61,
0x74, 0x20, 0x33, 0x00,
]);
expect(defaultDbBytes).toEqual(new Uint8Array(readFileSync(onDiskPath)));
});
});

View File

@@ -2,10 +2,6 @@
import { SQLiteFS } from 'absurd-sql';
import IndexedDBBackend from 'absurd-sql/dist/indexeddb-backend';
// Inlined so backend init does not require network: SharedWorker fetches
// bypass the service-worker cache in some browser/PWA contexts, so any
// init-path fetch breaks offline app load.
import defaultDbBytes from '#default-db.sqlite?bytes';
import * as connection from '#platform/server/connection';
import { join } from '#platform/server/fs/path-join';
import * as idb from '#platform/server/indexeddb';
@@ -239,8 +235,29 @@ async function _removeFile(filepath: string) {
FS.unlink(filepath);
}
// Load files from the server that should exist by default
async function populateDefaultFilesystem() {
await _writeFile(bundledDatabasePath, defaultDbBytes);
const index = await (
await fetch(process.env.PUBLIC_URL + 'data-file-index.txt')
).text();
const files = index
.split('\n')
.map(name => name.trim())
.filter(name => name !== '');
const fetchFile = url => fetch(url).then(res => res.arrayBuffer());
// This is hardcoded. We know we must create the migrations
// directory, it's not worth complicating the index to support
// creating arbitrary folders.
await mkdir('/migrations');
await mkdir('/demo-budget');
await Promise.all(
files.map(async file => {
const contents = await fetchFile(process.env.PUBLIC_URL + 'data/' + file);
await _writeFile('/' + file, contents);
}),
);
}
const populateFileHierarchy = async function () {

View File

@@ -43,7 +43,6 @@ import {
startBackupService,
stopBackupService,
} from './backups';
import { classifyUpdateVersionError } from './classify-error';
const DEMO_BUDGET_ID = '_demo-budget';
const TEST_BUDGET_ID = '_test-budget';
@@ -552,17 +551,20 @@ async function _loadBudget(id: Budget['id']): Promise<{
await updateVersion();
} catch (e) {
logger.warn('Error updating', e);
const { error, report } = classifyUpdateVersionError(e.message);
if (report) {
let result;
if (e.message.includes('out-of-sync-migrations')) {
result = { error: 'out-of-sync-migrations' };
} else if (e.message.includes('out-of-sync-data')) {
result = { error: 'out-of-sync-data' };
} else {
captureException(e);
}
if (error === 'loading-budget') {
logger.info('Error updating budget ' + id, e);
logger.log('Error updating budget', e);
result = { error: 'loading-budget' };
}
await closeBudget();
return { error };
return result;
}
await db.loadClock();

View File

@@ -1,62 +0,0 @@
import { classifyUpdateVersionError } from './classify-error';
describe('classifyUpdateVersionError', () => {
test('out-of-sync-migrations from migrate() maps to the same code, no report', () => {
expect(classifyUpdateVersionError('out-of-sync-migrations')).toEqual({
error: 'out-of-sync-migrations',
report: false,
});
});
test('out-of-sync-data maps to the same code, no report', () => {
expect(classifyUpdateVersionError('out-of-sync-data')).toEqual({
error: 'out-of-sync-data',
report: false,
});
});
test('schema-out-of-sync (from probeViews) funnels to out-of-sync-migrations and reports', () => {
expect(
classifyUpdateVersionError(
'schema-out-of-sync: v_schedules: no such column: custom_upcoming_length',
),
).toEqual({
error: 'out-of-sync-migrations',
report: true,
});
});
test('unknown messages fall back to loading-budget and report', () => {
expect(classifyUpdateVersionError('something broke')).toEqual({
error: 'loading-budget',
report: true,
});
expect(classifyUpdateVersionError('')).toEqual({
error: 'loading-budget',
report: true,
});
});
test('substring matching: longer prefixed messages still classify correctly', () => {
expect(
classifyUpdateVersionError(
'Error: out-of-sync-migrations (id mismatch at index 3)',
).error,
).toBe('out-of-sync-migrations');
expect(
classifyUpdateVersionError(
'Failed: out-of-sync-data — local clock diverged',
).error,
).toBe('out-of-sync-data');
});
test('schema-out-of-sync is routed correctly even though it shares the "out-of-sync" prefix', () => {
// The `out-of-sync-migrations` check looks for the `-migrations` suffix,
// so the schema error doesn't match it and falls through to the schema
// branch as intended.
const result = classifyUpdateVersionError('schema-out-of-sync: v_x: ...');
expect(result.error).toBe('out-of-sync-migrations');
expect(result.report).toBe(true);
});
});

View File

@@ -1,25 +0,0 @@
export type UpdateVersionErrorCode =
| 'out-of-sync-migrations'
| 'out-of-sync-data'
| 'loading-budget';
// Maps an `updateVersion` failure to the user-facing error and a flag for
// whether the error is an unexpected bug worth reporting. `out-of-sync-*`
// errors are expected recovery states (the user is guided to resync from the
// server); `schema-out-of-sync` and any unrecognized message indicate a real
// bug.
export function classifyUpdateVersionError(message: string): {
error: UpdateVersionErrorCode;
report: boolean;
} {
if (message.includes('out-of-sync-migrations')) {
return { error: 'out-of-sync-migrations', report: false };
}
if (message.includes('out-of-sync-data')) {
return { error: 'out-of-sync-data', report: false };
}
if (message.includes('schema-out-of-sync')) {
return { error: 'out-of-sync-migrations', report: true };
}
return { error: 'loading-budget', report: true };
}

View File

@@ -1,29 +1,16 @@
// @ts-strict-ignore
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join as pathJoin } from 'node:path';
import * as fs from '#platform/server/fs';
import * as db from '#server/db';
import {
applyMigration,
getAppliedMigrations,
getMigrationId,
getMigrationList,
getMigrationsDir,
getPending,
getUpMigration,
migrate,
withMigrationsDir,
} from './migrations';
beforeEach(global.emptyDatabase(true));
function makeTempMigrationsDir(prefix: string): string {
return mkdtempSync(pathJoin(tmpdir(), prefix));
}
describe('Migrations', () => {
test('gets the latest migrations', async () => {
const applied = await getAppliedMigrations(db.getDatabase());
@@ -36,14 +23,6 @@ describe('Migrations', () => {
expect(getPending(applied, available)).toMatchSnapshot();
});
test('bundled list is sorted by id and includes the latest sql migration', async () => {
const available = await getMigrationList(fs.migrationsPath);
expect(available).toContain('1769000000000_add_custom_upcoming_length.sql');
const ids = available.map(getMigrationId);
expect(ids).toEqual([...ids].sort((a, b) => a - b));
});
test('applied migrations are returned in order', async () => {
return withMigrationsDir(
__dirname + '/../../mocks/migrations',
@@ -100,282 +79,3 @@ describe('Migrations', () => {
);
});
});
describe('getMigrationId', () => {
test('parses the leading numeric run', () => {
expect(getMigrationId('1769000000000_add_custom_upcoming_length.sql')).toBe(
1769000000000,
);
expect(getMigrationId('1632571489012_remove_cache.js')).toBe(1632571489012);
expect(getMigrationId('1.sql')).toBe(1);
});
});
describe('getUpMigration', () => {
test('returns the matching name', () => {
const names = ['1000_a.sql', '2000_b.sql', '3000_c.js'];
expect(getUpMigration(2000, names)).toBe('2000_b.sql');
expect(getUpMigration(3000, names)).toBe('3000_c.js');
});
test('returns undefined when no name matches', () => {
expect(getUpMigration(9999, ['1000_a.sql'])).toBeUndefined();
expect(getUpMigration(1, [])).toBeUndefined();
});
});
describe('getPending', () => {
const all = ['1000_a.sql', '2000_b.sql', '3000_c.sql'];
test('returns names whose ids are not in applied', () => {
expect(getPending([1000], all)).toEqual(['2000_b.sql', '3000_c.sql']);
});
test('returns empty when all are applied', () => {
expect(getPending([1000, 2000, 3000], all)).toEqual([]);
});
test('returns full list when none are applied', () => {
expect(getPending([], all)).toEqual(all);
});
test('ignores applied ids that are not in the available list', () => {
expect(getPending([1000, 9999], all)).toEqual(['2000_b.sql', '3000_c.sql']);
});
});
describe('getMigrationList', () => {
test('sorts numerically, not lexically, for mixed-length ids', async () => {
const dir = makeTempMigrationsDir('mig-sort-');
// Lexical sort would put "100" before "9"; numeric sort puts "9" first.
writeFileSync(pathJoin(dir, '9_short.sql'), '');
writeFileSync(pathJoin(dir, '100_long.sql'), '');
writeFileSync(pathJoin(dir, '1000_longer.sql'), '');
const list = await getMigrationList(dir);
expect(list).toEqual(['9_short.sql', '100_long.sql', '1000_longer.sql']);
});
test('filters out files that are not .sql or .js', async () => {
const dir = makeTempMigrationsDir('mig-filter-');
writeFileSync(pathJoin(dir, '1_keep.sql'), '');
writeFileSync(pathJoin(dir, '2_keep.js'), 'export default function() {}');
writeFileSync(pathJoin(dir, '3_ignore.txt'), '');
writeFileSync(pathJoin(dir, '4_ignore.md'), '');
writeFileSync(pathJoin(dir, '.force-copy-windows'), '');
const list = await getMigrationList(dir);
expect(list).toEqual(['1_keep.sql', '2_keep.js']);
});
test('returns the bundled list for the default migrations dir', async () => {
const list = await getMigrationList(fs.migrationsPath);
// JS migrations are present as synthesized `${id}.js` entries.
expect(list).toContain('1632571489012.js');
// Real SQL migrations keep their on-disk filename.
expect(list).toContain('1548957970627_remove-db-version.sql');
});
});
describe('withMigrationsDir', () => {
test('restores the previous dir after the callback resolves', async () => {
const before = getMigrationsDir();
await withMigrationsDir('/tmp/whatever', async () => {
expect(getMigrationsDir()).toBe('/tmp/whatever');
});
expect(getMigrationsDir()).toBe(before);
});
test('restores the previous dir when the callback throws', async () => {
const before = getMigrationsDir();
await expect(
withMigrationsDir('/tmp/whatever', async () => {
throw new Error('boom');
}),
).rejects.toThrow('boom');
expect(getMigrationsDir()).toBe(before);
});
});
describe('applyMigration', () => {
test('SQL path against bundled dir applies the SQL and records the id', async () => {
expect(await getAppliedMigrations(db.getDatabase())).toEqual([]);
// 1548957970627_remove-db-version.sql drops the db_version table.
await applyMigration(
db.getDatabase(),
'1548957970627_remove-db-version.sql',
fs.migrationsPath,
);
const tbl = await db.first<{ name: string }>(
"SELECT name FROM sqlite_master WHERE name = 'db_version'",
);
expect(tbl).toBe(null);
expect(await getAppliedMigrations(db.getDatabase())).toEqual([
1548957970627,
]);
});
test('JS path against bundled dir runs the registered handler', async () => {
// 1632571489012_remove_cache.js drops spreadsheet_cells and creates
// zero_budget_months (plus other budget tables). Bundled names use the
// synthesized `${id}.js` form.
await applyMigration(
db.getDatabase(),
'1632571489012.js',
fs.migrationsPath,
);
const newTable = await db.first<{ name: string }>(
"SELECT name FROM sqlite_master WHERE name = 'zero_budget_months'",
);
expect(newTable).toBeDefined();
expect(newTable.name).toBe('zero_budget_months');
const droppedTable = await db.first<{ name: string }>(
"SELECT name FROM sqlite_master WHERE name = 'spreadsheet_cells'",
);
expect(droppedTable).toBe(null);
expect(await getAppliedMigrations(db.getDatabase())).toEqual([
1632571489012,
]);
});
test('JS path throws when the migration id is not registered', async () => {
await expect(
applyMigration(db.getDatabase(), '9999999999999_unknown.js', '/ignored'),
).rejects.toThrow(
'Could not find JS migration code to run for 9999999999999',
);
// Nothing recorded in __migrations__.
expect(await getAppliedMigrations(db.getDatabase())).toEqual([]);
});
test('SQL path propagates SQLite errors and skips recording the id', async () => {
const dir = makeTempMigrationsDir('mig-bad-');
writeFileSync(pathJoin(dir, '1_broken.sql'), 'THIS IS NOT VALID SQL;');
await expect(
applyMigration(db.getDatabase(), '1_broken.sql', dir),
).rejects.toThrow();
expect(await getAppliedMigrations(db.getDatabase())).toEqual([]);
});
});
describe('migrate (end-to-end against bundled dir)', () => {
test('applies every bundled migration on a fresh init.sql DB', async () => {
const pending = await migrate(db.getDatabase());
expect(pending.length).toBeGreaterThan(0);
const applied = await getAppliedMigrations(db.getDatabase());
expect(applied.length).toBe(pending.length);
// Both bundled JS and SQL migrations executed.
expect(applied).toContain(1632571489012); // JS
expect(applied).toContain(1769000000000); // SQL: custom_upcoming_length
// The column the JS migration creates exists in the database.
const zb = await db.first<{ name: string }>(
"SELECT name FROM sqlite_master WHERE name = 'zero_budget_months'",
);
expect(zb.name).toBe('zero_budget_months');
// The column the latest SQL migration adds exists.
const cols = await db.all<{ name: string }>(
"PRAGMA table_info('schedules')",
);
expect(cols.map(c => c.name)).toContain('custom_upcoming_length');
});
test('returns an empty pending list on the second invocation (idempotent)', async () => {
await migrate(db.getDatabase());
const second = await migrate(db.getDatabase());
expect(second).toEqual([]);
});
});
describe('patchBadMigrations', () => {
test('replaces the bad-filters id with the new-filters id before validity check', async () => {
return withMigrationsDir(
__dirname + '/../../mocks/migrations',
async () => {
// Inject the bad id. The mock dir doesn't contain either id, so the
// only way patching can avoid an "out-of-sync" throw is by removing
// the bad id (and inserting the new one, which we also assert isn't
// tripping validity below because it's not in available either).
db.runQuery('INSERT INTO __migrations__ (id) VALUES (1685375406832)');
// The new id (1688749527273) gets inserted by patchBadMigrations.
// Neither id is in the mock available list, so validity will still
// complain — assert via the patched table state directly.
await migrate(db.getDatabase()).catch(() => {
// expected: validity throws because 1688749527273 isn't in mocks
});
const ids = await getAppliedMigrations(db.getDatabase());
expect(ids).not.toContain(1685375406832);
expect(ids).toContain(1688749527273);
},
);
});
test('is a no-op when the bad id is not in __migrations__', async () => {
return withMigrationsDir(
__dirname + '/../../mocks/migrations',
async () => {
await migrate(db.getDatabase());
const ids = await getAppliedMigrations(db.getDatabase());
expect(ids).not.toContain(1685375406832);
expect(ids).not.toContain(1688749527273);
},
);
});
});
describe('checkDatabaseValidity (via migrate)', () => {
test('throws when more migrations are recorded than are available', async () => {
return withMigrationsDir(
__dirname + '/../../mocks/migrations',
async () => {
// Mock dir has 3 migrations. Insert 4 unrelated ids → applied.length
// (4) > available.length (3) → length-branch throw.
db.runQuery('INSERT INTO __migrations__ (id) VALUES (1)');
db.runQuery('INSERT INTO __migrations__ (id) VALUES (2)');
db.runQuery('INSERT INTO __migrations__ (id) VALUES (3)');
db.runQuery('INSERT INTO __migrations__ (id) VALUES (4)');
await expect(migrate(db.getDatabase())).rejects.toThrow(
'out-of-sync-migrations',
);
},
);
});
test('throws on id mismatch even when counts match', async () => {
return withMigrationsDir(
__dirname + '/../../mocks/migrations',
async () => {
// Same count as available (3) but the ids don't line up.
db.runQuery('INSERT INTO __migrations__ (id) VALUES (1)');
db.runQuery('INSERT INTO __migrations__ (id) VALUES (2)');
db.runQuery('INSERT INTO __migrations__ (id) VALUES (3)');
await expect(migrate(db.getDatabase())).rejects.toThrow(
'out-of-sync-migrations',
);
},
);
});
});

View File

@@ -14,19 +14,6 @@ import { logger } from '#platform/server/log';
import * as sqlite from '#platform/server/sqlite';
import * as prefs from '#server/prefs';
// Inline SQL migrations into the worker chunk so they cannot desync with the
// AQL schema across a service-worker cache boundary. `import.meta.glob` is
// statically analyzed, so the path must be a literal relative to this file.
const bundledSqlByName: Record<string, string> = Object.fromEntries(
Object.entries(
import.meta.glob<string>('../../../migrations/*.sql', {
query: '?raw',
import: 'default',
eager: true,
}),
).map(([p, sql]) => [p.slice(p.lastIndexOf('/') + 1), sql]),
);
let MIGRATIONS_DIR = fs.migrationsPath;
const javascriptMigrations = {
@@ -37,36 +24,21 @@ const javascriptMigrations = {
1765518577215: m1765518577215,
};
// JS migrations are looked up by id, so the on-disk filename is irrelevant
// once bundled — `${id}.js` is enough to round-trip through the sort/filter
// path in `getMigrationList`.
const bundledMigrationNames: string[] = [
...Object.keys(bundledSqlByName),
...Object.keys(javascriptMigrations).map(id => `${id}.js`),
];
function isDefaultMigrationsDir(dir: string): boolean {
return dir === fs.migrationsPath;
}
export async function withMigrationsDir(
dir: string,
func: () => Promise<void>,
): Promise<void> {
const oldDir = MIGRATIONS_DIR;
MIGRATIONS_DIR = dir;
try {
await func();
} finally {
MIGRATIONS_DIR = oldDir;
}
await func();
MIGRATIONS_DIR = oldDir;
}
export function getMigrationsDir(): string {
return MIGRATIONS_DIR;
}
export function getMigrationId(name: string): number {
function getMigrationId(name: string): number {
return parseInt(name.match(/^(\d)+/)[0]);
}
@@ -105,9 +77,7 @@ export async function getAppliedMigrations(db: Database): Promise<number[]> {
export async function getMigrationList(
migrationsDir: string,
): Promise<string[]> {
const files = isDefaultMigrationsDir(migrationsDir)
? bundledMigrationNames
: await fs.listDir(migrationsDir);
const files = await fs.listDir(migrationsDir);
return files
.filter(name => name.match(/(\.sql|\.js)$/))
.sort((m1, m2) => {
@@ -162,13 +132,11 @@ export async function applyMigration(
name: string,
migrationsDir: string,
): Promise<void> {
const code = await fs.readFile(fs.join(migrationsDir, name));
if (name.match(/\.js$/)) {
await applyJavaScript(db, getMigrationId(name));
} else {
const sql = isDefaultMigrationsDir(migrationsDir)
? bundledSqlByName[name]
: await fs.readFile(fs.join(migrationsDir, name));
await applySql(db, sql);
await applySql(db, code);
}
sqlite.runQuery(db, 'INSERT INTO __migrations__ (id) VALUES (?)', [
getMigrationId(name),

View File

@@ -1,107 +0,0 @@
import * as db from '#server/db';
import { updateVersion } from './update';
beforeEach(global.emptyDatabase());
const VIEWS = [
'v_payees',
'v_categories',
'v_schedules',
'v_transactions_internal',
'v_transactions_internal_alive',
'v_transactions',
];
describe('updateVersion (happy path via emptyDatabase)', () => {
test('all configured views are created and queryable after init', async () => {
for (const view of VIEWS) {
// Throws if the view doesn't exist or its definition is invalid.
const row = await db.first<Record<string, unknown>>(
`SELECT * FROM ${view} LIMIT 1`,
);
// Either an empty result or a row — we just need the SELECT to succeed.
expect(row === null || typeof row === 'object').toBe(true);
}
});
test('view hash is stored in __meta__ after init', async () => {
const row = await db.first<{ value: string }>(
"SELECT value FROM __meta__ WHERE key = 'view-hash'",
);
expect(row?.value).toMatch(/^[0-9a-f]{32}$/);
});
});
describe('updateViews (re-run behavior)', () => {
test('is a no-op when the stored hash matches (does not change __meta__ row)', async () => {
const before = await db.first<{ value: string }>(
"SELECT value FROM __meta__ WHERE key = 'view-hash'",
);
await updateVersion();
const after = await db.first<{ value: string }>(
"SELECT value FROM __meta__ WHERE key = 'view-hash'",
);
expect(after?.value).toBe(before?.value);
});
test('recreates views when the stored hash differs', async () => {
// Force a hash mismatch.
await db.run("UPDATE __meta__ SET value = 'stale' WHERE key = 'view-hash'");
await updateVersion();
const after = await db.first<{ value: string }>(
"SELECT value FROM __meta__ WHERE key = 'view-hash'",
);
expect(after?.value).not.toBe('stale');
expect(after?.value).toMatch(/^[0-9a-f]{32}$/);
// Views are still queryable after recreation.
const row = await db.first<{ id: string }>(
'SELECT * FROM v_payees LIMIT 1',
);
expect(row === null || typeof row === 'object').toBe(true);
});
});
describe('probeViews (failure surfaces schema-out-of-sync)', () => {
test('throws schema-out-of-sync with the failing view name when an underlying table is missing', async () => {
// Simulate the #7710 desync: a migration was recorded as applied but the
// table/column it created is gone. Dropping `schedules` makes `v_schedules`
// unresolvable when probed, even though CREATE VIEW itself succeeds.
db.execQuery('DROP TABLE schedules');
await db.run("UPDATE __meta__ SET value = 'stale' WHERE key = 'view-hash'");
await expect(updateVersion()).rejects.toThrow(/schema-out-of-sync/);
});
test('error message includes the view name and the underlying cause', async () => {
db.execQuery('DROP TABLE schedules');
await db.run("UPDATE __meta__ SET value = 'stale' WHERE key = 'view-hash'");
let thrown: unknown;
try {
await updateVersion();
} catch (e) {
thrown = e;
}
if (!(thrown instanceof Error)) {
throw new Error(
`expected updateVersion to throw an Error, got: ${String(thrown)}`,
);
}
expect(thrown.message).toContain('schema-out-of-sync');
expect(thrown.message).toContain('v_schedules');
expect(thrown.message.toLowerCase()).toContain('schedules');
});
test('does not throw when every view resolves cleanly (fresh DB)', async () => {
// Force re-run of view creation + probe on a healthy DB.
await db.run("UPDATE __meta__ SET value = 'stale' WHERE key = 'view-hash'");
await expect(updateVersion()).resolves.toBeUndefined();
});
});

View File

@@ -1,8 +1,6 @@
// @ts-strict-ignore
import { createHash } from 'node:crypto';
import { logger } from '#platform/server/log';
import { makeViews, schema, schemaConfig } from './aql';
import * as db from './db';
import * as migrations from './migrate/migrations';
@@ -13,29 +11,6 @@ async function runMigrations() {
await migrations.migrate(db.getDatabase());
}
// `'fields'` is a non-view entry inside each table's view map, shared with
// `makeViews` — skip it.
function getConfiguredViewNames(): string[] {
return Object.values(schemaConfig.views).flatMap(tableViews =>
Object.keys(tableViews).filter(name => name !== 'fields'),
);
}
// Fail fast when the newly-created views reference columns the migrations
// didn't add, so the user hits the recovery dialog once at startup instead of
// a cryptic error on every UI query.
function probeViews(): void {
for (const viewName of getConfiguredViewNames()) {
try {
db.execQuery(`SELECT * FROM ${viewName} LIMIT 0`);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
logger.error(`View ${viewName} failed schema probe`, e);
throw new Error(`schema-out-of-sync: ${viewName}: ${message}`);
}
}
}
async function updateViews() {
const hashKey = 'view-hash';
const row = await db.first<{ value: string }>(
@@ -53,7 +28,6 @@ async function updateViews() {
hashKey,
currentHash,
]);
probeViews();
}
}

View File

@@ -1,4 +0,0 @@
declare module '*?bytes' {
const bytes: Uint8Array;
export default bytes;
}

View File

@@ -5,8 +5,6 @@ import { defineConfig } from 'vite';
import { nodePolyfills } from 'vite-plugin-node-polyfills';
import peggyLoader from 'vite-plugin-peggy-loader';
import { bytesLoader } from './scripts/bytes-loader.mts';
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
const isDev = mode === 'development';
@@ -63,7 +61,6 @@ export default defineConfig(({ mode }) => {
'process.env.ACTUAL_DOCUMENT_DIR': JSON.stringify('/documents'),
},
plugins: [
bytesLoader(),
peggyLoader(),
// https://github.com/davidmyersdev/vite-plugin-node-polyfills/issues/142
nodePolyfills({

View File

@@ -1,8 +1,6 @@
import peggyLoader from 'vite-plugin-peggy-loader';
import { defineConfig } from 'vitest/config';
import { bytesLoader } from './scripts/bytes-loader.mts';
export default defineConfig({
test: {
environment: 'jsdom',
@@ -13,5 +11,5 @@ export default defineConfig({
],
maxWorkers: 2,
},
plugins: [bytesLoader(), peggyLoader()],
plugins: [peggyLoader()],
});

View File

@@ -7,7 +7,7 @@ WORKDIR /app
# Copy only the files needed for installing dependencies
COPY .yarn ./.yarn
COPY yarn.lock package.json .yarnrc.yml tsconfig.json ./
COPY yarn.lock package.json .yarnrc.yml tsconfig.json lage.config.js ./
COPY packages/api/package.json packages/api/package.json
COPY packages/component-library/package.json packages/component-library/package.json
COPY packages/crdt/package.json packages/crdt/package.json
@@ -31,6 +31,13 @@ COPY packages/ ./packages/
# Increase memory limit for the build process to 8GB
ENV NODE_OPTIONS=--max_old_space_size=8192
# lage's task hasher invokes `git ls-tree HEAD` during initialization, so it
# needs a git repo even when individual targets disable caching. .dockerignore
# omits the real .git, so seed a throwaway repo with a single commit here.
RUN git -c init.defaultBranch=master init -q \
&& git -c user.email=build@docker -c user.name=docker-build add -A \
&& git -c user.email=build@docker -c user.name=docker-build commit -qm build
RUN yarn build:server
# Focus the workspaces in production mode (including @actual-app/web you just built)

View File

@@ -1,6 +0,0 @@
---
category: Bugfixes
authors: [MatissJanis]
---
Bundle `default-db.sqlite` into the loot-core worker chunk so backend init no longer fetches it from the server. Fixes the FatalError users hit when opening the app while the sync server was unreachable (e.g. local-network host on a different Wi-Fi, PWA on iOS).

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [matt-fidd]
---
Automation UI: various tweaks and fixes

View File

@@ -0,0 +1,6 @@
---
category: Bugfixes
authors: [samaluk]
---
Color the Balance Forecast line by zero-balance crossing.

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [MatissJanis]
---
Refactor workflows to utilize native `gh` CLI commands instead of third-party GitHub Actions.

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [MatissJanis]
---
Reference dedicated environments for workflows that consume secrets, satisfying zizmor's `secrets-without-environment` audit.

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [MatissJanis]
---
Fix template injection in setup action's Lage cache step.

View File

@@ -0,0 +1,6 @@
---
category: Bugfixes
authors: [MatissJanis]
---
Update Dockerfile to ensure `yarn build:server` works after lage migration.

View File

@@ -19650,9 +19650,9 @@ __metadata:
linkType: hard
"lodash-es@npm:^4.17.21":
version: 4.17.21
resolution: "lodash-es@npm:4.17.21"
checksum: 10/03f39878ea1e42b3199bd3f478150ab723f93cc8730ad86fec1f2804f4a07c6e30deaac73cad53a88e9c3db33348bb8ceeb274552390e7a75d7849021c02df43
version: 4.18.1
resolution: "lodash-es@npm:4.18.1"
checksum: 10/8bfad225ef09ef42b04283cdaf7830efcc2ba29ae41b56501c74422155ee1ccaa1f0f6e8319def3451a1fe54dec501c8e4bee622bae2b2d98ac993731e0a5cce
languageName: node
linkType: hard
@@ -23873,11 +23873,11 @@ __metadata:
linkType: hard
"qs@npm:^6.12.3, qs@npm:^6.14.0, qs@npm:^6.14.1":
version: 6.14.1
resolution: "qs@npm:6.14.1"
version: 6.15.1
resolution: "qs@npm:6.15.1"
dependencies:
side-channel: "npm:^1.1.0"
checksum: 10/34b5ab00a910df432d55180ef39c1d1375e550f098b5ec153b41787f1a6a6d7e5f9495593c3b112b77dbc6709d0ae18e55b82847a4c2bbbb0de1e8ccbb1794c5
checksum: 10/ec10b9957446b3f4a38000940f6374720b4e2985209b89df197066038c951472ea24cd98d6bc6df73a0cbec75bc056f638032e3fb447345017ff7e0f0a2693ac
languageName: node
linkType: hard