summaryrefslogtreecommitdiffstats
path: root/acf2
diff options
context:
space:
mode:
Diffstat (limited to 'acf2')
-rw-r--r--acf2/error.lua99
-rw-r--r--acf2/init.lua18
-rw-r--r--acf2/loader.lua23
-rw-r--r--acf2/model/aaa.lua87
-rw-r--r--acf2/model/combination.lua50
-rw-r--r--acf2/model/field.lua241
-rw-r--r--acf2/model/init.lua205
-rw-r--r--acf2/model/model.lua204
-rw-r--r--acf2/model/net.lua96
-rw-r--r--acf2/model/node.lua275
-rw-r--r--acf2/model/permission.lua22
-rw-r--r--acf2/model/root.lua91
-rw-r--r--acf2/model/set.lua47
-rw-r--r--acf2/modules/awall.lua149
-rw-r--r--acf2/modules/generic.lua14
-rw-r--r--acf2/modules/net.lua25
-rw-r--r--acf2/object.lua68
-rw-r--r--acf2/path.lua111
-rw-r--r--acf2/persistence/backends/augeas.lua153
-rw-r--r--acf2/persistence/backends/files.lua107
-rw-r--r--acf2/persistence/backends/json.lua79
-rw-r--r--acf2/persistence/backends/null.lua10
-rw-r--r--acf2/persistence/backends/volatile.lua46
-rw-r--r--acf2/persistence/init.lua79
-rw-r--r--acf2/persistence/util.lua22
-rw-r--r--acf2/transaction/backend.lua70
-rw-r--r--acf2/transaction/init.lua210
-rw-r--r--acf2/util.lua84
28 files changed, 2685 insertions, 0 deletions
diff --git a/acf2/error.lua b/acf2/error.lua
new file mode 100644
index 0000000..14a2570
--- /dev/null
+++ b/acf2/error.lua
@@ -0,0 +1,99 @@
+--[[
+Copyright (c) 2012-2013 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = {}
+
+local object = require('acf2.object')
+local class = object.class
+
+local util = require('acf2.util')
+
+local json = require('cjson')
+
+
+local ErrorTable = class()
+
+function ErrorTable:init() self.errors = {} end
+
+function ErrorTable:success() return not next(self.errors) end
+
+function ErrorTable:raise()
+ if not self:success() then error(json.encode(self.errors)) end
+end
+
+
+local ErrorList = class(ErrorTable)
+
+function ErrorList:init(label)
+ object.super(self, ErrorList):init()
+ self.label = label
+end
+
+function ErrorList:insert(msg)
+ table.insert(util.setdefault(self.errors, self.label, {}), msg)
+end
+
+
+M.ErrorDict = class(ErrorTable)
+
+function M.ErrorDict:collect(func, ...)
+ local function pack(success, ...)
+ local arg = {...}
+ return success, success and arg or arg[1]
+ end
+
+ local arg = {...}
+ local success, res = pack(
+ xpcall(
+ function() return func(unpack(arg)) end,
+ function(err)
+ local _, _, data = err:find('.-: (.+)')
+ local success, res = pcall(json.decode, data)
+ if success and type(res) == 'table' then return res end
+ return data..'\n'..debug.traceback()
+ end
+ )
+ )
+
+ if success then return unpack(res) end
+
+ if type(res) == 'table' then
+ for label, errors in pairs(res) do
+ for _, err in ipairs(errors) do
+ table.insert(util.setdefault(self.errors, label, {}), err)
+ end
+ end
+ else error(res) end
+end
+
+
+function M.raise(label, msg)
+ local err = ErrorList(label)
+ err:insert(msg)
+ err:raise()
+end
+
+function M.relabel(label, ...)
+ local err = M.ErrorDict()
+ local res = {err:collect(...)}
+ if err:success() then return unpack(res) end
+
+ elist = ErrorList(label)
+ for lbl, el in pairs(err.errors) do
+ for _, e in ipairs(el) do elist:insert(lbl..': '..e) end
+ end
+ elist:raise()
+end
+
+function M.call(...)
+ local err = M.ErrorDict()
+ local res = {err:collect(...)}
+ if err:success() then return true, unpack(res) end
+ if err.errors.system then error(err.errors.system[1]) end
+ return false, err.errors
+end
+
+
+return M
diff --git a/acf2/init.lua b/acf2/init.lua
new file mode 100644
index 0000000..eef3ff0
--- /dev/null
+++ b/acf2/init.lua
@@ -0,0 +1,18 @@
+--[[
+Copyright (c) 2012-2013 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = {}
+
+M.model = require('acf2.model')
+
+require('acf2.model.aaa')
+require('acf2.loader')('modules')
+
+M.call = require('acf2.error').call
+M.object = require('acf2.object')
+M.path = require('acf2.path')
+M.start_txn = require('acf2.transaction')
+
+return M
diff --git a/acf2/loader.lua b/acf2/loader.lua
new file mode 100644
index 0000000..71aeabf
--- /dev/null
+++ b/acf2/loader.lua
@@ -0,0 +1,23 @@
+--[[
+Copyright (c) 2012-2013 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+module(..., package.seeall)
+
+local pth = require('acf2.path')
+
+local posix = require('posix')
+local stringy = require('stringy')
+
+return function(subdir)
+ local comps = pth.split('acf2/'..subdir)
+ local res = {}
+ for _, modfile in ipairs(posix.dir(pth.join(unpack(comps)))) do
+ if stringy.endswith(modfile, '.lua') then
+ local name = modfile:sub(1, -5)
+ res[name] = require(table.concat(comps, '.')..'.'..name)
+ end
+ end
+ return res
+ end
diff --git a/acf2/model/aaa.lua b/acf2/model/aaa.lua
new file mode 100644
index 0000000..c8a8328
--- /dev/null
+++ b/acf2/model/aaa.lua
@@ -0,0 +1,87 @@
+--[[
+Copyright (c) 2012-2013 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = require('acf2.model')
+local object = require('acf2.object')
+
+local digest = require('crypto').digest
+
+
+Role = M.new()
+Role.permissions = M.Set{type=M.Reference{scope='../../../permissions'}}
+
+
+local function hash_password(algorithm, salt, password)
+ return algorithm..'$'..salt..'$'..digest(algorithm, salt..password)
+end
+
+local hash_pattern = '^(%w+)%$(%w+)%$%x+$'
+
+
+local Password = object.class(M.String)
+
+function Password:_validate(context, value)
+ value = object.super(self, M.String):_validate(context, value)
+ if not value or value:find(hash_pattern) then return value end
+
+ local salt = ''
+ for i = 1,12 do
+ local c = math.random(48, 109)
+ if c > 57 then c = c + 7 end
+ if c > 90 then c = c + 6 end
+ salt = salt..string.char(c)
+ end
+ return hash_password('sha256', salt, value)
+end
+
+
+User = M.new()
+User.password = Password
+User['real-name'] = M.String
+User.superuser = M.Boolean{default=false}
+User.roles = M.Set{type=M.Reference{scope='../../../roles'}}
+
+function User:check_password(password)
+ if not self.password then return false end
+ local _, _, algorithm, salt = self.password:find(hash_pattern)
+ if not salt then return false end
+ return hash_password(algorithm, salt, password) == self.password
+end
+
+function User:check_permission(permission)
+ -- TODO audit trail
+ print('check permission', permission)
+
+ if self.superuser then return true end
+
+ assert(getmetatable(self).txn:fetch('/auth/permissions')[permission])
+
+ for _, role in M.node.pairs(self.roles, true) do
+ for _, p in M.node.pairs(role.permissions, true) do
+ if p == permission then return true end
+ end
+ end
+ return false
+end
+
+
+Authentication = M.new()
+Authentication.users = M.Collection{type=User}
+Authentication.roles = M.Collection{type=Role}
+Authentication.permissions = M.Set{
+ type=M.String,
+ addr='/volatile/aaa/permissions'
+}
+
+M.register(
+ 'auth',
+ Authentication,
+ {
+ addr='/json'..require('posix').getcwd()..'/config/aaa.json',
+ ui_name='Authentication'
+ }
+)
+
+M.permission.defaults('/auth')
diff --git a/acf2/model/combination.lua b/acf2/model/combination.lua
new file mode 100644
index 0000000..28c1570
--- /dev/null
+++ b/acf2/model/combination.lua
@@ -0,0 +1,50 @@
+--[[
+Copyright (c) 2012-2013 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = {}
+
+local err = require('acf2.error')
+local raise = err.raise
+
+local fld = require('acf2.model.field')
+local String = fld.String
+
+local to_field = require('acf2.model.model').to_field
+
+local object = require('acf2.object')
+local class = object.class
+local super = object.super
+
+
+local stringy = require('stringy')
+
+
+M.Range = class(String)
+
+function M.Range:init(params)
+ super(self, M.Range):init(params)
+ if not self.type then self.type = fld.Integer end
+end
+
+function M.Range:validate(context, value)
+ local comps = stringy.split(value, '-')
+ if #comps > 2 then raise(context.path, 'Invalid range') end
+ for _, v in ipairs(comps) do to_field(self.type):_validate(context, v) end
+end
+
+
+M.Union = class(String)
+
+function M.Union:validate(context, value)
+ super(self, M.Union):validate(context, value)
+ for _, tpe in ipairs(self.types) do
+ local field = to_field(tpe)
+ if err.call(field.validate, field, context, value) then return end
+ end
+ raise(context.path, self.error or 'Invalid value')
+end
+
+
+return M
diff --git a/acf2/model/field.lua b/acf2/model/field.lua
new file mode 100644
index 0000000..6cbfa96
--- /dev/null
+++ b/acf2/model/field.lua
@@ -0,0 +1,241 @@
+--[[
+Copyright (c) 2012-2013 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = {}
+
+local err = require('acf2.error')
+local raise = err.raise
+
+local node = require('acf2.model.node')
+
+local object = require('acf2.object')
+local class = object.class
+local super = object.super
+
+local util = require('acf2.util')
+
+
+local function contains(list, value)
+ for k, v in ipairs(list) do if v == value then return true end end
+ return false
+end
+
+M.Member = class()
+
+function M.Member:init(params)
+ for k, v in pairs(params or {}) do
+ if self[k] == nil then self[k] = v end
+ end
+end
+
+function M.Member:auto_ui_name(name)
+ if not name then return end
+ return (name:sub(1, 1):upper()..name:sub(2)):gsub('-', ' ')
+end
+
+function M.Member:meta(context)
+ return {
+ name=self.name,
+ description=self.description,
+ ['ui-name']=self.ui_name or self:auto_ui_name(self.name)
+ }
+end
+
+
+M.Field = class(M.Member)
+
+function M.Field:init(params)
+ super(self, M.Field):init(params)
+
+ if self.choice and not self['ui-choice'] then
+ self['ui-choice'] = util.map(
+ function(name) return self:auto_ui_name(name) end,
+ self.choice
+ )
+ end
+
+ if not self.widget then
+ self.widget = self.choice and 'combobox' or 'field'
+ end
+end
+
+function M.Field:meta(context)
+ assert(self.dtype)
+ local res = super(self, M.Field):meta(context)
+
+ res.type = self.dtype
+ res.required = self.required
+ res.default = self.default
+ res.choice = self.choice
+ res.widget = self.widget
+ res['ui-choice'] = self['ui-choice']
+
+ return res
+end
+
+function M.Field:topology(context)
+ return {
+ {path=context.path, addr=context.addr, type=self.dtype}
+ }
+end
+
+function M.Field:load(context)
+ if not context.txn then return setmetatable({}, context) end
+ local value = context.txn:get(context.addr)
+ if value == nil then return self.default end
+ return value
+end
+
+function M.Field:_validate(context, value)
+ if self.required and value == nil then
+ raise(context.path, 'Required value not set')
+ end
+ if self.choice and value ~= nil and not contains(self.choice, value) then
+ raise(context.path, 'Invalid value')
+ end
+ if value ~= nil then self:validate(context, value) end
+ return value
+end
+
+function M.Field:validate(context, value) end
+
+function M.Field:save(context, value)
+ context.txn:set(context.addr, self:_validate(context, value))
+end
+
+function M.Field:validate_saved(context)
+ self:save(context, self:load(context))
+end
+
+
+local Primitive = class(M.Field)
+
+function Primitive:validate(context, value)
+ local t = self.dtype
+ if type(value) ~= t then raise(context.path, 'Not a '..t) end
+end
+
+
+M.String = class(Primitive)
+
+function M.String:init(params)
+ super(self, M.String):init(params)
+ self.dtype = 'string'
+end
+
+function M.String:validate(context, value)
+ super(self, M.String):validate(context, value)
+ if self['max-length'] and value:len() > self['max-length'] then
+ raise(context.path, 'Maximum length exceeded')
+ end
+ if self.pattern and not value:match('^'..self.pattern..'$') then
+ raise(context.path, 'Invalid value')
+ end
+end
+
+function M.String:meta(context)
+ local res = super(self, M.String):meta(context)
+ res['max-length'] = self['max-length']
+ return res
+end
+
+
+M.Number = class(Primitive)
+
+function M.Number:init(params)
+ super(self, M.Number):init(params)
+ self.dtype = 'number'
+end
+
+function M.Number:_validate(context, value)
+ return super(self, M.Number):_validate(
+ context,
+ value and tonumber(value) or value
+ )
+end
+
+
+M.Integer = class(M.Number)
+
+function M.Integer:validate(context, value)
+ super(self, M.Integer):validate(context, value)
+ if math.floor(value) ~= value then raise(context.path, 'Not an integer') end
+end
+
+
+M.Boolean = class(Primitive)
+
+function M.Boolean:init(params)
+ super(self, M.Boolean):init(params)
+ self.dtype = 'boolean'
+ self.widget = self.dtype
+end
+
+
+M.TreeNode = class(M.Field)
+
+function M.TreeNode:init(params)
+ if not params.widget then params.widget = 'link' end
+ super(self, M.TreeNode):init(params)
+end
+
+function M.TreeNode:topology(context)
+ local res = super(self, M.TreeNode):topology(context)
+ res[1].type = 'table'
+ util.extend(res, node.topology(self:load(context, {create=true})))
+ return res
+end
+
+function M.TreeNode:load(context, options)
+ if context.txn and not (
+ (
+ options and options.create
+ ) or self.create or context.txn:get(context.addr)
+ ) then return end
+ return self.itype(context, self.iparams)
+end
+
+function M.TreeNode:save(context, value)
+ local path = context.path
+
+ if value == path then return end
+ if type(value) == 'string' then value = context.txn:fetch(value) end
+ if object.isinstance(value, node.TreeNode) and node.path(value) == path then
+ return
+ end
+
+ context.txn:set(context.addr)
+
+ if value then
+ if type(value) ~= 'table' then
+ raise(path, 'Cannot assign primitive value')
+ end
+
+ context.txn:set(context.addr, {})
+ local new = self:load(context, {create=true})
+
+ local errors = err.ErrorDict()
+ for k, v in node.pairs(value) do
+ errors:collect(self.save_member, new, k, v)
+ end
+ errors:raise()
+ end
+end
+
+function M.TreeNode.save_member(node, k, v) node[k] = v end
+
+
+M.Model = class(M.TreeNode)
+
+function M.Model:init(params)
+ super(self, M.Model):init(params)
+
+ assert(self.model)
+ self.itype = self.model
+ self.dtype = 'model'
+end
+
+
+return M
diff --git a/acf2/model/init.lua b/acf2/model/init.lua
new file mode 100644
index 0000000..51cdbe8
--- /dev/null
+++ b/acf2/model/init.lua
@@ -0,0 +1,205 @@
+--[[
+Copyright (c) 2012-2013 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = {}
+
+M.error = require('acf2.error')
+local raise = M.error.raise
+
+local combination = require('acf2.model.combination')
+M.Union = combination.Union
+M.Range = combination.Range
+
+local fld = require('acf2.model.field')
+local Field = fld.Field
+M.Boolean = fld.Boolean
+M.Integer = fld.Integer
+M.Number = fld.Number
+M.String = fld.String
+
+local model = require('acf2.model.model')
+M.Action = model.Action
+M.new = model.new
+local to_field = model.to_field
+
+M.net = require('acf2.model.net')
+
+local node = require('acf2.model.node')
+M.node = {}
+for _, m in ipairs{
+ 'List',
+ 'Set',
+ 'TreeNode',
+ 'has_permission',
+ 'insert',
+ 'meta',
+ 'mmeta',
+ 'path',
+ 'pairs',
+ 'ipairs'
+} do M.node[m] = node[m] end
+
+M.permission = require('acf2.model.permission')
+M.register = require('acf2.model.root').register
+M.node.Set = require('acf2.model.set').Set
+
+local object = require('acf2.object')
+local class = object.class
+local isinstance = object.isinstance
+local super = object.super
+
+local pth = require('acf2.path')
+local map = require('acf2.util').map
+
+
+local stringy = require('stringy')
+
+
+M.Reference = class(Field)
+
+function M.Reference:init(params)
+ if not params.widget then params.widget = 'reference' end
+ super(self, M.Reference):init(params)
+ self.dtype = 'reference'
+ if not self.scope then self.scope = '/' end
+end
+
+function M.Reference:topology(context)
+ local res = super(self, M.Reference):topology(context)
+ res[1].scope = self.scope
+ return res
+end
+
+function M.Reference:abs_scope(context)
+ return pth.to_absolute(self.scope, node.path(context.parent))
+end
+
+function M.Reference:meta(context)
+ local res = super(self, M.Reference):meta(context)
+ res.scope = self:abs_scope(context)
+ return res
+end
+
+function M.Reference:follow(context, value)
+ return context.txn:fetch(pth.rawjoin(self:abs_scope(context), value))
+end
+
+function M.Reference:load(context, options)
+ local ref = super(self, M.Reference):load(context)
+ return (
+ (not options or options.dereference ~= false) and context.txn and ref
+ ) and self:follow(context, ref) or ref
+end
+
+function M.Reference:_validate(context, value)
+ super(self, M.Reference):_validate(context, value)
+
+ if value == nil then return end
+
+ if isinstance(value, node.TreeNode) then value = node.path(value) end
+
+ local path = context.path
+ if type(value) ~= 'string' then raise(path, 'Path name must be string') end
+
+ if pth.is_absolute(value) then
+ local scope = self:abs_scope(context)
+ local prefix = scope..'/'
+ if not stringy.startswith(value, prefix) then
+ raise(path, 'Reference out of scope ('..scope..')')
+ end
+ value = value:sub(prefix:len() + 1, -1)
+ end
+
+ -- assume one-level ref for now
+ if #pth.split(value) > 1 then
+ raise(path, 'Subtree references not yet supported')
+ end
+
+ -- TODO check instance type
+ M.error.relabel(path, self.follow, self, context, value)
+
+ return value
+end
+
+function M.Reference:deleted(context, addr)
+ local target = self:load(context)
+ if target and node.addr(target) == addr then
+ -- TODO raise error for the target object
+ raise(context.path, 'Refers to '..addr)
+ end
+end
+
+
+M.Model = fld.Model
+
+
+M.Collection = class(fld.TreeNode)
+
+function M.Collection:init(params, itype)
+ if params.create == nil then params.create = true end
+ super(self, M.Collection):init(params)
+
+ assert(self.type)
+ self.itype = itype or node.Collection
+ self.iparams = {
+ destroy=self.destroy,
+ layout=self.layout,
+ required=self.required,
+ ui_member=self.ui_member
+ }
+
+ self.dtype = 'collection'
+end
+
+function M.Collection:auto_ui_name(name)
+ if not name then return end
+ if name:sub(-1, -1) ~= 's' then name = name..'s' end
+ return super(self, M.Collection):auto_ui_name(name)
+end
+
+function M.Collection:load(context, options)
+ if not self.iparams.field then self.iparams.field = to_field(self.type) end
+ return super(self, M.Collection):load(context, options)
+end
+
+
+M.List = class(M.Collection)
+function M.List:init(params) super(self, M.List):init(params, node.List) end
+
+
+M.Set = class(M.Collection)
+function M.Set:init(params)
+ if not params.widget and isinstance(params.type, M.Reference) then
+ params.widget = 'checkboxes'
+ end
+ super(self, M.Set):init(params, M.node.Set)
+end
+function M.Set.save_member(tn, k, v) node.insert(tn, v) end
+
+
+-- experimental
+M.Mixed = class(M.Collection)
+
+function M.Mixed:init(params)
+ params.type = M.Mixed
+ super(self, M.Mixed):init(params, node.Mixed)
+ self.pfield = Field()
+end
+
+function M.Mixed:topology(context) return {} end
+
+function M.Mixed:load(context)
+ local value = self.pfield:load(context)
+ if type(value) == 'table' then return super(self, M.Mixed):load(context) end
+ return value
+end
+
+function M.Mixed:save(context, value)
+ if type(value) == 'table' then super(self, M.Mixed):save(context, value)
+ else self.pfield:save(context, value) end
+end
+
+
+return M
diff --git a/acf2/model/model.lua b/acf2/model/model.lua
new file mode 100644
index 0000000..dc506b3
--- /dev/null
+++ b/acf2/model/model.lua
@@ -0,0 +1,204 @@
+--[[
+Copyright (c) 2012-2013 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = {}
+
+local raise = require('acf2.error').raise
+
+local fld = require('acf2.model.field')
+local Field = fld.Field
+local Member = fld.Member
+
+local node = require('acf2.model.node')
+local BoundMember = node.BoundMember
+
+local object = require('acf2.object')
+local class = object.class
+local super = object.super
+local isinstance = object.isinstance
+
+local pth = require('acf2.path')
+local util = require('acf2.util')
+
+
+local function to_member(obj, params)
+ if not params then params = {} end
+ if object.issubclass(obj, M.Model) then
+ params.model = obj
+ return fld.Model(params)
+ end
+ local res = getmetatable(obj).class and obj or obj(params)
+ assert(isinstance(res, Member))
+ return res
+end
+
+function M.to_field(obj, params)
+ local res = to_member(obj, params)
+ assert(isinstance(res, Field))
+ return res
+end
+
+
+M.Action = class(Member)
+
+function M.Action:init(params)
+ super(self, M.Action):init(params)
+
+ if not self.func then error('Function not defined for action') end
+
+ if self.field then
+ assert(type(self.field) == 'table')
+ self.field = M.to_field(self.field)
+ self.field.addr = '/null/action'
+ end
+
+ getmetatable(self).__newindex = function(t, k, v)
+ assert(k == 'name')
+ rawset(t, k, v)
+ if t.field then t.field.name = v end
+ end
+end
+
+function M.Action:meta(context)
+ local res = super(self, M.Action):meta(context)
+ if self.field then res.arg = self.field:meta(context) end
+ return res
+end
+
+
+function M.new(base)
+ if not base then base = M.Model end
+
+ local res = class(base)
+ res.members = base == node.TreeNode and {} or util.copy(base.members)
+
+ local mt = util.copy(getmetatable(res))
+
+ function mt.__index(t, k) return base[k] end
+
+ function mt.__newindex(t, k, v)
+ assert(v)
+
+ local override = t[k]
+ if type(v) == 'table' then v = to_member(v) end
+
+ rawset(t, k, v)
+
+ if isinstance(v, Member) then
+ v.name = k
+ if not override then table.insert(t.members, k) end
+ end
+ end
+
+ setmetatable(res, mt)
+
+ if isinstance(base, M.Model) then util.setdefaults(res, base) end
+
+ return res
+end
+
+M.Model = M.new(node.TreeNode)
+
+function M.Model:init(context)
+ super(self, M.Model):init(context)
+
+ local mt = getmetatable(self)
+
+ function mt.member(name, loose, tpe)
+ local m = mt.class[name]
+
+ if not tpe then tpe = Member end
+ if not isinstance(m, tpe) then m = nil end
+
+ if m == nil then
+ if loose then return end
+ raise(mt.path, 'Does not exist: '..name)
+ end
+
+ return BoundMember(self, name, m)
+ end
+
+ local function _members(tpe)
+ local res = {}
+ for _, name in ipairs(self.members) do
+ local m = mt.member(name, true, tpe)
+ if m then table.insert(res, m) end
+ end
+ return res
+ end
+
+ function mt.topology()
+ local res = {}
+ for _, f in ipairs(_members(Field)) do util.extend(res, f:topology()) end
+ return res
+ end
+
+ function mt.load(k, options)
+ local v = mt.class[k]
+ local create = options and options.create
+
+ if isinstance(v, Field) then
+ v = BoundMember(self, k, v)
+ if v.compute then return v:compute() end
+ return v:load{create=create}
+ end
+
+ assert(mt.txn)
+
+ if isinstance(v, M.Action) then
+ local f = v.field and BoundMember(self, k, v.field)
+ if create then return f and f:load{create=true} end
+
+ return function(var)
+ if f then f:save(var)
+ elseif var ~= nil then
+ raise(
+ pth.join(mt.path, v.name),
+ 'Action does not accept an input argument'
+ )
+ end
+ local res = v.func(self, f and f:load())
+ if f then mt.txn:set(v.field.addr) end
+ return res
+ end
+ end
+
+ return v
+ end
+
+ if not mt.txn then return end
+
+
+ function mt.mmeta(name) return mt.member(name):meta() end
+
+ function mt.save(k, v) return mt.member(k, false, Field):save(v) end
+
+ local function tmeta(tpe)
+ return util.map(function(m) return m:meta() end, _members(tpe))
+ end
+
+ mt.meta.type = 'model'
+ mt.meta.fields = tmeta(Field)
+ mt.meta.actions = tmeta(M.Action)
+
+ function mt.members()
+ return util.map(function(f) return f.name end, mt.meta.fields)
+ end
+
+ function mt.validate()
+ for _, f in ipairs(_members(Field)) do
+ if not f.compute then f:validate_saved() end
+ end
+ end
+
+ if self.has_permission then
+ function mt.has_permission(user, permission)
+ return self:has_permission(user, permission)
+ end
+ end
+end
+
+
+return M
diff --git a/acf2/model/net.lua b/acf2/model/net.lua
new file mode 100644
index 0000000..27ab262
--- /dev/null
+++ b/acf2/model/net.lua
@@ -0,0 +1,96 @@
+--[[
+Copyright (c) 2012-2013 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = {}
+
+local raise = require('acf2.error').raise
+local Union = require('acf2.model.combination').Union
+
+local fld = require('acf2.model.field')
+local String = fld.String
+
+local object = require('acf2.object')
+local class = object.class
+local super = object.super
+
+local update = require('acf2.util').update
+
+
+local stringy = require('stringy')
+
+
+M.IPv4Address = class(String)
+
+function M.IPv4Address:validate(context, value)
+ super(self, M.IPv4Address):validate(context, value)
+ local function test(...)
+ if #{...} ~= 4 then return true end
+ for _, octet in ipairs{...} do
+ if tonumber(octet) > 255 then return true end
+ end
+ end
+ if test(value:match('^(%d+)%.(%d+)%.(%d+)%.(%d+)$')) then
+ raise(context.path, 'Invalid IPv4 address')
+ end
+end
+
+
+M.IPv6Address = class(String)
+
+function M.IPv6Address:validate(context, value)
+ super(self, M.IPv6Address):validate(context, value)
+
+ local function invalid() raise(context.path, 'Invalid IPv6 address') end
+
+ if value == '' then invalid() end
+
+ local comps = stringy.split(value, ':')
+ if #comps < 3 then invalid() end
+
+ local function collapse(i, ofs)
+ if comps[i] > '' then return end
+ if comps[i + ofs] > '' then invalid() end
+ table.remove(comps, i)
+ end
+ collapse(1, 1)
+ collapse(#comps, -1)
+ if #comps > 8 then invalid() end
+
+ local short = false
+ for _, comp in ipairs(comps) do
+ if comp == '' then
+ if short then invalid() end
+ short = true
+ elseif not comp:match('^%x%x?%x?%x?$') then invalid() end
+ end
+ if (
+ short and #comps == 3 and comps[2] == ''
+ ) or (not short and #comps < 8) then
+ invalid()
+ end
+end
+
+
+M.IPAddress = class(Union)
+
+function M.IPAddress:init(params)
+ super(self, M.IPAddress):init(
+ update(
+ params,
+ {types={M.IPv4Address, M.IPv6Address}, error='Invalid IP address'}
+ )
+ )
+end
+
+
+M.Port = class(fld.Integer)
+
+function M.Port:validate(context, value)
+ super(self, M.Port):validate(context, value)
+ if value < 0 or value > 65535 then raise(context.path, 'Invalid port') end
+end
+
+
+return M
diff --git a/acf2/model/node.lua b/acf2/model/node.lua
new file mode 100644
index 0000000..85fc416
--- /dev/null
+++ b/acf2/model/node.lua
@@ -0,0 +1,275 @@
+--[[
+Copyright (c) 2012-2013 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = {}
+
+local raise = require('acf2.error').raise
+
+local object = require('acf2.object')
+local class = object.class
+local isinstance = object.isinstance
+local super = object.super
+
+local pth = require('acf2.path')
+local util = require('acf2.util')
+
+
+M.BoundMember = class()
+
+function M.BoundMember:init(parent, name, field)
+ local pmt = getmetatable(parent)
+ local mt = {}
+
+ function mt.__index(t, k)
+ local member = field[k]
+ if type(member) ~= 'function' then return member end
+ return function(self, ...)
+ return member(
+ field,
+ {
+ txn=pmt.txn,
+ parent=parent,
+ path=pth.join(pmt.path, name),
+ addr=pth.to_absolute(
+ field.addr or pth.escape(name), pmt.addr
+ )
+ },
+ ...
+ )
+ end
+ end
+
+ setmetatable(self, mt)
+end
+
+
+M.TreeNode = class()
+
+function M.TreeNode:init(context)
+ local mt = getmetatable(self)
+ util.update(mt, context)
+
+ mt.dereference = true
+ mt.meta = {}
+ function mt.get(k, create) return mt.load(k, {create=create}) end
+
+ if not mt.txn then return end
+
+ if mt.parent then
+ mt.meta['ui-name'] = getmetatable(mt.parent).mmeta(
+ pth.name(mt.path)
+ )['ui-name']
+ end
+
+ function mt.save(k, v) rawset(self, k, v) end
+ function mt.__index(t, k) return mt.get(k) end
+ function mt.__newindex(t, k, v) mt.save(k, v) end
+
+ function mt.has_permission(user, permission)
+ local p = permission..mt.path
+ if mt.txn:fetch('/auth/permissions')[p] then
+ return user:check_permission(p)
+ end
+
+ if ({create=true, delete=true})[permission] then
+ permission = 'modify'
+ end
+ return M.has_permission(mt.parent, user, permission)
+ end
+
+ mt.txn.validable[mt.path] = mt.addr
+end
+
+function M.TreeNode:fetch(path, create)
+ if type(path) == 'string' then path = pth.split(path) end
+
+ if #path == 0 then return self end
+
+ local mt = getmetatable(self)
+ local name = path[1]
+ if not mt.member(name) then
+ raise(mt.path, 'Member does not exist: '..name)
+ end
+
+ local next = mt.get(name, create)
+ if next == nil and (not create or #path > 1) then
+ raise(mt.path, 'Subordinate does not exist: '..name)
+ end
+
+ table.remove(path, 1)
+ if #path > 0 and type(next) ~= 'table' then
+ raise(pth.join(mt.path, name), 'Is a primitive value')
+ end
+
+ return M.TreeNode.fetch(next, path, create)
+end
+
+function M.TreeNode:search_refs(path)
+ if type(path) == 'string' then path = pth.split(path) end
+
+ if #path == 0 then return {} end
+
+ local mt = getmetatable(self)
+ local name = path[1]
+ table.remove(path, 1)
+
+ local function collect(name)
+ local next = mt.load(name)
+ if not next then return {} end
+
+ local member = mt.member(name)
+ if member.deleted then return {member} end
+
+ return isinstance(next, M.TreeNode) and M.TreeNode.search_refs(
+ next, path
+ ) or {}
+ end
+
+ if name == pth.wildcard then
+ local res = {}
+ for _, member in ipairs(mt.members()) do
+ util.extend(res, collect(member))
+ end
+ return res
+ end
+
+ return collect(name)
+end
+
+
+M.Collection = class(M.TreeNode)
+
+function M.Collection:init(context, params)
+ super(self, M.Collection):init(context)
+
+ self.init = nil
+ self.fetch = nil
+ self.search_refs = nil
+
+ local mt = getmetatable(self)
+ local field = M.BoundMember(self, pth.wildcard, params.field)
+
+ function mt.topology() return field:topology() end
+
+ function mt.member(name)
+ return M.BoundMember(self, name, params.field)
+ end
+
+ function mt.load(k, options) return mt.member(k):load(options) end
+
+ if not mt.txn then return end
+
+ mt.meta.type = 'collection'
+ mt.meta.members = field:meta()
+ mt.meta['ui-member'] = params.ui_member or mt.meta['ui-name']:gsub('s$', '')
+ mt.meta.widget = params.layout
+
+ function mt.mmeta(name)
+ local res = util.copy(mt.meta.members)
+ if name ~= pth.wildcard then
+ res['ui-name'] = mt.meta['ui-member']..' '..name
+ end
+ return res
+ end
+
+ function mt.members() return mt.txn:get(mt.addr) or {} end
+
+ function mt.validate()
+ if #mt.members() > 0 then return end
+ if params.required then raise(mt.path, 'Collection cannot be empty') end
+ if params.destroy then
+ mt.txn:set(mt.addr)
+ validate(mt.parent)
+ end
+ end
+
+ function mt.save(k, v) mt.member(k):save(v) end
+end
+
+
+M.List = class(M.Collection)
+
+function M.List:init(context, params)
+ super(self, M.List):init(context, params)
+
+ local mt = getmetatable(self)
+ mt.meta.type = 'list'
+
+ local save = mt.save
+ function mt.save(k, v)
+ assert(type(k) == 'number')
+ if v == nil then
+ local len = #mt.members()
+ while k < len do
+ mt.save(k, mt.load(k + 1, {dereference=false}))
+ k = k + 1
+ end
+ end
+ save(k, v)
+ end
+
+ function mt.insert(v, i)
+ local len = #mt.members()
+ if not i then i = len + 1 end
+ for j = len,i,-1 do mt.save(j + 1, mt.load(j, {dereference=false})) end
+ mt.save(i, v)
+ end
+end
+
+
+-- experimental
+M.Mixed = class(M.Collection)
+
+function M.Mixed:init(context, params)
+ super(self, M.Mixed):init(context, params)
+
+ -- TODO dynamic meta: list non-leaf children
+ local mt = getmetatable(self)
+ mt.meta = {type='mixed', ['ui-name']=mt.path}
+ function mt.mmeta(name)
+ return {type='mixed', ['ui-name']=pth.join(mt.path, name)}
+ end
+end
+
+
+local function meta_func(attr)
+ return function(node, ...)
+ local res = getmetatable(node)[attr]
+ if type(res) == 'function' then return res(...) end
+ return res
+ end
+end
+
+for _, mf in ipairs{
+ 'addr', 'has_permission', 'insert', 'meta', 'mmeta', 'path', 'topology'
+} do M[mf] = meta_func(mf) end
+
+
+function M.pairs(tbl, dereference)
+ if not isinstance(tbl, M.TreeNode) then return pairs(tbl) end
+
+ local mt = getmetatable(tbl)
+ if dereference == nil then dereference = mt.dereference end
+
+ local res = {}
+ for _, member in ipairs(mt.members()) do
+ res[member] = mt.load(member, {dereference=dereference})
+ end
+ return pairs(res)
+end
+
+local function _ipairs(mt, i)
+ i = i + 1
+ local v = mt.load(i)
+ if v == nil then return end
+ return i, v
+end
+function M.ipairs(tbl)
+ if not isinstance(tbl, M.TreeNode) then return ipairs(tbl) end
+ return _ipairs, getmetatable(tbl), 0
+end
+
+
+return M
diff --git a/acf2/model/permission.lua b/acf2/model/permission.lua
new file mode 100644
index 0000000..71fa1a5
--- /dev/null
+++ b/acf2/model/permission.lua
@@ -0,0 +1,22 @@
+--[[
+Copyright (c) 2012-2013 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = {}
+
+local insert = require('acf2.model.node').insert
+local start_txn = require('acf2.transaction')
+
+function M.define(path, ...)
+ local txn = start_txn()
+ local db = txn:fetch('/auth/permissions')
+ for _, permission in ipairs{...} do insert(db, permission..path) end
+ txn:commit()
+end
+
+function M.defaults(path)
+ M.define(path, 'read', 'create', 'modify', 'delete')
+end
+
+return M
diff --git a/acf2/model/root.lua b/acf2/model/root.lua
new file mode 100644
index 0000000..61debec
--- /dev/null
+++ b/acf2/model/root.lua
@@ -0,0 +1,91 @@
+--[[
+Copyright (c) 2012-2013 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = {}
+
+local model = require('acf2.model.model')
+local node = require('acf2.model.node')
+local object = require('acf2.object')
+local pth = require('acf2.path')
+
+local util = require('acf2.util')
+local setdefault = util.setdefault
+
+
+M.RootModel = model.new()
+
+function M.RootModel:init(txn)
+ object.super(self, M.RootModel):init{txn=txn, path='/', addr='/null/root'}
+end
+
+function M.RootModel:has_permission(user, permission)
+ return permission == 'read'
+end
+
+function M.RootModel:meta(path)
+ local obj = self:fetch(path, true)
+ if object.isinstance(obj, node.TreeNode) then return node.meta(obj) end
+ return node.mmeta(self:fetch(pth.parent(path), true), pth.name(path))
+end
+
+
+local _topology = {}
+local order = 0
+
+function M.topology(addr, create)
+ local top = _topology
+ if type(addr) == 'table' then addr = util.copy(addr)
+ else addr = pth.split(addr) end
+
+ local function defaults(top)
+ return util.setdefaults(top, {members={}, paths={}, referrers={}})
+ end
+
+ while #addr > 0 do
+ if create then
+ top = setdefault(defaults(top).members, addr[1], {})
+ else
+ top = top.members[addr[1]] or top.members[pth.wildcard]
+ if not top then return end
+ end
+ table.remove(addr, 1)
+ end
+
+ return defaults(top)
+end
+
+function M.register(name, field, params)
+ if not params then params = {} end
+ params.create = true
+ M.RootModel[name] = model.to_field(field, params)
+
+ local root = M.RootModel()
+
+ for _, record in ipairs(node.topology(root:fetch(name))) do
+ local top = M.topology(record.addr, true)
+
+ setdefault(top, 'order', order)
+ order = order + 1
+
+ local function set(k, v)
+ setdefault(top, k, v)
+ assert(top[k] == v)
+ end
+
+ set('type', record.type)
+ table.insert(top.paths, record.path)
+
+ if record.scope then
+ local scope = node.addr(
+ root:fetch(pth.to_absolute(record.scope, pth.parent(record.path)))
+ )
+ set('scope', scope)
+ table.insert(M.topology(scope, true).referrers, record.path)
+ end
+ end
+end
+
+
+return M
diff --git a/acf2/model/set.lua b/acf2/model/set.lua
new file mode 100644
index 0000000..215056e
--- /dev/null
+++ b/acf2/model/set.lua
@@ -0,0 +1,47 @@
+--[[
+Copyright (c) 2012-2013 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = {}
+
+local TreeNode = require('acf2.model.field').TreeNode
+local npairs = require('acf2.model.node').pairs
+local object = require('acf2.object')
+
+
+M.Set = object.class(require('acf2.model.node').List)
+
+function M.Set:init(context, params)
+ assert(not object.isinstance(params.field, TreeNode))
+ object.super(self, M.Set):init(context, params)
+
+ local function find(value)
+ for i, member in npairs(self) do
+ if member == value then return i end
+ end
+ end
+
+ local mt = getmetatable(self)
+ mt.dereference = false
+ mt.meta.type = 'set'
+
+ function mt.get(k, create)
+ local i = find(k)
+ if i then return mt.load(i) end
+ if create then return k end
+ end
+
+ function mt.__newindex(t, k, v)
+ assert(v == nil)
+ local i = find(k)
+ if not i then return end
+ mt.save(i, nil)
+ end
+
+ local insert = mt.insert
+ function mt.insert(v) if not find(v) then insert(v) end end
+end
+
+
+return M
diff --git a/acf2/modules/awall.lua b/acf2/modules/awall.lua
new file mode 100644
index 0000000..071db57
--- /dev/null
+++ b/acf2/modules/awall.lua
@@ -0,0 +1,149 @@
+--[[
+Copyright (c) 2012-2013 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = require('acf2.model')
+local object = require('acf2.object')
+
+
+local Direction = object.class(M.String)
+function Direction:init(params)
+ if not params then params = {} end
+ params.choice = {'in', 'out'}
+ object.super(self, Direction):init(params)
+end
+
+
+-- TODO reference types?
+
+local IPSet = M.new()
+-- TODO choices
+IPSet.type = M.String{required=true}
+IPSet.family = M.String{required=true, choice={'inet', 'inet6'}}
+-- TODO only for bitmaps
+IPSet.range = M.Range{type=M.net.IPv4Address}
+
+local Service = M.new()
+Service.proto = M.String{required=true, ui_name='Protocol'}
+Service.port = M.Set{type=M.Range{type=M.net.Port}}
+Service['icmp-type'] = M.String{ui_name='ICMP type'}
+Service['ct-helper'] = M.String{ui_name='Connection tracking helper'}
+
+-- TODO fw zone
+
+local Zone = M.new()
+Zone.iface = M.Set{type=M.String, ui_name='Interfaces'}
+Zone.addr = M.Set{type=M.String, ui_name='Addresses'}
+Zone['route-back'] = M.Boolean{default=false}
+
+local LogClass = M.new()
+LogClass.mode = M.String{
+ required=true, default='log', choice={'log', 'nflog', 'ulog'}
+}
+LogClass.every = M.Integer{ui_name='Sampling frequency'}
+LogClass.limit = M.Integer
+LogClass.prefix = M.String
+LogClass.probability = M.Number
+LogClass.group = M.Integer
+LogClass.range = M.Integer
+LogClass.threshold = M.Integer
+
+local IPSetReference = M.new()
+IPSetReference.name = M.Reference{scope='/awall/ipset', required=true}
+IPSetReference.args = M.List{
+ type=Direction, required=true, ui_name='Arguments'
+}
+
+local Rule = M.new()
+Rule['in'] = M.Set{
+ type=M.Reference{scope='/awall/zone'}, ui_name='Ingress zones'
+}
+Rule.out = M.Set{
+ type=M.Reference{scope='/awall/zone'}, ui_name='Egress zones'
+}
+Rule.src = M.Set{type=M.String, ui_name='Sources'}
+Rule.dest = M.Set{type=M.String, ui_name='Destinations'}
+Rule.ipset = M.Model{model=IPSetReference, ui_name='IP set'}
+Rule.ipsec = Direction{ui_name='Require IPsec'}
+Rule.service = M.Set{type=M.Reference{scope='/awall/service'}}
+Rule.action = M.String{choice={'accept'}}
+
+
+local PacketLogRule = M.new(Rule)
+PacketLogRule.log = M.Reference{scope='../../log', ui_name='Log class'}
+
+-- TODO no service field
+local PolicyRule = M.new(PacketLogRule)
+PolicyRule.action = M.String{
+ required=true, choice={'accept', 'drop', 'reject', 'tarpit'}
+}
+
+local Limit = M.new()
+Limit.count = M.Integer
+Limit.interval = M.Integer
+Limit.log = M.Reference{scope='../../../log'}
+
+local FilterRule = M.new(PolicyRule)
+FilterRule['conn-limit'] = M.Model{model=Limit, ui_name='Connection limit'}
+FilterRule['flow-limit'] = M.Model{model=Limit, ui_name='Flow limit'}
+FilterRule.dnat = M.net.IPv4Address{ui_name='DNAT target'}
+FilterRule['no-track'] = M.Boolean{default=false, ui_name='CT bypass'}
+FilterRule.related = M.List{type=Rule, ui_name='Related packet rules'}
+
+local DivertRule = M.new(Rule)
+DivertRule['to-port'] = M.Range{type=M.net.Port, ui_name='Target port'}
+
+local NATRule = M.new(DivertRule)
+NATRule['to-addr'] = M.Range{type=M.net.IPv4Address, ui_name='Target address'}
+
+local MarkRule = M.new(Rule)
+MarkRule.mark = M.Integer{required=true}
+
+local ClampMSSRule = M.new(Rule)
+ClampMSSRule.mss = M.Integer{ui_name='MSS'}
+
+
+local AWall = M.new()
+-- TODO differentiate lists?
+AWall.service = M.Collection{type=M.List{type=Service}}
+AWall.zone = M.Collection{type=Zone}
+AWall.log = M.Collection{
+ type=LogClass, ui_name='Log classes', ui_member='Log class'
+}
+AWall.policy = M.List{type=PolicyRule, ui_name='Policies', ui_member='Policy'}
+AWall['packet-log'] = M.List{
+ type=PacketLogRule, ui_name='Logging', ui_member='Logging rule'
+}
+AWall.filter = M.List{type=FilterRule}
+AWall.dnat = M.List{type=NATRule, ui_name='DNAT', ui_member='DNAT rule'}
+AWall.snat = M.List{type=NATRule, ui_name='SNAT', ui_member='SNAT rule'}
+AWall.mark = M.List{
+ type=MarkRule, ui_name='Packet marking', ui_member='Packet marking rule'
+}
+AWall['route-track'] = M.List{
+ type=MarkRule, ui_name='Route tracking', ui_member='Route tracking rule'
+}
+AWall.tproxy = M.List{
+ type=DivertRule,
+ ui_name='Transparent proxy',
+ ui_member='Transparent proxy rule'
+}
+AWall['clamp-mss'] = M.List{
+ type=ClampMSSRule, ui_name='MSS clamping', ui_member='MSS clamping rule'
+}
+AWall['no-track'] = M.List{
+ type=Rule, ui_name='CT bypass', ui_member='Connection tracking bypass rule'
+}
+AWall.ipset = M.Collection{type=IPSet, ui_name='IP sets', ui_member='IP set'}
+
+M.register(
+ 'awall',
+ AWall,
+ {
+ addr='/json'..require('posix').getcwd()..'/config/awall.json',
+ ui_name='Alpine Wall'
+ }
+)
+
+M.permission.defaults('/awall')
diff --git a/acf2/modules/generic.lua b/acf2/modules/generic.lua
new file mode 100644
index 0000000..254add8
--- /dev/null
+++ b/acf2/modules/generic.lua
@@ -0,0 +1,14 @@
+--[[
+Copyright (c) 2012-2013 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+-- provided as an example, to be removed from production version
+
+local M = require('acf2.model')
+
+M.register('proc', M.Mixed, {addr='/files/proc', ui_name='/proc'})
+M.permission.defaults('/proc')
+
+M.register('augeas', M.Mixed, {addr='/augeas'})
+M.permission.defaults('/augeas')
diff --git a/acf2/modules/net.lua b/acf2/modules/net.lua
new file mode 100644
index 0000000..b03ae9d
--- /dev/null
+++ b/acf2/modules/net.lua
@@ -0,0 +1,25 @@
+--[[
+Copyright (c) 2012-2013 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = require('acf2.model')
+
+local Host = M.new()
+Host.address = M.net.IPAddress{addr='ipaddr'}
+Host.canonical = M.String{ui_name='Canonical name'}
+Host.alias = M.Set{type=M.String, ui_name='Aliases', ui_member='Alias'}
+
+local Resolv = M.new()
+Resolv.servers = M.List{type=M.net.IPAddress, addr='nameserver'}
+Resolv['search-domains'] = M.List{type=M.String, addr='search/domain'}
+
+local Net = M.new()
+Net['host-name'] = M.String{addr='/augeas/etc/hostname/hostname'}
+Net.hosts = M.List{type=Host, addr='/augeas/etc/hosts'}
+Net.resolver = M.Model{
+ model=Resolv, addr='/augeas/etc/resolv.conf', ui_name='DNS resolver'
+}
+
+M.register('net', Net, {ui_name='Network'})
+M.permission.defaults('/net')
diff --git a/acf2/object.lua b/acf2/object.lua
new file mode 100644
index 0000000..64fbb9c
--- /dev/null
+++ b/acf2/object.lua
@@ -0,0 +1,68 @@
+--[[
+Copyright (c) 2012-2013 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = {}
+
+function M.class(base)
+ local cls = {}
+
+ local mt = {
+ __call=function(self, ...)
+ local obj = {}
+ setmetatable(obj, {__index=cls, class=cls})
+ obj:init(...)
+ return obj
+ end
+ }
+
+ if not base and M.Object then base = M.Object end
+ if base then
+ cls._base = base
+ mt.__index = base
+ end
+
+ return setmetatable(cls, mt)
+end
+
+
+M.Object = M.class()
+
+function M.Object:init(...) end
+
+
+function M.super(obj, cls)
+ assert(M.isinstance(obj, cls))
+
+ local mt = {}
+
+ function mt.__index(t, k)
+ local v = cls._base[k]
+ if type(v) ~= 'function' then return v end
+ return function(...)
+ local arg = {...}
+ arg[1] = obj
+ return v(unpack(arg))
+ end
+ end
+
+ return setmetatable({}, mt)
+end
+
+
+function M.issubclass(cls1, cls2)
+ if cls1 == cls2 then return true end
+ if cls1._base then return M.issubclass(cls1._base, cls2) end
+ return false
+end
+
+function M.isinstance(obj, cls)
+ if not obj or type(obj) ~= 'table' then return false end
+ local mt = getmetatable(obj)
+ if not mt then return false end
+ return type(obj) == 'table' and mt.class and M.issubclass(mt.class, cls)
+end
+
+
+return M
diff --git a/acf2/path.lua b/acf2/path.lua
new file mode 100644
index 0000000..a31687d
--- /dev/null
+++ b/acf2/path.lua
@@ -0,0 +1,111 @@
+--[[
+Copyright (c) 2012-2013 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = {}
+
+local map = require('acf2.util').map
+
+
+local up = {}
+M.wildcard = {}
+local special = {['..']=up, ['*']=M.wildcard}
+
+
+function M.is_absolute(path) return path:sub(1, 1) == '/' end
+
+
+function M.escape(comp)
+ for symbol, item in pairs(special) do
+ if comp == item then return symbol end
+ end
+ if type(comp) == 'number' then return tostring(comp) end
+ local res = comp:gsub('([\\/])', '\\%1')
+ return (special[res] or tonumber(res)) and '\\'..res or res
+end
+
+
+function M.rawjoin(p1, p2, ...)
+ if not p2 then return p1 end
+ if not M.is_absolute(p2) then p2 = '/'..p2 end
+ return M.rawjoin((p1 == '/' and '' or p1)..p2, ...)
+end
+
+function M.join(parent, ...)
+ return M.rawjoin(parent, unpack(map(M.escape, {...})))
+end
+
+
+function M.split(path)
+ local res = {}
+ local comp = ''
+ local escaped
+
+ local function merge(s)
+ if s > '' then
+ table.insert(res, not escaped and (special[s] or tonumber(s)) or s)
+ end
+ end
+
+ while true do
+ local prefix, sep, suffix = path:match('([^\\/]*)([\\/])(.*)')
+ if not prefix then
+ merge(comp..path)
+ return res
+ end
+
+ comp = comp..prefix
+ if sep == '\\' then
+ comp = comp..suffix:sub(1, 1)
+ escaped = true
+ path = suffix:sub(2, -1)
+ else
+ merge(comp)
+ comp = ''
+ escaped = false
+ path = suffix
+ end
+ end
+end
+
+
+function M.is_unique(path)
+ for _, comp in ipairs(M.split(path)) do
+ if comp == M.wildcard then return false end
+ end
+ return true
+end
+
+
+function M.to_absolute(path, base)
+ if not M.is_absolute(path) then
+ path = base..(base ~= '/' and '/' or '')..path
+ end
+ local comps = M.split(path)
+ local i = 1
+ while i <= #comps do
+ if comps[i] == up then
+ if i == 1 then error('Invalid path: '..path) end
+ table.remove(comps, i - 1)
+ table.remove(comps, i - 1)
+ i = i - 1
+ else i = i + 1 end
+ end
+ return M.join('/', unpack(comps))
+end
+
+
+function M.parent(path)
+ local comps = M.split(path)
+ table.remove(comps)
+ return M.join('/', unpack(comps))
+end
+
+function M.name(path)
+ local comps = M.split(path)
+ return comps[#comps]
+end
+
+
+return M
diff --git a/acf2/persistence/backends/augeas.lua b/acf2/persistence/backends/augeas.lua
new file mode 100644
index 0000000..010a52b
--- /dev/null
+++ b/acf2/persistence/backends/augeas.lua
@@ -0,0 +1,153 @@
+--[[
+Copyright (c) 2012-2013 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local topology = require('acf2.model.root').topology
+local pth = require('acf2.path')
+
+local util = require('acf2.util')
+local copy = util.copy
+
+
+local function aug_path(path) return pth.join('/files', unpack(path)) end
+
+local function strip_name(name)
+ return type(name) == 'string' and name:match('^[^][/=)%s]+') or name
+end
+
+local function ipath(path, index) return path..'['..index..']' end
+
+
+local backend = require('acf2.object').class()
+
+function backend:init() self.aug = require('augeas').init() end
+
+function backend:find(path, leaf)
+ util.map(
+ function(comp)
+ assert(
+ comp == strip_name(comp) and (
+ type(comp) == 'number' or not comp:match('^%.+$')
+ )
+ )
+ end,
+ path
+ )
+ local res = aug_path(path)
+
+ if #self.aug:match(res) == 0 and #path > 1 and leaf then
+ local index = path[#path]
+ if type(index) == 'number' then
+ local ppath = copy(path)
+ table.remove(ppath)
+ ppath = aug_path(ppath)
+
+ if #self.aug:match(ppath) > 0 and #self.aug:match(
+ ppath..'/*'
+ ) == 0 then
+ return ipath(ppath, index), ppath, index
+ end
+ end
+ end
+
+ return res
+end
+
+function backend:get(path, top)
+ local tpe = top and top.type
+ local leaf = tpe and tpe ~= 'table'
+ local apath, mvpath = self:find(path, leaf or not tpe)
+
+ local matches = self.aug:match(apath)
+ if mvpath then
+ assert(#matches < 2)
+ leaf = true
+ end
+
+ if #matches == 0 then return end
+
+ if #matches > 1 then
+ assert(not leaf)
+ local res = {}
+ path = copy(path)
+ for i, _ in ipairs(matches) do
+ table.insert(path, i)
+ if self:get(path) then table.insert(res, i) end
+ table.remove(path)
+ end
+ return res
+ end
+
+ local value = self.aug:get(matches[1])
+ if value then return tpe == 'table' and {1} or value end
+ if leaf then return end
+
+ local names = {}
+ for _, child in ipairs(self.aug:match(apath..'/*')) do
+ names[strip_name(pth.name(child))] = true
+ end
+ return util.keys(names)
+end
+
+function backend:set(mods)
+ local gcpaths = {}
+
+ for _, mod in ipairs(mods) do
+ local path, value = unpack(mod)
+
+ local delete = value == nil
+ self.aug:rm(aug_path(path)..(delete and '' or '/*'))
+
+ local apath, mvpath, index = self:find(path, type(value) ~= 'table')
+ local mpath = mvpath or apath
+
+ if not delete then
+ if #self.aug:match(mpath) == 0 then
+
+ local function order(path)
+ return topology('/augeas'..path).order
+ end
+ local ord = order(pth.join('/', unpack(path)))
+
+ for _, sibling in ipairs(self.aug:match(pth.parent(mpath)..'/*')) do
+ if order(strip_name(sibling):sub(7, -1)) > ord then
+ self.aug:insert(sibling, pth.name(mpath), true)
+ break
+ end
+ end
+ end
+
+ if mvpath then
+ local size = #self.aug:match(mvpath)
+ while size < index do
+ self.aug:insert(ipath(mvpath, size), pth.name(mvpath))
+ size = size + 1
+ end
+ end
+ end
+
+ if type(value) == 'table' then value = nil end
+ if not delete or mvpath then self.aug:set(apath, value)
+ elseif apath > '/' then apath = pth.parent(apath) end
+
+ if delete or value == '' then gcpaths[mpath] = true end
+ end
+
+ local function gc(path)
+ if path == '/' or #self.aug:match(path..'/*') > 0 then return end
+ if self.aug:rm(path.."[. = '']") > 0 then gc(pth.parent(path)) end
+ end
+ for p, _ in pairs(gcpaths) do gc(p) end
+
+ if self.aug:save() ~= 0 then
+ print('Augeas save failed')
+ for _, ep in ipairs(self.aug:match('/augeas//error')) do
+ print(ep, self.aug:get(ep))
+ end
+ assert(false)
+ end
+end
+
+
+return backend
diff --git a/acf2/persistence/backends/files.lua b/acf2/persistence/backends/files.lua
new file mode 100644
index 0000000..70ff33e
--- /dev/null
+++ b/acf2/persistence/backends/files.lua
@@ -0,0 +1,107 @@
+--[[
+Copyright (c) 2012-2013 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local topology = require('acf2.model.root').topology
+local pth = require('acf2.path')
+local util = require('acf2.persistence.util')
+local copy = require('acf2.util').copy
+
+local posix = require('posix')
+local stringy = require('stringy')
+
+
+local function get_scope(top)
+ if not top or top.type ~= 'reference' or not pth.is_unique(top.scope) then
+ return
+ end
+
+ return stringy.startswith(
+ top.scope, '/files/'
+ ) and top.scope:sub(7, -1) or nil
+end
+
+
+local backend = require('acf2.object').class()
+
+-- TODO cache expiration
+function backend:init() self.cache = {} end
+
+function backend:get(path, top)
+ local name = pth.join('/', unpack(path))
+
+ if not self.cache[name] then
+ local t = posix.stat(name, 'type')
+ if not t then return end
+
+ if t == 'regular' then
+ self.cache[name] = util.read_file(name)
+
+ elseif t == 'link' then
+ -- TODO handle relative symlinks
+ local target = posix.readlink(name)
+ assert(target)
+
+ local scope = get_scope(top)
+ assert(scope)
+ scope = scope..'/'
+
+ local slen = scope:len()
+ assert(target:sub(1, slen) == scope)
+ return target:sub(slen + 1, -1)
+
+ elseif t == 'directory' then
+ local res = {}
+ for _, fname in ipairs(posix.dir(name)) do
+ if not ({['.']=true, ['..']=true})[fname] then
+ table.insert(res, pth.name(fname))
+ end
+ end
+ return res
+
+ else error('Unsupported file type: '..name) end
+ end
+
+ return self.cache[name]
+end
+
+function backend:set(mods)
+ for _, mod in pairs(mods) do
+ local path, value = unpack(mod)
+ local name = pth.join('/', unpack(path))
+
+ if value == nil then
+ print('DEL', name)
+
+ local t = posix.stat(name, 'type')
+ if t == 'directory' then
+ assert(posix.rmdir(name))
+ elseif t then assert(os.remove(name)) end
+
+ self.cache[name] = nil
+
+ elseif type(value) == 'table' then
+ assert(posix.mkdir(name))
+
+ else
+ local scope = get_scope(topology('/files'..name))
+
+ if scope then
+ -- TODO use relative symlink
+ os.remove(name)
+ assert(posix.link(pth.to_absolute(value, scope), name, true))
+
+ else
+ local file = util.open_file(name, 'w')
+ file:write(tostring(value))
+ file:close()
+
+ self.cache[name] = value
+ end
+ end
+ end
+end
+
+
+return backend
diff --git a/acf2/persistence/backends/json.lua b/acf2/persistence/backends/json.lua
new file mode 100644
index 0000000..809d947
--- /dev/null
+++ b/acf2/persistence/backends/json.lua
@@ -0,0 +1,79 @@
+--[[
+Copyright (c) 2012-2013 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local pth = require('acf2.path')
+local Cache = require('acf2.persistence.backends.volatile')
+local util = require('acf2.persistence.util')
+local copy = require('acf2.util').copy
+
+local json = require('cjson')
+local posix = require('posix')
+
+
+local backend = require('acf2.object').class()
+
+function backend:init()
+ -- TODO cache expiration
+ self.cache = {}
+ self.dirty = {}
+end
+
+function backend:split_path(path)
+ local fpath = copy(path)
+ local jpath = {}
+ local res
+
+ while #fpath > 0 do
+ local fp = pth.join('/', unpack(fpath))
+ if self.cache[fp] then return fp, jpath end
+ table.insert(jpath, 1, fpath[#fpath])
+ table.remove(fpath)
+ end
+
+ fpath = '/'
+
+ while true do
+ fpath = pth.join(fpath, jpath[1])
+ table.remove(jpath, 1)
+
+ local t = posix.stat(fpath, 'type')
+ if t == 'link' then t = posix.stat(posix.readlink(fpath), 'type') end
+ if not t or not ({directory=true, regular=true})[t] then
+ error('File or directory does not exist: '..fpath)
+ end
+
+ if t == 'regular' then return fpath, jpath end
+
+ assert(#jpath > 0)
+ end
+end
+
+function backend:get(path, top)
+ local fpath, jpath = self:split_path(path)
+ if not self.cache[fpath] then
+ self.cache[fpath] = Cache(json.decode(util.read_file(fpath)))
+ end
+ return self.cache[fpath]:get(jpath, top)
+end
+
+function backend:set(mods)
+ local dirty = {}
+
+ for _, mod in ipairs(mods) do
+ local path, value = unpack(mod)
+ local fpath, jpath = self:split_path(path)
+ self.cache[fpath]:_set(jpath, value)
+ dirty[fpath] = true
+ end
+
+ for path, _ in pairs(dirty) do
+ local file = util.open_file(path, 'w')
+ file:write(json.encode(self.cache[path]:_get{}))
+ file:close()
+ end
+end
+
+
+return backend
diff --git a/acf2/persistence/backends/null.lua b/acf2/persistence/backends/null.lua
new file mode 100644
index 0000000..118f35b
--- /dev/null
+++ b/acf2/persistence/backends/null.lua
@@ -0,0 +1,10 @@
+--[[
+Copyright (c) 2012-2013 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local backend = require('acf2.object').class()
+function backend:get(path, top) if #path == 0 then return {} end end
+function backend:set(mods) end
+
+return backend
diff --git a/acf2/persistence/backends/volatile.lua b/acf2/persistence/backends/volatile.lua
new file mode 100644
index 0000000..e33068d
--- /dev/null
+++ b/acf2/persistence/backends/volatile.lua
@@ -0,0 +1,46 @@
+--[[
+Copyright (c) 2012-2013 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local util = require('acf2.util')
+
+
+local backend = require('acf2.object').class()
+
+function backend:init(data) self.data = data or {} end
+
+function backend:_get(path)
+ local res = self.data
+ for _, comp in ipairs(path) do
+ if res == nil then return end
+ assert(type(res) == 'table')
+ res = res[comp]
+ end
+ return res
+end
+
+function backend:get(path, top)
+ local res = self:_get(path)
+ return type(res) == 'table' and util.keys(res) or res
+end
+
+function backend:_set(path, value)
+ if type(value) == 'table' then value = {} end
+
+ if #path == 0 then self.data = value
+
+ else
+ local comps = util.copy(path)
+ local name = comps[#comps]
+ table.remove(comps)
+ self:_get(comps)[name] = value
+ end
+end
+
+function backend:set(mods)
+ for _, mod in ipairs(mods) do self:_set(unpack(mod)) end
+end
+
+
+return backend
diff --git a/acf2/persistence/init.lua b/acf2/persistence/init.lua
new file mode 100644
index 0000000..1dca61d
--- /dev/null
+++ b/acf2/persistence/init.lua
@@ -0,0 +1,79 @@
+--[[
+Copyright (c) 2012-2013 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = {}
+
+local loadmods = require('acf2.loader')
+local topology = require('acf2.model.root').topology
+local object = require('acf2.object')
+local pth = require('acf2.path')
+local util = require('acf2.util')
+
+local stringy = require('stringy')
+
+
+local DataStore = object.class(
+ require('acf2.transaction.backend').TransactionBackend
+)
+
+function DataStore:init()
+ object.super(self, DataStore):init()
+ self.backends = util.map(
+ function(m) return m() end,
+ loadmods('persistence/backends')
+ )
+end
+
+function DataStore:split_path(path)
+ local comps = pth.split(path)
+ local backend = self.backends[comps[1]]
+ assert(backend)
+ table.remove(comps, 1)
+ return backend, comps
+end
+
+function DataStore:get(path)
+ local backend, comps = self:split_path(path)
+ local top = topology(path)
+
+ local res = backend:get(comps, top)
+
+ if top then
+ local t = top.type
+ if t and res ~= nil then
+ local atype = type(res)
+
+ if t == 'table' then assert(atype == 'table')
+
+ else
+ assert(atype ~= 'table')
+
+ if t == 'string' then res = tostring(res)
+ elseif t == 'number' then res = tonumber(res)
+ elseif t == 'boolean' then
+ res = (res and res ~= 'false') and true or false
+ elseif t == 'reference' then assert(atype == 'string')
+ else assert(false) end
+ end
+ end
+ end
+
+ return util.copy(res), self.mod_time[path] or 0
+end
+
+function DataStore:_set_multiple(mods)
+ local bms = {}
+
+ for _, mod in ipairs(mods) do
+ local path, value = unpack(mod)
+ local backend, comps = self:split_path(path)
+ table.insert(util.setdefault(bms, backend, {}), {comps, value})
+ end
+
+ for backend, bm in pairs(bms) do backend:set(bm) end
+end
+
+
+return DataStore()
diff --git a/acf2/persistence/util.lua b/acf2/persistence/util.lua
new file mode 100644
index 0000000..a657411
--- /dev/null
+++ b/acf2/persistence/util.lua
@@ -0,0 +1,22 @@
+--[[
+Copyright (c) 2012-2013 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = {}
+
+function M.open_file(path, mode)
+ local file = io.open(path, mode)
+ if not file then error('Cannot open file: '..path) end
+ return file
+end
+
+function M.read_file(path)
+ local file = M.open_file(path)
+ local data = ''
+ for line in file:lines() do data = data..line end
+ file:close()
+ return data
+end
+
+return M
diff --git a/acf2/transaction/backend.lua b/acf2/transaction/backend.lua
new file mode 100644
index 0000000..79ea83a
--- /dev/null
+++ b/acf2/transaction/backend.lua
@@ -0,0 +1,70 @@
+--[[
+Copyright (c) 2012-2013 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = {}
+
+local err = require('acf2.error')
+
+-- TODO each transaction backend (i.e. persistence manager or
+-- transaction proper) should be implemented as a thread or have its
+-- internal state stored in shared storage (with appropriate locking)
+
+
+local generation = 0
+function M.gen_number()
+ generation = generation + 1
+ return generation
+end
+
+
+M.TransactionBackend = require('acf2.object').class()
+
+function M.TransactionBackend:init() self.mod_time = {} end
+
+function M.TransactionBackend:get_if_older(path, timestamp)
+ local value, ts = self:get(path)
+ if ts > timestamp then err.raise('conflict', path) end
+ return value, ts
+end
+
+function M.TransactionBackend:set(path, value)
+ self:set_multiple{{path, value}}
+end
+
+function M.TransactionBackend:set_multiple(mods)
+ -- TODO delegate to PM backends?
+ local timestamp = M.gen_number()
+ local effective = {}
+
+ local function tostr(s) return s ~= nil and tostring(s) or nil end
+
+ for _, mod in ipairs(mods) do
+ local path, value = unpack(mod)
+
+ if type(value) == 'table' or type(
+ self:get(path)
+ ) == 'table' or self:get(path) ~= value then
+
+ table.insert(effective, mod)
+ self.mod_time[path] = timestamp
+ end
+ end
+
+ self:_set_multiple(effective)
+end
+
+-- TODO should be atomic, mutex with set_multiple
+function M.TransactionBackend:comp_and_setm(accessed, mods)
+ local errors = err.ErrorDict()
+ for path, timestamp in pairs(accessed) do
+ errors:collect(self.get_if_older, self, path, timestamp)
+ end
+ errors:raise()
+
+ self:set_multiple(mods)
+end
+
+
+return M
diff --git a/acf2/transaction/init.lua b/acf2/transaction/init.lua
new file mode 100644
index 0000000..eb316b5
--- /dev/null
+++ b/acf2/transaction/init.lua
@@ -0,0 +1,210 @@
+--[[
+Copyright (c) 2012-2013 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local ErrorDict = require('acf2.error').ErrorDict
+local root = require('acf2.model.root')
+local object = require('acf2.object')
+local pth = require('acf2.path')
+local be_mod = require('acf2.transaction.backend')
+
+local util = require('acf2.util')
+local copy = util.copy
+
+
+local function remove_list_value(list, value)
+ value = tostring(value)
+
+ for i, v in ipairs(list) do
+ if tostring(v) == value then
+ table.remove(list, i)
+ return
+ end
+ end
+
+ assert(false)
+end
+
+
+local Transaction = object.class(be_mod.TransactionBackend)
+
+function Transaction:init(backend, validate)
+ object.super(self, Transaction):init()
+
+ self.backend = backend
+
+ self.started = be_mod.gen_number()
+ self.access_time = {}
+
+ self.added = {}
+ self.modified = {}
+ self.deleted = {}
+
+ self.validate = validate
+ self.validable = {}
+
+ self.root = root.RootModel(self)
+end
+
+function Transaction:check()
+ if not self.backend then error('Transaction already committed') end
+end
+
+function Transaction:get(path)
+ self:check()
+
+ if self.deleted[path] then return nil, self.mod_time[path] end
+ for _, tbl in ipairs{self.added, self.modified} do
+ if tbl[path] ~= nil then
+ return copy(tbl[path]), self.mod_time[path]
+ end
+ end
+
+ local value, timestamp = self.backend:get_if_older(path, self.started)
+ self.access_time[path] = timestamp
+ return value, timestamp
+end
+
+function Transaction:_set_multiple(mods)
+
+ local function set(path, value, new)
+ local delete = value == nil
+
+ if self.added[path] == nil and (not new or self.deleted[path]) then
+ self.modified[path] = value
+ self.deleted[path] = delete
+ else self.added[path] = value end
+ end
+
+ for _, mod in ipairs(mods) do
+ local path, value = unpack(mod)
+
+ local ppath = pth.parent(path)
+ local parent = self:get(ppath)
+ if parent == nil then
+ parent = {}
+ self:set(ppath, parent)
+ end
+
+ local name = pth.name(path)
+ local old = self:get(path)
+
+ local is_table = type(value) == 'table'
+ local delete = value == nil
+
+ if delete then
+ -- assume one-level refs for now
+ local top = root.topology(ppath)
+ if top then
+ local errors = ErrorDict()
+ for _, refs in ipairs(top.referrers) do
+ for _, ref in ipairs(self.root:search_refs(refs)) do
+ errors:collect(ref.deleted, ref, path)
+ end
+ end
+ errors:raise()
+ end
+ end
+
+ if type(old) == 'table' then
+ if delete then
+ for _, child in ipairs(old) do self:set(pth.join(path, child)) end
+ elseif is_table then return
+ elseif #old > 0 then
+ error('Cannot assign a primitive value to non-leaf node '..path)
+ end
+ end
+
+ if is_table then value = {} end
+ set(path, value, old == nil)
+
+ local function set_parent()
+ set(ppath, parent)
+ self.mod_time[ppath] = self.mod_time[path]
+ end
+
+ if old == nil and not delete then
+ table.insert(parent, name)
+ set_parent()
+ elseif old ~= nil and delete then
+ remove_list_value(parent, name)
+ set_parent()
+ end
+ end
+end
+
+function Transaction:fetch(path) return self.root:fetch(path) end
+
+function Transaction:meta(path) return self.root:meta(path) end
+
+function Transaction:commit()
+ self:check()
+
+ if self.validate then
+ local errors = ErrorDict()
+ for path, addr in pairs(copy(self.validable)) do
+ if self:get(addr) ~= nil then
+ errors:collect(getmetatable(self:fetch(path)).validate)
+ end
+ end
+ errors:raise()
+ end
+
+ local mods = {}
+ local handled = {}
+
+ local function insert(path, value)
+ assert(not handled[path])
+ table.insert(mods, {path, value})
+ handled[path] = true
+ end
+
+ local function insert_add(path)
+ if not handled[path] then
+ local pp = pth.parent(path)
+ if self.added[pp] then insert_add(pp) end
+ insert(path, self.added[path])
+ end
+ end
+
+ local function insert_del(path)
+ if not handled[path] then
+ local value = self.backend:get(path)
+ if type(value) == 'table' then
+ for _, child in ipairs(value) do
+ local cp = pth.join(path, child)
+ assert(self.deleted[cp])
+ insert_del(cp)
+ end
+ end
+ insert(path)
+ end
+ end
+
+ for path, deleted in pairs(self.deleted) do
+ if deleted then insert_del(path) end
+ end
+
+ for path, value in pairs(self.modified) do
+ if type(value) ~= 'table' then insert(path, value) end
+ end
+
+ for path, _ in pairs(self.added) do insert_add(path) end
+
+ self.backend:comp_and_setm(self.access_time, mods)
+
+
+ if not self.validate then
+ util.update(self.backend.validable, self.validable)
+ end
+
+ self.backend = nil
+end
+
+
+local store = require('acf2.persistence')
+
+return function(txn, defer_validation)
+ return Transaction(txn or store, not (txn and defer_validation))
+ end
diff --git a/acf2/util.lua b/acf2/util.lua
new file mode 100644
index 0000000..25e885b
--- /dev/null
+++ b/acf2/util.lua
@@ -0,0 +1,84 @@
+--- ACF2 utility functions.
+--
+-- @module acf2.util
+
+--[[
+Copyright (c) 2012-2013 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+
+local M = {}
+
+--- set default value for a key in a table unless it is already set.
+-- @param t the table
+-- @param k the key
+-- @param v the default value
+-- @return the value `t[k]`
+function M.setdefault(t, k, v)
+ if t[k] == nil then t[k] = v end
+ return t[k]
+end
+
+--- merge a table into another.
+-- Copy values for all keys from `src` to `dst` and optionally keep existing
+-- values.
+-- @param dst the destination table
+-- @param src the source table
+-- @param preserve a boolean. If true then will existing entries in `dst` be
+-- kept.
+-- @return the destination table, `dst`
+function M.update(dst, src, preserve)
+ for k, v in pairs(src) do
+ if not preserve or dst[k] == nil then dst[k] = v end
+ end
+ return dst
+end
+
+--- copy default vaules from one table to another.
+-- Copy all entries in `src` to `dst` but keep any already existing values
+-- in `dst`.
+-- @param dst the destination table
+-- @param src the source table containing the default values
+-- @return the destination table, `dst`
+function M.setdefaults(dst, src) return M.update(dst, src, true) end
+
+--- copy a varable.
+-- If `var` is a table, then the table is cloned, otherwise return the value
+-- of `var`.
+-- @param var the variable to copy
+-- @return a clone of `var`
+function M.copy(var)
+ return type(var) == 'table' and M.setdefaults({}, var) or var
+end
+
+--- extend an array.
+-- inserts all elements in `src` into `dst`
+-- @param dst the destination array
+-- @param src the source array
+-- @return the destination array, `dst`
+function M.extend(dst, src)
+ for _, v in ipairs(src) do table.insert(dst, v) end
+ return dst
+end
+
+--- extract the keys of a table as an array.
+-- @param tbl a table
+-- @return an array of keys
+function M.keys(tbl)
+ local res = {}
+ for k, v in pairs(tbl) do table.insert(res, k) end
+ return res
+end
+
+--- map a function over a table.
+-- @param func a function with one argument
+-- @param tbl the table
+-- @return the transformed table
+function M.map(func, tbl)
+ local res = {}
+ for k, v in pairs(tbl) do res[k] = func(v) end
+ return res
+end
+
+return M