AWS Lambda container and local developer runner for synchronizing Notion tasks with Google Calendar.
The worker loads configuration from the mapping-domain contract and uses the event-ID-based synchronization algorithm described below. Validate configuration and run against test data first.
Releases: https://github.com/HUIXIN-TW/NotionSyncGCal/releases
- Loads normalized settings, Task sources, and Calendar mappings from the mapping-domain table.
- Expands one current-contract runtime setting per active Notion Task source.
- Runs the Notion/Google synchronization logic independently for each source.
- Uses the Notion
GCal Event IdandGCal Sync Timefields while resolving those properties strictly by persisted Notion property ID. - Supports multiple first-class Task sources and normalized Calendar-name → Calendar-ID mappings.
- Persists cloud sync logs in DynamoDB.
Synchronization is event-ID-based:
- Event matching uses the Notion
GCal Event Idfield. - Creating a Google event writes the provider event ID back to Notion.
- Update, delete, and move behavior uses that provider event ID.
- Timestamp comparison and
GCal Sync Timebehavior are implemented insrc/sync/sync.py. - Google → Notion and Notion → Google force modes are available.
- Default-Calendar behavior is driven by explicit
defaultCalendarNameconfiguration. - CLI date-range flags are in-memory execution overrides only and do not rewrite configuration.
- Multiple active Task sources are orchestrated by running the same sync function once per source.
Cloud execution reads the current mapping-domain records:
NOTION_SETTINGSsuppliestimeZoneandtimeCode; the Worker treatstimeZoneas temporal authority and retainstimeCodeonly as current contract/derived metadata, not as a date-specific runtime offset;- each active
NOTION_TASK_SOURCE#<sourceId>supplies one Notion database, defaults, and semantic property bindings; - each active
CALENDAR_MAPPING#<mappingId>supplies the persisted Notion Calendar select value (calendarName) and Google Calendar ID; - each source is converted into the current worker runtime setting using persisted Notion property IDs; mutable property names are not a runtime lookup fallback;
- each source is executed independently and results are aggregated at the user job boundary.
The worker requires the semantic bindings used by the existing synchronization implementation, including task/date, Calendar, location, extra info, GCal End Date, GCal Deleted?, GCal Event Id, GCal Sync Time, and GCal Icon.
Configuration fails closed when owner identity, lifecycle, required property bindings, Calendar-name uniqueness, default Calendar, or normalized record shape is invalid. Runtime property lookup uses propertyId only; there is no property-name fallback. SQS and EventBridge require UUID-scoped payloads with a non-empty uuid and fail closed on unsupported payload shapes.
The Worker vendors the public mapping-domain artifact and pins its distribution identity in:
contracts/notica-mapping-domain.lock.jsoncontracts/notica-mapping-domain-v1.json
Normal runtime and ordinary CI use these committed files only. They do not fetch contracts from GitHub or from the private Backend repository.
To explicitly adopt a published contract release:
uv run python scripts/update_notica_contract.py mapping-domain-v1.0.0The updater reads the public release assets and the same files from the protected version tag, requires the bytes to match, then verifies the requested versioned tag, approved producer, schema version, artifact SHA-256, source SHA-256, and artifact-set identity before writing anything. It then vendors the exact artifact bytes, rewrites the lock, regenerates src/contracts/notica_mapping_domain.py, and runs the focused compatibility tests.
Do not pin main, latest, or another mutable identity. Contract upgrades are explicit code changes reviewed through the normal Worker PR flow.
The runtime uses an explicit mode switch via APP_MODE:
APP_MODE=local: uses.env.localsecrets andconfig/local.mapping-domain.jsonfor local development.APP_MODE=cloud: uses first-class mapping-domain records, UUID-keyed OAuth records, and SSM SecureString paths.
Current cloud/runtime notes:
- Cloud secret values are resolved via:
GOOGLE_CALENDAR_CLIENT_SECRET_SSM_PATHTOKEN_ENCRYPTION_KEY_SSM_PATH
- Cloud runtime should not use plaintext
GOOGLE_CALENDAR_CLIENT_SECRETor plaintextTOKEN_ENCRYPTION_KEYenv vars. - Token JSON files under
token/are not runtime inputs. - Cloud token payloads at rest in DynamoDB should remain
enc:v1:encrypted.
- Python
>=3.11(frompyproject.toml) uv- Notion account + Notion integration token
- Google account + OAuth client credentials
- AWS account only for
APP_MODE=cloud
Install dependencies:
uv syncRun tests:
uv run python -m unittest discover -s test -vRun coverage:
uv run coverage run -m unittest discover -s test -v
uv run coverage report -mCoverage enforcement is configured in .coveragerc (fail_under = 50).
No AWS dependency for runtime.
- Local configuration/credentials are read from
.env.local:NOTION_TOKENGOOGLE_CALENDAR_CLIENT_IDGOOGLE_CALENDAR_CLIENT_SECRETGOOGLE_CALENDAR_REFRESH_TOKENTOKEN_ENCRYPTION_KEYonly when local token values are stored asenc:v1:payloads
- Structured local sync config is read from:
config/local.mapping-domain.json
Requires a uuid and AWS access.
- Loads configuration and tokens from DynamoDB:
- mapping-domain table (
USER#<uuid>partition andSourceMappingsIndex) - Google OAuth token table
- Notion OAuth token table
- sync logs table
- mapping-domain table (
DYNAMODB_USER_TABLEis used only for sync-log summary persistence; it is not a configuration source.- Lambda environment includes SSM parameter paths:
GOOGLE_CALENDAR_CLIENT_SECRET_SSM_PATHTOKEN_ENCRYPTION_KEY_SSM_PATH
- Runtime resolves SSM SecureString values with decryption.
- Runtime does not use plaintext
GOOGLE_CALENDAR_CLIENT_SECRETor plaintextTOKEN_ENCRYPTION_KEYenv vars. - Runtime does not use local
token/*.jsonfiles.
Create local files from examples:
cp .env.local.example .env.local
cp config/local.mapping-domain.example.json config/local.mapping-domain.jsonRun sync locally with explicit mode:
APP_MODE=local uv run python src/main.py
APP_MODE=local uv run python src/main.py -t <goback_days> <goforward_days>
APP_MODE=local uv run python src/main.py -n <goback_days> <goforward_days>CLI date range flags (-t, -n) are runtime in-memory overrides only. They do not modify config/local.mapping-domain.json.
Generate a local GOOGLE_CALENDAR_REFRESH_TOKEN with:
uv run python scripts/generate-google-refresh-token.py --client-id <client_id> --client-secret <client_secret>Do not commit .env.local.
Required Lambda environment shape:
APP_MODE=cloud
APP_STAGE=dev
APP_REGION=ap-southeast-2
DYNAMODB_USER_TABLE=...
DYNAMODB_MAPPING_DOMAIN_TABLE=...
DYNAMODB_SYNC_LOGS_TABLE=...
DYNAMODB_GOOGLE_OAUTH_TOKEN_TABLE=...
DYNAMODB_NOTION_OAUTH_TOKEN_TABLE=...
GOOGLE_CALENDAR_CLIENT_ID=...
GOOGLE_CALENDAR_CLIENT_SECRET_SSM_PATH=/dev/notica/google_calendar_client_secret
TOKEN_ENCRYPTION_KEY_SSM_PATH=/dev/notica/token_encryption_keyIAM for Lambda execution role should include least privilege:
- DynamoDB read/write permissions for exact tables and required indexes.
- SSM permissions:
ssm:GetParameterfor runtime single-parameter secret resolution.ssm:GetParametersonly if batch secret lookup is introduced.- Permissions must be scoped to exact parameter ARNs.
kms:Decryptonly if those SecureString parameters use a customer-managed KMS key.
Avoid wildcard permissions such as ssm:*.
Detailed deployment workflow behavior is documented in docs/deployment.md.
Run local code with dev cloud configuration:
./scripts/local-run-dev-sync.sh --mode cloud --uuid <uuid>Run local-only mode:
./scripts/local-run-dev-sync.sh --mode localNotes:
- Cloud runner loads dev Lambda env configuration and resolves SSM values using your current AWS credentials.
- Local runner reads
.env.local. - Runner output is designed not to print sensitive secret values.
.
├── .coveragerc
├── .env.local.example
├── config/
│ └── local.mapping-domain.example.json
├── docs/
│ ├── deployment.md
│ └── local-dev-sync-runner.md
├── lambda_function.py
├── pyproject.toml
├── scripts/
│ ├── generate-google-refresh-token.py
│ ├── local-run-dev-sync.sh
│ └── local_invoke_sync_lambda.py
├── src/
│ ├── config/config.py
│ ├── gcal/
│ ├── notion/
│ ├── sync/sync.py
│ └── utils/
│ ├── ssm_secrets.py
│ └── token_crypto.py
└── test/
- Local secrets (
.env.local) are gitignored. config/local.mapping-domain.jsonis gitignored.token/is deprecated and ignored.- Cloud secret inputs are SSM path env vars, not plaintext secret env values.
- Cloud token payloads in DynamoDB should stay
enc:v1:encrypted at rest. - Do not log tokens or secret values.
- Dev deploy:
.github/workflows/deploy-dev-lambda.yml- Trigger: push to
dev - Runs validation (format/lint/unit tests/coverage/secret checks/workflow guardrails) before deploy
- Builds and pushes image, then updates dev Lambda
- Trigger: push to
- Release:
.github/workflows/release-semantic.yml- Trigger: push to
master - Runs validation before semantic release
- Creates Git tag and GitHub Release only
- Trigger: push to
- Production Lambda deploy workflow exists under
.github/workflows/disabled/and is currently disabled/manual.
docs/local-dev-sync-runner.md: detailed local/cloud runner behavior and troubleshootingdocs/deployment.md: CI/CD and environment-level deployment policy