Skip to content

Commit

Permalink
feat: added support for state property storage
Browse files Browse the repository at this point in the history
  • Loading branch information
bflorian committed Oct 3, 2023
1 parent c4cf068 commit da688df
Show file tree
Hide file tree
Showing 12 changed files with 17,020 additions and 66 deletions.
44 changes: 44 additions & 0 deletions .github/workflows/ci-cd.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: CI/CD

on:
push:
pull_request:
workflow_dispatch:
branches:
- main

jobs:
lint-commits:
if: github.event_name == 'pull_request'

runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: actions/setup-node@v3
with:
node-version: 18
cache: "npm"
- run: npm ci --ignore-scripts
- run: npx commitlint --from HEAD~${{ github.event.pull_request.commits }} --to HEAD

build:
runs-on: ubuntu-latest

strategy:
matrix:
# add/remove versions as we move support forward
node-version: [16, 18]

steps:
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: "npm"
- run: npm ci
- run: npm run lint
- run: npm run test
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,6 @@ typings/
# next.js build output
.next

.DS_Store
test/data

.DS_Store
13 changes: 13 additions & 0 deletions .releasesrc.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
plugins:
- "@semantic-release/commit-analyzer"
- "@semantic-release/release-notes-generator"
- "@semantic-release/github"
- - "@semantic-release/changelog"
- changelogFile: docs/CHANGELOG.md
- "@semantic-release/npm"
- - "@semantic-release/git"
- assets: ["docs", "package.json"]
message: "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
preset: conventionalcommits
branches:
- main
19 changes: 16 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ The context stored by this module consists of the following data elements:

* **installedAppId**: the UUID of the installed app instance. This is the primary key of the table.
* **locationId**: the UUID of the location in which the app is installed
* **locale**: the locale client used to install the app
* **authToken**: the access token used in calling the API
* **refreshToken**: the refresh token used in generating a new access token when one expires
* **config**: the current installed app instance configuration, i.e. selected devices, options, etc.
Expand All @@ -22,7 +23,7 @@ Create a `FileContextStore` object and pass it to the SmartApp connector to stor
directory on the local machine.

```javascript
smartapp.contextStore(new DynamoDBContextStore())
smartapp.contextStore(new FileContextStore())
```

The default storage is in a directory named `data` in the project location.
Expand All @@ -33,10 +34,12 @@ constructor.
smartapp.contextStore(new FileContextStore('/opt/data/smartapp'))
```

## Storage Format
## Storage Formats

### Installed App Context

Each installedApp instance context record is stored as a JSON string in a file with the name
`<installedAppId>.json`. For example:
`<installedAppId>.json` in the data directory. For example:
```json
{
"installedAppId": "b643d57e-e2eb-40e4-b2ef-ff43519941cc",
Expand Down Expand Up @@ -73,6 +76,16 @@ Each installedApp instance context record is stored as a JSON string in a file w
}
}
```

### State Storage

State storage is a name-value store for the installed app instance. This is useful for storing information
between invocations of the SmartApp. Each state property is stored in a separate file with the name
`<installedAppId>/<stateName>.json` in the data directory. For example a numeric state property named
`count` with a value of `5` would be stored in a file named `b643d57e-e2eb-40e4-b2ef-ff43519941cc/count.json`:
```json
5
```
## Caveats

This data store is intended for development testing use only, not in a production
Expand Down
5 changes: 5 additions & 0 deletions config/jest.setup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/* eslint no-undef: 'off' */

process.on('unhandledRejection', error => {
fail(error)
})
8 changes: 8 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
module.exports = {
collectCoverageFrom: ['lib/**/*.js'],
coverageReporters: ['json', 'text'],
testEnvironment: 'node',
testPathIgnorePatterns: ['test/data'],
testMatch: ['**/test/**/*.[jt]s?(x)'],
setupFiles: ['<rootDir>/config/jest.setup.js'],
}
20 changes: 20 additions & 0 deletions lib/file-context-store.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export interface ContextObject {
installedAppId: string;
locationId: string
locale: string;
authToken: string;
refreshToken: string;
config: any;
state: any;
}

export interface FileContextStore {
get(installedAppId: string): Promise<ContextObject>;
put(installedAppId: string, context: ContextObject): Promise<void>;
update(installedAppId: string, context: Partial<ContextObject>): Promise<void>;
delete(installedAppId: string): Promise<void>;
getItem(installedAppId: string, key: string): Promise<any>;
putItem(installedAppId: string, key: string, value: any): Promise<void>;
removeItem(installedAppId: string, key: string): Promise<void>;
removeAllItems(installedAppId: string): Promise<void>;
}
171 changes: 115 additions & 56 deletions lib/file-context-store.js
Original file line number Diff line number Diff line change
@@ -1,75 +1,49 @@
'use strict'

const path = require('path')
const fs = require('fs')
const encoding = 'utf-8'

module.exports = class FileContextStore {
const fsp = fs.promises
const encoding = 'utf8'

module.exports = class FileContextStore {
/**
* Create a context store instance
* Create a context store instance. The directory will be created if it does not exist.
*/
constructor(directory = 'data') {
this.directory = directory
if (!fs.existsSync(this.directory)) {
try {
fs.mkdirSync(this.directory)
} catch (error) {
if (error.code !== 'EEXIST') {
throw error
}
}
}

/**
* Return the filename of the context store file for the specified installed app instance
* @param installedAppId
* @returns string
*/
filename(installedAppId) {
return path.join(this.directory, `${installedAppId}.json`)
}

/**
* Read the context of the installed app instance
* @param installedAppId
* @returns {Promise<ContextRecord>}
* @returns {Promise<ContextObject>}
*/
get(installedAppId) {
return new Promise((resolve, reject) => {
fs.exists(this.filename(installedAppId), (exists) => {
if (exists) {
fs.readFile(this.filename(installedAppId), encoding, (err, data) => {
if (err) {
reject(err)
} else {
resolve(JSON.parse(data))
}
})
} else {
resolve({})
}
})
})
async get(installedAppId) {
const data = await fsp.readFile(this.filename(installedAppId), encoding)
return JSON.parse(data)
}

/**
* Puts the data into the context store
* @param {ContextRecord} params Context object
* @returns {Promise<AWS.Request<AWS.DynamoDB.GetItemOutput, AWS.AWSError>>|Promise<Object>}
* @param {ContextObject} params Context object
* @returns {Promise<ContextObject>}
*/
put(params) {
return new Promise((resolve, reject) => {
fs.writeFile(this.filename(params.installedAppId), JSON.stringify(params, null, 2), encoding, (err, data) => {
if (err) {
reject(err)
} else {
resolve(params)
}
})
})
async put(params) {
await fsp.writeFile(this.filename(params.installedAppId), JSON.stringify({...params, state: {}}, null, 2), encoding)
return params
}

/**
* Updates the data in the context store by `installedAppId`
* @param {String} installedAppId Installed app identifier
* @param {ContextRecord} params Context object
* @returns {Promise<AWS.Request<AWS.DynamoDB.GetItemOutput, AWS.AWSError>>|Promise<Object>}
* @param {ContextObject} params Context object
* @returns {Promise<void>}
*/
async update(installedAppId, params) {
const record = await this.get(installedAppId)
Expand All @@ -82,17 +56,102 @@ module.exports = class FileContextStore {
/**
* Delete the row from the table
* @param {String} installedAppId Installed app identifier
* @returns {Promise<AWS.Request<AWS.DynamoDB.GetItemOutput, AWS.AWSError>>|Promise<Object>}
* @returns {Promise<void>}
*/
async delete(installedAppId) {
await fsp.unlink(this.filename(installedAppId))
await this.deleteStateDir(installedAppId)
}

/**
* Set the value of the key in the context store state property
* @param installedAppId the installed app identifier
* @param key the name of the property to set
* @param value the value to set
* @returns {Promise<*>}
*/
delete(installedAppId) {
return new Promise((resolve, reject) => {
fs.unlink(this.filename(installedAppId), (err) => {
if (err) {
reject(err)
} else {
resolve()
async setItem(installedAppId, key, value) {
const filePath = path.join(this.directory, installedAppId, `${key}.json`)
await fsp.mkdir(path.join(this.directory, installedAppId), {recursive: true})
await fsp.writeFile(filePath, JSON.stringify(value), encoding)
}

/**
* Get the value of the key from the context store state property
* @param installedAppId the installed app identifier
* @param key the name of the property to retrieve
* @returns {Promise<*>}
*/
async getItem(installedAppId, key) {
try {
const filePath = path.join(this.directory, installedAppId, `${key}.json`)
const data = await fsp.readFile(filePath, encoding)
return JSON.parse(data)
} catch (error) {
if (error.code === 'ENOENT') {
return undefined
}
throw error
}
}

/**
* Remove the key from the context store state property
* @param installedAppId the installed app identifier
* @param key the name of the property to remove
* @returns {Promise<void>}
*/
async removeItem(installedAppId, key) {
const filePath = path.join(this.directory, installedAppId, `${key}.json`)
try {
await fsp.unlink(filePath)
} catch (error) {
if (error.code !== 'ENOENT') {
throw error
}
}
}

/**
* Clear the context store state property
* @param installedAppId the installed app identifier
* @returns {Promise<void>}
*/
async removeAllItems(installedAppId) {
await this.deleteStateDir(installedAppId)
}

/**
* Return the filename of the context store file for the specified installed app instance
* @param installedAppId
* @returns string
*/
filename(installedAppId) {
return path.join(this.directory, `${installedAppId}.json`)
}

async deleteStateDir(installedAppId) {
try {
const directoryPath = path.join(this.directory, installedAppId)
await fsp.access(directoryPath)

const files = await fsp.readdir(directoryPath)
await Promise.all(files.map(async file => {
const filePath = path.join(directoryPath, file)
const stat = await fsp.stat(filePath)

if (!stat.isDirectory()) {
// Delete file
await fsp.unlink(filePath)
}
})
})
}))

// Delete the directory itself
await fsp.rmdir(directoryPath)
} catch (error) {
if (error.code !== 'ENOENT') {
throw error
}
}
}
}
Loading

0 comments on commit da688df

Please sign in to comment.